Files
BlueArchiveToolkit/infrastructure/src/bin/bat_official_sync.rs
T
nyaKazuhaandClaude Fable 5 b570f37359 fix(official-sync): 区分代理故障并前置校验代理配置
- 代理故障分类:curl 因代理 407 认证失败(CONNECT 隧道失败)、无法解析/连接
  代理等,原被归为普通可重试网络错误,永不成功也会耗尽 per-URL 重试且无诊断。
  新增 CurlFailureKind::Proxy,从 curl stderr 识别代理故障,per-URL 层 fail-fast
  不重试(daemon error-retry 循环仍整体重试以覆盖代理短暂抖动),并在失败摘要里
  以 kind=proxy 明确标注。
- 前置校验:parse_proxy_config 新增 validate_proxy_url,拒绝拼错的代理 scheme
  (如 htp://),只接受 http/https/socks4/socks4a/socks5/socks5h 或无 scheme
  的 host:port。
- doctor:proxy 检查不再恒 ok,对解析出的代理 URL(含 auto 从环境变量取得的)
  做 scheme 校验,前置暴露配置错误。

新增单测:代理 407/解析失败归为 Proxy 且不重试、源站连接失败仍为 Connect 可重试、
拒绝不支持的 scheme、无 scheme host:port 接受。

对应 issue #18 维护清单 2-5。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 08:18:37 -07:00

5388 lines
191 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.
use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{
gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink,
read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root,
validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE,
};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const EXIT_ERROR: i32 = 1;
const EXIT_LOCKED: i32 = 75;
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
const DEFAULT_DAEMON_STATE_DIR: &str = "/tmp/bat-pid";
const DAEMON_PID_FILE: &str = "bat.pid";
const DAEMON_STATUS_FILE: &str = "bat-status.json";
const DAEMON_LOG_FILE: &str = "bat-daemon.log";
const DAEMON_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl";
const DAEMON_SOCKET_FILE: &str = "bat.sock";
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
/// 后台进程保存代理凭据的专用文件,仅供 restart/reload 复用,永不序列化到状态输出。
const DAEMON_PROXY_SECRET_FILE: &str = "bat-proxy.secret";
/// 代理凭据下传子进程使用的环境变量;避免凭据出现在子进程 argv/proc/<pid>/cmdline)。
const PROXY_URL_ENV_VAR: &str = "BAT_OFFICIAL_SYNC_PROXY_URL";
/// 后台子进程 argv 中用于标记“代理 URL 从环境变量读取”的内部 flag。
const PROXY_FROM_ENV_FLAG: &str = "--proxy-from-env";
const DAEMON_STATUS_VERSION: u32 = 1;
const STRUCTURED_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
const STRUCTURED_LOG_ROTATE_KEEP: usize = 3;
const OFFICIAL_CURRENT_LINK: &str = "current";
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00";
const STARTUP_BANNER: &str = r#"
============================================================
____ _ _ _ _ _____ _ _ _ _
| __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_
| _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __|
| |_) | | |_| | __/ ___ \| | | (__| | | | |\ V / __/ | | (_) | (_) | | <| | |_
|____/|_|\__,_|\__/_/ \_\_| \___|_| |_|_|\_/ \___/ |_|\___/ \___/|_|_|\_\_|\__|
BlueArchiveToolkit
Official Resource Sync
============================================================
"#;
fn main() {
match run() {
Ok(exit_code) if exit_code != 0 => std::process::exit(exit_code),
Ok(_) => {}
Err(error) => {
let error_message = error.to_string();
let exit_code = if is_locked_error(&error_message) {
EXIT_LOCKED
} else {
EXIT_ERROR
};
let payload = ErrorReport {
status: "error",
exit_code,
error: localize_error_message(&error_message),
next_retry_seconds: None,
};
eprintln!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
);
std::process::exit(exit_code);
}
}
}
fn run() -> anyhow::Result<i32> {
let options = parse_args()?;
if matches!(
options.command,
CliCommand::Run | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair
) && !options.daemon
&& options.banner
{
print_startup_banner();
}
match options.command {
CliCommand::Run => {
if options.daemon {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_daemon_start(options)?;
} else if options.watch {
if !options.daemon_child {
assert_no_live_daemon_output_conflict(&options, "watch")?;
}
run_watch(options)?;
} else {
assert_no_live_daemon_output_conflict(&options, "run")?;
let mut logger = ProgressLogger::new(options.progress);
let report =
OfficialUpdateService::new().run_with_progress(&options.config, |event| {
logger.log(event);
})?;
if should_print_status(report.update_status, options.quiet_up_to_date) {
print_report(options.output_format, &report)?;
}
}
Ok(0)
}
CliCommand::Status => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
print_daemon_status(&options.state_dir, options.output_format)?;
Ok(0)
}
CliCommand::Stop => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
stop_daemon(&options.state_dir, options.output_format)?;
Ok(0)
}
CliCommand::Restart => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_daemon_restart(&options, "restart")?;
Ok(0)
}
CliCommand::Reload => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_daemon_restart(&options, "reload")?;
Ok(0)
}
CliCommand::Refresh => {
run_sync_command(&options, "refresh")?;
Ok(0)
}
CliCommand::Verify => {
let healthy = run_verify_command(&options)?;
Ok(if healthy { 0 } else { EXIT_ERROR })
}
CliCommand::Repair => {
run_sync_command(&options, "repair")?;
Ok(0)
}
CliCommand::Logs => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_logs_command(&options)?;
Ok(0)
}
CliCommand::Doctor => {
let healthy = run_doctor_command(&options)?;
Ok(if healthy { 0 } else { EXIT_ERROR })
}
CliCommand::CleanStable => {
run_clean_stable_command(&options)?;
Ok(0)
}
}
}
#[derive(Debug, Serialize)]
struct ErrorReport<'a> {
status: &'a str,
exit_code: i32,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
next_retry_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CliOptions {
config: OfficialUpdateConfig,
command: CliCommand,
output_format: OutputFormat,
watch: bool,
daemon: bool,
daemon_child: bool,
state_dir: PathBuf,
output_explicit: bool,
sync_option_explicit: bool,
proxy_option_explicit: bool,
interval: Duration,
error_retry_interval: Duration,
quiet_up_to_date: bool,
quiet_up_to_date_explicit: bool,
progress: bool,
banner: bool,
tail_lines: usize,
}
impl Default for CliOptions {
fn default() -> Self {
Self {
config: OfficialUpdateConfig::default(),
command: CliCommand::Run,
output_format: OutputFormat::Human,
watch: false,
daemon: false,
daemon_child: false,
state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR),
output_explicit: false,
sync_option_explicit: false,
proxy_option_explicit: false,
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS),
quiet_up_to_date: false,
quiet_up_to_date_explicit: false,
progress: true,
banner: true,
tail_lines: 200,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputFormat {
Human,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliCommand {
Run,
Status,
Stop,
Restart,
Reload,
Refresh,
Verify,
Repair,
Doctor,
Logs,
CleanStable,
}
fn run_watch(options: CliOptions) -> anyhow::Result<()> {
if options.config.dry_run {
return Err(anyhow::anyhow!("watch 常驻模式不能和 --dry-run 同时使用"));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("watch 检查间隔必须大于 0"));
}
if options.error_retry_interval.is_zero() {
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
}
if options.daemon_child {
validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?;
}
let service = OfficialUpdateService::new();
let mut logger = ProgressLogger::new(options.progress);
let daemon_state_dir = options.state_dir.clone();
if options.daemon_child {
logger.attach_structured_log(daemon_structured_log_path(&daemon_state_dir));
}
let daemon_control = if options.daemon_child {
Some(new_daemon_control())
} else {
None
};
let _rpc_server = if let Some(control) = daemon_control.as_ref() {
Some(start_daemon_rpc_server(
&daemon_state_dir,
Arc::clone(control),
)?)
} else {
None
};
let mut next_forced_refresh_at = next_forced_refresh_at_or_after(SystemTime::now());
let mut pending_scheduled_force = false;
let mut pending_rpc_force = false;
logger.log_text(
"watch",
format!(
"每日强制刷新时间:{DAILY_FORCED_REFRESH_LABEL};距离下一次强制刷新还有 {}",
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
loop {
if daemon_control_stop_requested(daemon_control.as_ref()) {
logger.log_text("daemon", "收到停止请求,watch 循环准备退出");
break;
}
let now = SystemTime::now();
if now >= next_forced_refresh_at {
pending_scheduled_force = true;
logger.log_text(
"watch",
format!("已触发固定时间强制刷新:{DAILY_FORCED_REFRESH_LABEL}"),
);
next_forced_refresh_at = next_forced_refresh_after(now);
}
let mut iteration_config = options.config.clone();
if pending_scheduled_force {
iteration_config.force = true;
}
let rpc_force_this_round = pending_rpc_force;
if rpc_force_this_round {
iteration_config.force = true;
pending_rpc_force = false;
}
let mut sleep_for = options.interval;
logger.log_text(
"watch",
if pending_scheduled_force && rpc_force_this_round {
"开始执行 watch 轮次:本轮为固定时间和 RPC 触发的强制刷新"
} else if pending_scheduled_force {
"开始执行 watch 轮次:本轮为固定时间强制刷新"
} else if rpc_force_this_round {
"开始执行 watch 轮次:本轮为 RPC 触发的强制刷新"
} else {
"开始执行 watch 轮次"
},
);
record_daemon_status(
options.daemon_child,
&daemon_state_dir,
&mut logger,
DaemonStatusUpdate {
state: "running",
last_update_status: None,
last_error: None,
next_retry_seconds: None,
last_success_unix_seconds: None,
next_check_unix_seconds: None,
pending_scheduled_force,
next_forced_refresh_at,
},
);
match service.run_with_progress_and_cancellation(
&iteration_config,
|event| {
record_daemon_progress(
options.daemon_child,
&daemon_state_dir,
&mut logger,
&event,
);
logger.log(event);
},
|| daemon_control_stop_requested(daemon_control.as_ref()),
) {
Ok(report) => {
if pending_scheduled_force {
pending_scheduled_force = false;
}
if should_print_status(report.update_status, options.quiet_up_to_date) {
print_report(options.output_format, &report)?;
}
sleep_for =
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
record_daemon_status(
options.daemon_child,
&daemon_state_dir,
&mut logger,
DaemonStatusUpdate {
state: "sleeping",
last_update_status: Some(report.update_status.as_str().to_string()),
last_error: None,
next_retry_seconds: Some(sleep_for.as_secs()),
last_success_unix_seconds: Some(unix_seconds_now()),
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
pending_scheduled_force,
next_forced_refresh_at,
},
);
logger.log_text(
"watch",
format!(
"本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}",
report.update_status.as_str(),
format_duration(sleep_for),
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
}
Err(error) => {
sleep_for = options.error_retry_interval;
sleep_for =
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
record_daemon_status(
options.daemon_child,
&daemon_state_dir,
&mut logger,
DaemonStatusUpdate {
state: "error",
last_update_status: None,
last_error: Some(localize_error_message(&error.to_string())),
next_retry_seconds: Some(sleep_for.as_secs()),
last_success_unix_seconds: None,
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
pending_scheduled_force,
next_forced_refresh_at,
},
);
logger.log_text(
"watch",
format!(
"本轮失败;将在 {} 后重试:{}",
format_duration(sleep_for),
localize_error_message(&error.to_string())
),
);
let payload = ErrorReport {
status: "error",
exit_code: EXIT_ERROR,
error: localize_error_message(&error.to_string()),
next_retry_seconds: Some(sleep_for.as_secs()),
};
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
if daemon_control_stop_requested(daemon_control.as_ref()) {
logger.log_text("daemon", "停止请求已中断本轮同步,watch 循环准备退出");
break;
}
}
}
match wait_for_daemon_wake(daemon_control.as_ref(), sleep_for) {
DaemonWake::Timeout => {}
DaemonWake::Stop => {
logger.log_text("daemon", "收到停止请求,watch 循环准备退出");
break;
}
DaemonWake::Refresh { force } => {
pending_rpc_force = force;
logger.log_text(
"daemon",
if force {
"收到 RPC refresh --force 请求,将尽快执行强制刷新"
} else {
"收到 RPC refresh 请求,将尽快执行刷新检查"
},
);
}
DaemonWake::Reload => {
pending_rpc_force = true;
logger.log_text(
"daemon",
"收到 RPC reload 请求,将尽快重新执行自动发现和强制刷新",
);
}
}
}
record_daemon_status(
options.daemon_child,
&daemon_state_dir,
&mut logger,
DaemonStatusUpdate {
state: "stopped",
last_update_status: None,
last_error: None,
next_retry_seconds: None,
last_success_unix_seconds: None,
next_check_unix_seconds: None,
pending_scheduled_force,
next_forced_refresh_at,
},
);
if options.daemon_child {
let _ = fs::remove_file(daemon_pid_path(&daemon_state_dir));
let _ = fs::remove_file(daemon_socket_path(&daemon_state_dir));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DaemonStatusFile {
version: u32,
pid: u32,
state: String,
resource_output_root: PathBuf,
state_dir: PathBuf,
log_path: PathBuf,
#[serde(default)]
structured_log_path: Option<PathBuf>,
started_unix_seconds: u64,
updated_unix_seconds: u64,
#[serde(default)]
last_success_unix_seconds: Option<u64>,
#[serde(default)]
next_check_unix_seconds: Option<u64>,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
#[serde(default)]
current_stage: Option<String>,
#[serde(default)]
current_message: Option<String>,
#[serde(default)]
download_progress: Option<DaemonDownloadProgress>,
pending_scheduled_force: bool,
next_forced_refresh_unix_seconds: Option<u64>,
command: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DaemonDownloadProgress {
index: usize,
total: usize,
url: String,
status: Option<String>,
bytes: Option<u64>,
transferred_bytes: Option<u64>,
#[serde(default)]
failure_kind: Option<String>,
#[serde(default)]
failure_http_status: Option<u16>,
#[serde(default)]
failure_retryable: Option<bool>,
#[serde(default)]
failure_attempts: Option<usize>,
#[serde(default)]
quarantined: Option<bool>,
}
#[derive(Debug)]
struct DaemonStatusUpdate<'a> {
state: &'a str,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
last_success_unix_seconds: Option<u64>,
next_check_unix_seconds: Option<u64>,
pending_scheduled_force: bool,
next_forced_refresh_at: SystemTime,
}
type DaemonControl = Arc<(Mutex<DaemonControlState>, Condvar)>;
#[derive(Debug, Default)]
struct DaemonControlState {
stop_requested: bool,
refresh_requested: bool,
force_refresh_requested: bool,
reload_requested: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DaemonWake {
Timeout,
Stop,
Refresh { force: bool },
Reload,
}
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
#[allow(dead_code)]
jsonrpc: Option<String>,
id: Option<serde_json::Value>,
method: String,
params: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
struct JsonRpcResponse {
jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JsonRpcError {
code: i32,
message: String,
}
#[derive(Debug, Deserialize)]
struct JsonRpcClientResponse {
#[allow(dead_code)]
jsonrpc: String,
#[allow(dead_code)]
id: Option<serde_json::Value>,
result: Option<serde_json::Value>,
error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
struct DaemonRpcAck {
command: &'static str,
status: &'static str,
message: &'static str,
state_dir: PathBuf,
socket_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
force: Option<bool>,
}
const RPC_METHOD_STATUS: &str = "bat.status";
const RPC_METHOD_STOP: &str = "bat.stop";
const RPC_METHOD_RELOAD: &str = "bat.reload";
const RPC_METHOD_REFRESH: &str = "bat.refresh";
const RPC_METHOD_LOGS: &str = "bat.logs";
fn record_daemon_status(
enabled: bool,
state_dir: &Path,
logger: &mut ProgressLogger,
update: DaemonStatusUpdate<'_>,
) {
if !enabled {
return;
}
if let Err(error) = update_daemon_status(state_dir, update) {
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
}
}
fn record_daemon_progress(
enabled: bool,
state_dir: &Path,
logger: &mut ProgressLogger,
event: &OfficialUpdateProgress,
) {
if !enabled {
return;
}
if let Err(error) = update_daemon_progress(state_dir, event) {
logger.log_text("daemon", format!("更新后台进度失败:{error}"));
}
}
#[derive(Debug)]
struct DaemonRpcServer {
socket_path: PathBuf,
}
impl Drop for DaemonRpcServer {
fn drop(&mut self) {
let _ = fs::remove_file(&self.socket_path);
}
}
#[derive(Debug)]
struct DaemonControlLock {
path: PathBuf,
pid: u32,
}
impl DaemonControlLock {
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let path = daemon_control_lock_path(state_dir);
let pid = std::process::id();
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
file.write_all(pid.to_string().as_bytes())?;
return Ok(Self { path, pid });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
continue;
}
return Err(anyhow::anyhow!(
"后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理",
path.display(),
describe_pid_lock_owner(&path)?
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"获取后台控制锁失败 {}{error}",
path.display()
));
}
}
}
Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display()))
}
}
impl Drop for DaemonControlLock {
fn drop(&mut self) {
let expected = self.pid.to_string();
if fs::symlink_metadata(&self.path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if fs::read_to_string(&self.path)
.map(|contents| contents.trim() == expected)
.unwrap_or(false)
{
let _ = fs::remove_file(&self.path);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PidLockState {
Missing,
Active(u32),
StalePid(u32),
Corrupt,
}
fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(PidLockState::Missing);
}
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Ok(PidLockState::Corrupt);
}
let contents = fs::read_to_string(path)?;
let Some(pid) = parse_pid_value(&contents) else {
return Ok(PidLockState::Corrupt);
};
if process_exists(pid) {
Ok(PidLockState::Active(pid))
} else {
Ok(PidLockState::StalePid(pid))
}
}
fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result<bool> {
match classify_pid_lock_file(path)? {
PidLockState::Missing => Ok(false),
PidLockState::Active(_) => Ok(false),
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(path)?;
Ok(true)
}
}
}
fn describe_pid_lock_owner(path: &Path) -> anyhow::Result<String> {
Ok(match classify_pid_lock_file(path)? {
PidLockState::Missing => "锁文件已不存在".to_string(),
PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"),
PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"),
PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(),
})
}
fn parse_pid_value(contents: &str) -> Option<u32> {
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DaemonResourceConflict {
pid: Option<u32>,
daemon_root: Option<PathBuf>,
target_root: PathBuf,
}
fn assert_no_live_daemon_output_conflict(
options: &CliOptions,
command_name: &str,
) -> anyhow::Result<()> {
if options.daemon || options.daemon_child || options.config.dry_run {
return Ok(());
}
let Some(conflict) =
live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)?
else {
return Ok(());
};
let daemon_root = conflict
.daemon_root
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "未知".to_string());
let pid = conflict
.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "未知".to_string());
Err(anyhow::anyhow!(
"后台同步进程正在管理同一资源目录 (locked)command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源",
daemon_root,
conflict.target_root.display()
))
}
fn live_daemon_resource_conflict(
state_dir: &Path,
output_root: &Path,
) -> anyhow::Result<Option<DaemonResourceConflict>> {
let pid_path = daemon_pid_path(state_dir);
let status_path = daemon_status_path(state_dir);
let pid_from_file = read_pid_file(&pid_path)?;
let rpc_available = daemon_rpc_available(state_dir);
let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false);
let status_file = match read_daemon_status_file(&status_path) {
Ok(status_file) => status_file,
Err(error) if pid_file_live || rpc_available => {
return Err(anyhow::anyhow!(
"后台同步进程可能仍在运行,但状态文件不可解析 (locked){}{error};请先执行 status/stop 或 clean-stable 恢复状态目录",
status_path.display()
));
}
Err(_) => return Ok(None),
};
let status_pid = status_file.as_ref().map(|status| status.pid);
let pid = pid_from_file.or(status_pid);
let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available;
if !live {
return Ok(None);
}
let target_root = normalized_abs_path(output_root)?;
let Some(daemon_root) = status_file
.as_ref()
.map(|status| status.resource_output_root.clone())
else {
return Ok(Some(DaemonResourceConflict {
pid,
daemon_root: None,
target_root,
}));
};
let normalized_daemon_root = normalized_abs_path(&daemon_root)?;
if normalized_daemon_root == target_root {
Ok(Some(DaemonResourceConflict {
pid,
daemon_root: Some(normalized_daemon_root),
target_root,
}))
} else {
Ok(None)
}
}
fn normalized_abs_path(path: &Path) -> anyhow::Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir()?.join(path)
};
Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute)))
}
fn lexically_normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !normalized.pop() {
normalized.push(component.as_os_str());
}
}
_ => normalized.push(component.as_os_str()),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
fn new_daemon_control() -> DaemonControl {
Arc::new((Mutex::new(DaemonControlState::default()), Condvar::new()))
}
fn daemon_control_stop_requested(control: Option<&DaemonControl>) -> bool {
let Some(control) = control else {
return false;
};
let (lock, _) = &**control;
lock.lock()
.map(|state| state.stop_requested)
.unwrap_or(true)
}
fn daemon_control_mark_stop_requested(control: &DaemonControl) {
let (lock, _) = &**control;
if let Ok(mut state) = lock.lock() {
state.stop_requested = true;
}
}
fn daemon_control_notify_all(control: &DaemonControl) {
let (_, cvar) = &**control;
cvar.notify_all();
}
fn daemon_control_request_refresh(control: &DaemonControl, force: bool) {
let (lock, cvar) = &**control;
if let Ok(mut state) = lock.lock() {
state.refresh_requested = true;
state.force_refresh_requested |= force;
}
cvar.notify_all();
}
fn daemon_control_request_reload(control: &DaemonControl) {
let (lock, cvar) = &**control;
if let Ok(mut state) = lock.lock() {
state.reload_requested = true;
}
cvar.notify_all();
}
fn wait_for_daemon_wake(control: Option<&DaemonControl>, timeout: Duration) -> DaemonWake {
let Some(control) = control else {
thread::sleep(timeout);
return DaemonWake::Timeout;
};
let (lock, cvar) = &**control;
let Ok(mut state) = lock.lock() else {
return DaemonWake::Stop;
};
if let Some(wake) = take_daemon_wake(&mut state) {
return wake;
}
let Ok((mut state, _timeout)) = cvar.wait_timeout(state, timeout) else {
return DaemonWake::Stop;
};
take_daemon_wake(&mut state).unwrap_or(DaemonWake::Timeout)
}
fn take_daemon_wake(state: &mut DaemonControlState) -> Option<DaemonWake> {
if state.stop_requested {
return Some(DaemonWake::Stop);
}
if state.reload_requested {
state.reload_requested = false;
state.refresh_requested = false;
state.force_refresh_requested = false;
return Some(DaemonWake::Reload);
}
if state.refresh_requested {
state.refresh_requested = false;
let force = state.force_refresh_requested;
state.force_refresh_requested = false;
return Some(DaemonWake::Refresh { force });
}
None
}
#[cfg(unix)]
fn start_daemon_rpc_server(
state_dir: &Path,
control: DaemonControl,
) -> anyhow::Result<DaemonRpcServer> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let socket_path = daemon_socket_path(state_dir);
if daemon_socket_path_exists(&socket_path)? {
ensure_daemon_socket_not_symlink(&socket_path)?;
match UnixStream::connect(&socket_path) {
Ok(_) => {
return Err(anyhow::anyhow!(
"后台 RPC socket 已被占用:{}",
socket_path.display()
));
}
Err(_) => {
let _ = fs::remove_file(&socket_path);
}
}
}
let listener = UnixListener::bind(&socket_path)?;
let server_state_dir = state_dir.to_path_buf();
thread::Builder::new()
.name("bat-daemon-rpc".to_string())
.spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let state_dir = server_state_dir.clone();
let control = Arc::clone(&control);
let _ = thread::Builder::new()
.name("bat-daemon-rpc-client".to_string())
.spawn(move || handle_daemon_rpc_client(stream, state_dir, control));
}
Err(error) => {
eprintln!("[daemon] RPC socket accept 失败:{error}");
break;
}
}
}
})?;
Ok(DaemonRpcServer { socket_path })
}
#[cfg(not(unix))]
fn start_daemon_rpc_server(
_state_dir: &Path,
_control: DaemonControl,
) -> anyhow::Result<DaemonRpcServer> {
Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台"))
}
#[cfg(unix)]
fn handle_daemon_rpc_client(mut stream: UnixStream, state_dir: PathBuf, control: DaemonControl) {
let Ok(reader_stream) = stream.try_clone() else {
return;
};
let mut reader = BufReader::new(reader_stream);
let mut line = String::new();
loop {
line.clear();
let bytes_read = match reader.read_line(&mut line) {
Ok(bytes_read) => bytes_read,
Err(error) => {
eprintln!("[daemon] RPC socket 读取失败:{error}");
return;
}
};
if bytes_read == 0 {
return;
}
if line.trim().is_empty() {
continue;
}
let mut notify_stop_after_response = false;
let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
Ok(request) => {
notify_stop_after_response = request.method == RPC_METHOD_STOP;
handle_daemon_rpc_request(request, &state_dir, &control)
}
Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")),
};
if let Err(error) = write_json_rpc_response(&mut stream, &response) {
if notify_stop_after_response {
daemon_control_notify_all(&control);
}
eprintln!("[daemon] RPC socket 写入失败:{error}");
return;
}
if notify_stop_after_response {
daemon_control_notify_all(&control);
}
}
}
fn handle_daemon_rpc_request(
request: JsonRpcRequest,
state_dir: &Path,
control: &DaemonControl,
) -> JsonRpcResponse {
let id = request.id.clone();
let result: anyhow::Result<serde_json::Value> = match request.method.as_str() {
RPC_METHOD_STATUS => {
let report = build_daemon_status_report(state_dir);
report.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
}
RPC_METHOD_STOP => {
daemon_control_mark_stop_requested(control);
let _ = update_daemon_state_only(state_dir, "stopping");
serde_json::to_value(DaemonRpcAck {
command: "stop",
status: "accepted",
message: "后台停止请求已发送",
state_dir: state_dir.to_path_buf(),
socket_path: daemon_socket_path(state_dir),
force: None,
})
.map_err(anyhow::Error::from)
}
RPC_METHOD_RELOAD => {
daemon_control_request_reload(control);
serde_json::to_value(DaemonRpcAck {
command: "reload",
status: "accepted",
message: "后台重新加载请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后重新执行自动发现和强制刷新",
state_dir: state_dir.to_path_buf(),
socket_path: daemon_socket_path(state_dir),
force: Some(true),
})
.map_err(anyhow::Error::from)
}
RPC_METHOD_REFRESH => {
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
daemon_control_request_refresh(control, force);
serde_json::to_value(DaemonRpcAck {
command: "refresh",
status: "accepted",
message: if force {
"后台强制刷新请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行"
} else {
"后台刷新检查请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行"
},
state_dir: state_dir.to_path_buf(),
socket_path: daemon_socket_path(state_dir),
force: Some(force),
})
.map_err(anyhow::Error::from)
}
RPC_METHOD_LOGS => {
let tail = match rpc_tail_param(request.params.as_ref(), 200) {
Ok(tail) => tail,
Err(error) => return json_rpc_error(id, -32602, error.to_string()),
};
let report = build_logs_report(state_dir, tail);
report.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
}
_ => {
return json_rpc_error(id, -32601, format!("未知后台 RPC 方法:{}", request.method));
}
};
match result {
Ok(value) => json_rpc_result(id, value),
Err(error) => json_rpc_error(id, -32603, error.to_string()),
}
}
#[cfg(unix)]
fn write_json_rpc_response(
stream: &mut UnixStream,
response: &JsonRpcResponse,
) -> anyhow::Result<()> {
serde_json::to_writer(&mut *stream, response)?;
stream.write_all(b"\n")?;
stream.flush()?;
Ok(())
}
fn json_rpc_result(id: Option<serde_json::Value>, result: serde_json::Value) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
fn json_rpc_error(id: Option<serde_json::Value>, code: i32, message: String) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0",
id,
result: None,
error: Some(JsonRpcError { code, message }),
}
}
fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option<bool> {
params
.and_then(|params| params.get(key))
.and_then(serde_json::Value::as_bool)
}
fn rpc_tail_param(params: Option<&serde_json::Value>, default: usize) -> anyhow::Result<usize> {
let tail = params
.and_then(|params| params.get("tail"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(default as u64);
let tail = usize::try_from(tail)?;
if tail == 0 {
return Err(anyhow::anyhow!("tail 必须大于 0"));
}
Ok(tail)
}
#[cfg(unix)]
fn daemon_rpc_call(
state_dir: &Path,
method: &str,
params: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let socket_path = daemon_socket_path(state_dir);
ensure_daemon_socket_not_symlink(&socket_path)?;
let mut stream = UnixStream::connect(&socket_path).map_err(|error| {
anyhow::anyhow!("无法连接后台 RPC socket {}{error}", socket_path.display())
})?;
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params.unwrap_or(serde_json::Value::Null),
});
serde_json::to_writer(&mut stream, &request)?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line)?;
if line.trim().is_empty() {
return Err(anyhow::anyhow!("后台 RPC socket 未返回响应"));
}
let response: JsonRpcClientResponse = serde_json::from_str(&line)?;
if let Some(error) = response.error {
return Err(anyhow::anyhow!(
"后台 RPC {} 失败:{} ({})",
method,
error.message,
error.code
));
}
Ok(response.result.unwrap_or(serde_json::Value::Null))
}
#[cfg(not(unix))]
fn daemon_rpc_call(
_state_dir: &Path,
_method: &str,
_params: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台"))
}
#[cfg(unix)]
fn daemon_rpc_available(state_dir: &Path) -> bool {
let socket_path = daemon_socket_path(state_dir);
ensure_daemon_socket_not_symlink(&socket_path).is_ok()
&& UnixStream::connect(socket_path).is_ok()
}
#[cfg(not(unix))]
fn daemon_rpc_available(_state_dir: &Path) -> bool {
false
}
#[cfg(unix)]
fn ensure_daemon_socket_not_symlink(socket_path: &Path) -> anyhow::Result<()> {
match fs::symlink_metadata(socket_path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!(
"后台 RPC socket 不能是 symlink{}",
socket_path.display()
)),
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
#[cfg(unix)]
fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result<bool> {
path_exists_no_follow(socket_path).map_err(|error| {
anyhow::anyhow!(
"后台 RPC socket 路径检查失败 {}{error}",
socket_path.display()
)
})
}
#[cfg(not(unix))]
fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result<bool> {
path_exists_no_follow(socket_path)
}
fn path_exists_no_follow(path: &Path) -> anyhow::Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
#[derive(Debug, Serialize)]
struct DaemonStartReport {
status: &'static str,
message: &'static str,
pid: u32,
resource_output_root: PathBuf,
state_dir: PathBuf,
pid_path: PathBuf,
status_path: PathBuf,
log_path: PathBuf,
structured_log_path: PathBuf,
socket_path: PathBuf,
}
#[derive(Debug, Serialize)]
struct DaemonStatusReport {
status: &'static str,
message: &'static str,
running: bool,
pid: Option<u32>,
resource_output_root: Option<PathBuf>,
state_dir: PathBuf,
pid_path: PathBuf,
status_path: PathBuf,
socket_path: PathBuf,
rpc_available: bool,
stale_pid_file: bool,
stale_socket: bool,
log_path: Option<PathBuf>,
structured_log_path: Option<PathBuf>,
rotated_structured_log_paths: Vec<PathBuf>,
started_unix_seconds: Option<u64>,
updated_unix_seconds: Option<u64>,
last_success_unix_seconds: Option<u64>,
next_check_unix_seconds: Option<u64>,
daemon_state: Option<String>,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
current_stage: Option<String>,
current_message: Option<String>,
download_progress: Option<DaemonDownloadProgress>,
version_state_path: Option<PathBuf>,
version_state: Option<OfficialVersionState>,
pending_scheduled_force: Option<bool>,
next_forced_refresh_unix_seconds: Option<u64>,
command: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct DaemonStopReport {
status: &'static str,
message: &'static str,
stopped: bool,
pid: Option<u32>,
state_dir: PathBuf,
pid_path: PathBuf,
socket_path: PathBuf,
}
fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> {
if options.config.dry_run {
return Err(anyhow::anyhow!("daemon 后台模式不能和 --dry-run 同时使用"));
}
if options.watch {
return Err(anyhow::anyhow!(
"--daemon 会自动启动 watch 模式,不要同时传 --watch"
));
}
let report = start_daemon_with_options(&options)?;
print_report(options.output_format, &report)?;
Ok(())
}
fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result<DaemonStartReport> {
let resource_output_root = normalized_abs_path(&options.config.output_root)?;
let state_dir = options.state_dir.clone();
let args = daemon_child_args(options);
let proxy_url = curl_proxy_url(&options.config.curl_proxy);
start_daemon_with_args(
state_dir,
resource_output_root,
args,
proxy_url,
"后台同步已启动",
)
}
fn start_daemon_with_args(
state_dir: PathBuf,
resource_output_root: PathBuf,
args: Vec<String>,
proxy_url: Option<String>,
message: &'static str,
) -> anyhow::Result<DaemonStartReport> {
validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(&state_dir)?;
let pid_path = daemon_pid_path(&state_dir);
let status_path = daemon_status_path(&state_dir);
let log_path = daemon_log_path(&state_dir);
let structured_log_path = daemon_structured_log_path(&state_dir);
let socket_path = daemon_socket_path(&state_dir);
match classify_pid_lock_file(&pid_path)? {
PidLockState::Active(existing_pid) => {
return Err(anyhow::anyhow!(
"官方同步后台进程已经在运行,pid={existing_pid}"
));
}
PidLockState::StalePid(_) | PidLockState::Corrupt => {
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&socket_path);
}
PidLockState::Missing => {}
}
// 代理凭据经环境变量下传子进程(/proc/<pid>/environ 仅属主可读),
// 并写入专用 0600 文件供后续 restart/reload 复用;无代理时清除残留凭据。
match proxy_url.as_deref() {
Some(url) => write_daemon_proxy_secret(&state_dir, url)?,
None => clear_daemon_proxy_secret(&state_dir),
}
let executable = env::current_exe()?;
let log =
open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?;
let log_for_stdout = log.try_clone()?;
let mut command = Command::new(&executable);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::from(log_for_stdout))
.stderr(Stdio::from(log));
match proxy_url.as_deref() {
Some(url) => {
command.env(PROXY_URL_ENV_VAR, url);
}
None => {
command.env_remove(PROXY_URL_ENV_VAR);
}
}
configure_daemon_command(&mut command);
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
// 子进程未能启动,清除刚写入的代理凭据,避免残留。
clear_daemon_proxy_secret(&state_dir);
return Err(error.into());
}
};
let pid = child.id();
if let Err(error) = write_file_atomic(
&pid_path,
pid.to_string().as_bytes(),
PRIVATE_FILE_MODE,
"后台 PID 文件",
)
.map_err(anyhow::Error::msg)
{
let _ = terminate_process(pid);
clear_daemon_proxy_secret(&state_dir);
return Err(error);
}
let mut command = Vec::with_capacity(args.len() + 1);
command.push(executable.to_string_lossy().to_string());
command.extend(args);
let status = DaemonStatusFile {
version: DAEMON_STATUS_VERSION,
pid,
state: "started".to_string(),
resource_output_root: resource_output_root.clone(),
state_dir: state_dir.clone(),
log_path: log_path.clone(),
structured_log_path: Some(structured_log_path.clone()),
started_unix_seconds: unix_seconds_now(),
updated_unix_seconds: unix_seconds_now(),
last_success_unix_seconds: None,
next_check_unix_seconds: None,
last_update_status: None,
last_error: None,
next_retry_seconds: None,
current_stage: None,
current_message: None,
download_progress: None,
pending_scheduled_force: false,
next_forced_refresh_unix_seconds: None,
command,
};
write_daemon_status_file(&status_path, &status)?;
Ok(DaemonStartReport {
status: "started",
message,
pid,
resource_output_root,
state_dir,
pid_path,
status_path,
log_path,
structured_log_path,
socket_path,
})
}
fn print_daemon_status(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> {
if let Ok(report) = daemon_rpc_call(state_dir, RPC_METHOD_STATUS, None) {
print_json_value(output_format, &report)?;
} else {
let report = build_daemon_status_report(state_dir)?;
print_report(output_format, &report)?;
}
Ok(())
}
fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> {
if daemon_rpc_available(state_dir) {
let pid = read_pid_file(&daemon_pid_path(state_dir))?.or_else(|| {
read_daemon_status_file(&daemon_status_path(state_dir))
.ok()
.flatten()
.map(|status| status.pid)
});
if daemon_rpc_call(state_dir, RPC_METHOD_STOP, None).is_ok() {
let forced_signal = if let Some(pid) = pid {
wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?
} else {
false
};
let pid_path = daemon_pid_path(state_dir);
let socket_path = daemon_socket_path(state_dir);
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&socket_path);
let report = DaemonStopReport {
status: if forced_signal {
"stopped_after_signal"
} else {
"stopped"
},
message: if forced_signal {
"后台进程已接收 RPC 停止请求,但未在优雅窗口内退出,已发送 SIGTERM 后停止"
} else {
"后台进程已通过 RPC 停止"
},
stopped: true,
pid,
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path,
};
print_report(output_format, &report)?;
return Ok(());
}
}
let pid_path = daemon_pid_path(state_dir);
let Some(_pid) = read_pid_file(&pid_path)? else {
let report = stop_daemon_inner(state_dir)?;
print_report(output_format, &report)?;
return Ok(());
};
let report = stop_daemon_inner(state_dir)?;
print_report(output_format, &report)?;
Ok(())
}
fn stop_daemon_inner(state_dir: &Path) -> anyhow::Result<DaemonStopReport> {
let pid_path = daemon_pid_path(state_dir);
let pid_state = classify_pid_lock_file(&pid_path)?;
let (pid, running) = match pid_state {
PidLockState::Missing => {
return Ok(DaemonStopReport {
status: "not_running",
message: "后台进程当前未运行",
stopped: false,
pid: None,
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path: daemon_socket_path(state_dir),
});
}
PidLockState::Corrupt => {
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(daemon_socket_path(state_dir));
return Ok(DaemonStopReport {
status: "stale_pid_removed",
message: "已清理无效的后台 PID 文件",
stopped: false,
pid: None,
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path: daemon_socket_path(state_dir),
});
}
PidLockState::StalePid(pid) => (pid, false),
PidLockState::Active(pid) => {
terminate_process(pid)?;
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止"));
}
(pid, true)
}
};
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(daemon_socket_path(state_dir));
Ok(DaemonStopReport {
status: if running {
"stopped"
} else {
"stale_pid_removed"
},
message: if running {
"后台进程已停止"
} else {
"已清理失效的后台 PID 文件"
},
stopped: running,
pid: Some(pid),
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path: daemon_socket_path(state_dir),
})
}
fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusReport> {
let pid_path = daemon_pid_path(state_dir);
let status_path = daemon_status_path(state_dir);
let socket_path = daemon_socket_path(state_dir);
let status_file = read_daemon_status_file(&status_path)?;
let pid_file_state = classify_pid_lock_file(&pid_path)?;
let pid_file_pid = match pid_file_state {
PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid),
PidLockState::Missing | PidLockState::Corrupt => None,
};
let pid = pid_file_pid.or_else(|| status_file.as_ref().map(|status| status.pid));
let running = pid.map(process_exists).unwrap_or(false);
let rpc_available = daemon_rpc_available(state_dir);
let stale_pid_file = matches!(
pid_file_state,
PidLockState::StalePid(_) | PidLockState::Corrupt
);
let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available;
let default_log_path = daemon_log_path(state_dir);
let default_log_exists = path_exists_no_follow(&default_log_path)?;
let structured_log_path = status_file
.as_ref()
.and_then(|status| status.structured_log_path.clone())
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
let version_state_path = status_file.as_ref().map(|status| {
status
.resource_output_root
.join("official-version-state.json")
});
let version_state = version_state_path
.as_ref()
.and_then(|path| read_version_state(path).ok().flatten());
Ok(DaemonStatusReport {
status: if running { "running" } else { "stopped" },
message: if running {
"后台进程正在运行"
} else if stale_pid_file || stale_socket {
"后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理"
} else {
"后台进程当前未运行"
},
running,
pid,
resource_output_root: status_file
.as_ref()
.map(|status| status.resource_output_root.clone()),
state_dir: state_dir.to_path_buf(),
pid_path,
status_path,
socket_path,
rpc_available,
stale_pid_file,
stale_socket,
log_path: status_file
.as_ref()
.map(|status| status.log_path.clone())
.or_else(|| default_log_exists.then_some(default_log_path)),
structured_log_path: structured_log_exists.then_some(structured_log_path),
rotated_structured_log_paths,
started_unix_seconds: status_file
.as_ref()
.map(|status| status.started_unix_seconds),
updated_unix_seconds: status_file
.as_ref()
.map(|status| status.updated_unix_seconds),
last_success_unix_seconds: status_file
.as_ref()
.and_then(|status| status.last_success_unix_seconds),
next_check_unix_seconds: status_file
.as_ref()
.and_then(|status| status.next_check_unix_seconds),
daemon_state: status_file.as_ref().map(|status| status.state.clone()),
last_update_status: status_file
.as_ref()
.and_then(|status| status.last_update_status.clone()),
last_error: status_file
.as_ref()
.and_then(|status| status.last_error.clone()),
next_retry_seconds: status_file
.as_ref()
.and_then(|status| status.next_retry_seconds),
current_stage: status_file
.as_ref()
.and_then(|status| status.current_stage.clone()),
current_message: status_file
.as_ref()
.and_then(|status| status.current_message.clone()),
download_progress: status_file
.as_ref()
.and_then(|status| status.download_progress.clone()),
version_state_path,
version_state,
pending_scheduled_force: status_file
.as_ref()
.map(|status| status.pending_scheduled_force),
next_forced_refresh_unix_seconds: status_file
.as_ref()
.and_then(|status| status.next_forced_refresh_unix_seconds),
command: status_file.map(|status| redact_command_proxy_credentials(status.command)),
})
}
/// Redacts proxy credentials in a saved daemon command before it is surfaced to
/// callers (RPC `status`, `bat status --json`, human output).
///
/// The on-disk status file keeps the raw command because `restart`/`reload`
/// relaunch the daemon from it; only the externally visible copy is redacted so
/// credentials never reach stdout, monitoring, or log collection.
fn redact_command_proxy_credentials(command: Vec<String>) -> Vec<String> {
let mut redacted = command;
for index in 0..redacted.len() {
if redacted[index] == "--proxy" {
if let Some(value) = redacted.get_mut(index + 1) {
*value = redact_proxy_url(value);
}
}
}
redacted
}
#[derive(Debug, Serialize)]
struct DaemonControlReport {
command: &'static str,
status: &'static str,
message: &'static str,
strategy: &'static str,
previous_pid: Option<u32>,
pid: u32,
state_dir: PathBuf,
resource_output_root: PathBuf,
log_path: PathBuf,
socket_path: PathBuf,
}
fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
let has_explicit_options = options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config);
if command_name == "reload" && !has_explicit_options && daemon_rpc_available(&options.state_dir)
{
let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_RELOAD, None)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?;
// 在停止旧后台前读取已保存的代理凭据,供复用型重启还原。
let saved_proxy_url = read_daemon_proxy_secret(&options.state_dir)?;
let status_report = build_daemon_status_report(&options.state_dir)?;
let previous_pid = status_report.pid.filter(|pid| process_exists(*pid));
if previous_pid.is_some() {
if daemon_rpc_available(&options.state_dir)
&& daemon_rpc_call(&options.state_dir, RPC_METHOD_STOP, None).is_ok()
{
if let Some(pid) = previous_pid {
let _ = wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?;
}
let _ = fs::remove_file(daemon_pid_path(&options.state_dir));
let _ = fs::remove_file(daemon_socket_path(&options.state_dir));
} else {
let _ = stop_daemon_inner(&options.state_dir)?;
}
} else if read_pid_file(&daemon_pid_path(&options.state_dir))?.is_some() {
let _ = stop_daemon_inner(&options.state_dir)?;
}
let (state_dir, resource_output_root, args, proxy_url, strategy) = if has_explicit_options {
let mut start_options = options.clone();
start_options.daemon = true;
start_options.watch = false;
(
options.state_dir.clone(),
normalized_abs_path(&options.config.output_root)?,
daemon_child_args(&start_options),
curl_proxy_url(&options.config.curl_proxy),
"start_with_explicit_options",
)
} else {
let status_file = status_file.ok_or_else(|| {
anyhow::anyhow!(
"没有可复用的后台配置;请先执行 bat --auto-discover --daemon,或为 {command_name} 显式传入同步参数"
)
})?;
let mut command = status_file.command.into_iter();
let _executable = command.next().ok_or_else(|| {
anyhow::anyhow!("后台状态文件中的 command 为空,无法执行 {command_name}")
})?;
let args = command.collect::<Vec<_>>();
if args.is_empty() {
return Err(anyhow::anyhow!(
"后台状态文件中的 command 参数为空,无法执行 {command_name}"
));
}
// 复用命令若声明代理从环境变量读取,则必须能从凭据文件还原 URL。
let proxy_url = if args.iter().any(|arg| arg == PROXY_FROM_ENV_FLAG) {
Some(saved_proxy_url.ok_or_else(|| {
anyhow::anyhow!(
"后台配置需要代理凭据但 {DAEMON_PROXY_SECRET_FILE} 缺失;请为 {command_name} 重新传入 --proxy"
)
})?)
} else {
None
};
(
options.state_dir.clone(),
status_file.resource_output_root,
args,
proxy_url,
"restart_with_existing_command",
)
};
let start = start_daemon_with_args(
state_dir.clone(),
resource_output_root.clone(),
args,
proxy_url,
if command_name == "reload" {
"后台配置已重新加载"
} else {
"后台进程已重启"
},
)?;
let report = DaemonControlReport {
command: command_name,
status: if command_name == "reload" {
"reloaded"
} else {
"restarted"
},
message: if command_name == "reload" {
"已使用原后台参数重启进程完成配置重新加载"
} else {
"已停止旧后台进程并启动新后台进程"
},
strategy,
previous_pid,
pid: start.pid,
state_dir,
resource_output_root,
log_path: start.log_path,
socket_path: start.socket_path,
};
print_report(options.output_format, &report)?;
Ok(())
}
#[derive(Debug, Serialize)]
struct CommandReport<T> {
command: &'static str,
status: &'static str,
message: &'static str,
data: T,
}
fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
if refresh_should_use_daemon_rpc(options, command_name)
&& daemon_rpc_available(&options.state_dir)
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call(
&options.state_dir,
RPC_METHOD_REFRESH,
Some(serde_json::json!({ "force": options.config.force })),
)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
assert_no_live_daemon_output_conflict(options, command_name)?;
let mut config = options.config.clone();
if command_name == "repair" {
config.repair = true;
config.force = false;
}
let mut logger = ProgressLogger::new(options.progress);
let report =
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
let command_report = CommandReport {
command: command_name,
status: "completed",
message: if command_name == "repair" {
"资源修复命令已执行"
} else if report.update_status == OfficialUpdateStatus::UpToDate {
"资源已是最新,无需更新"
} else {
"资源刷新命令已执行"
},
data: report,
};
print_report(options.output_format, &command_report)?;
Ok(())
}
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
if command_name != "refresh" {
return false;
}
let defaults = OfficialUpdateConfig::default();
options.command == CliCommand::Refresh
&& !options.watch
&& !options.daemon
&& !options.daemon_child
&& !options.output_explicit
&& !options.proxy_option_explicit
&& options.config.server_info_source.is_none()
&& options.config.connection_group.is_none()
&& options.config.app_version.is_none()
&& options.config.launcher_version == defaults.launcher_version
&& options.config.platforms.is_none()
&& options.config.output_root == defaults.output_root
&& options.config.snapshot_path.is_none()
&& options.config.curl_command == defaults.curl_command
&& options.config.curl_proxy == defaults.curl_proxy
&& options.config.unzip_command == defaults.unzip_command
&& !options.config.dry_run
&& !options.config.plan
&& options.config.audit_local == defaults.audit_local
&& options.config.repair == defaults.repair
}
fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
where
T: Serialize + HumanReport,
{
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
OutputFormat::Human => report.print_human()?,
}
Ok(())
}
fn print_json_value(format: OutputFormat, value: &serde_json::Value) -> anyhow::Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputFormat::Human => print_human_json_value(value)?,
}
Ok(())
}
trait HumanReport {
fn print_human(&self) -> anyhow::Result<()>;
}
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
if value.get("running").is_some() && value.get("state_dir").is_some() {
print_title("后台状态");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "running", "运行中");
print_json_field(value, "pid", "PID");
print_json_field(value, "daemon_state", "后台状态");
print_json_field(value, "rpc_available", "RPC 可用");
print_json_field(value, "stale_pid_file", "失效 PID");
print_json_field(value, "stale_socket", "失效 socket");
print_json_field(value, "last_update_status", "上次同步");
print_json_field(value, "last_success_unix_seconds", "最后成功时间");
print_json_field(value, "last_error", "上次错误");
print_json_field(value, "next_retry_seconds", "下次重试秒数");
print_json_field(value, "next_check_unix_seconds", "下次检查时间");
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_daemon_download_progress_json_summary(value.get("download_progress"));
print_json_field(value, "version_state_path", "版本状态路径");
print_daemon_version_state_json_summary(value.get("version_state"));
print_json_field(value, "resource_output_root", "资源目录");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
print_json_field(value, "log_path", "日志");
print_json_field(value, "structured_log_path", "结构化日志");
print_json_field(value, "rotated_structured_log_paths", "轮转日志");
return Ok(());
}
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
print_title("后台日志");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "log_path", "日志");
print_json_field(value, "bytes", "字节");
print_json_field(value, "total_lines", "总行数");
print_json_field(value, "returned_lines", "返回行数");
if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) {
if !content.is_empty() {
println!();
println!("{content}");
}
}
return Ok(());
}
if value.get("command").is_some() && value.get("status").is_some() {
print_title("后台命令");
print_json_field(value, "command", "命令");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "force", "force");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
return Ok(());
}
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
print_field(
"下载进度",
format_daemon_download_progress_json(value)
.unwrap_or_else(|error| format!("无法解析:{error}")),
);
}
fn format_daemon_download_progress_json(value: &serde_json::Value) -> Result<String, String> {
let progress = serde_json::from_value::<DaemonDownloadProgress>(value.clone())
.map_err(|error| error.to_string())?;
Ok(format_daemon_download_progress(&progress))
}
fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
match serde_json::from_value::<OfficialVersionState>(value.clone()) {
Ok(version_state) => print_daemon_version_state_summary(&version_state),
Err(error) => print_field("版本状态", format!("无法解析:{error}")),
}
}
fn print_daemon_version_state_summary(version_state: &OfficialVersionState) {
print_optional_field(
"当前完成版本",
version_state
.current_completed_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"正在拉取版本",
version_state
.in_progress_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"上一个可用版本",
version_state
.previous_available_version
.as_ref()
.map(|version| version.id.as_str()),
);
let historical_failures = visible_historical_failed_versions(version_state);
print_field("历史失败版本数", historical_failures.len());
if let Some(failed) = historical_failures.last() {
print_field("最近历史失败版本", &failed.version.id);
print_field("最近历史失败时间", failed.failed_unix_seconds);
print_field("最近历史失败原因", &failed.error);
}
}
fn visible_historical_failed_versions(
version_state: &OfficialVersionState,
) -> Vec<&OfficialFailedVersionRecord> {
let in_progress = version_state.in_progress_version.as_ref();
version_state
.failed_versions
.iter()
.filter(|failed| {
!in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version))
})
.collect()
}
fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
left.app_version == right.app_version
&& left.bundle_version == right.bundle_version
&& left.addressables_root == right.addressables_root
}
fn print_json_field(value: &serde_json::Value, key: &str, label: &str) {
let Some(value) = value.get(key) else {
return;
};
if value.is_null() {
return;
}
if let Some(value) = value.as_str() {
print_field(label, value);
} else {
print_field(label, value);
}
}
fn print_title(title: &str) {
println!("{title}");
}
fn print_field(label: &str, value: impl std::fmt::Display) {
println!(" {label:<18} {value}");
}
fn print_optional_field<T>(label: &str, value: Option<T>)
where
T: std::fmt::Display,
{
if let Some(value) = value {
print_field(label, value);
}
}
fn print_path_field(label: &str, value: &Path) {
print_field(label, value.display());
}
fn print_optional_path_field(label: &str, value: Option<&PathBuf>) {
if let Some(value) = value {
print_path_field(label, value);
}
}
fn print_list(label: &str, values: &[String], limit: usize) {
if values.is_empty() {
return;
}
println!(" {label}:");
for value in values.iter().take(limit) {
println!(" - {value}");
}
if values.len() > limit {
println!(" ... 还有 {} 项", values.len() - limit);
}
}
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
let status = progress.status.as_deref().unwrap_or("running");
if status == "failed" {
return format!(
"{}/{} failed kind={} http={} retryable={} attempts={} quarantined={} {}",
progress.index,
progress.total,
progress.failure_kind.as_deref().unwrap_or("unknown"),
progress
.failure_http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
progress
.failure_retryable
.map(|retryable| retryable.to_string())
.unwrap_or_else(|| "unknown".to_string()),
progress
.failure_attempts
.map(|attempts| attempts.to_string())
.unwrap_or_else(|| "0".to_string()),
progress
.quarantined
.map(|quarantined| quarantined.to_string())
.unwrap_or_else(|| "false".to_string()),
progress.url
);
}
format!(
"{}/{} {} {}",
progress.index, progress.total, status, progress.url
)
}
fn print_verification_summary(summary: &OfficialVerificationSummary) {
println!(" 校验摘要:");
for line in verification_summary_lines(summary) {
println!(" - {line}");
}
}
fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec<String> {
vec![
format!(
"官方 .hash 强校验: {} 对 ({})",
summary.official_hash_verified_count, summary.official_hash_scope
),
format!(
"本地 BLAKE3 复用校验: {} 项通过, {} 项需修复 ({})",
summary.local_manifest_blake3_verified_count,
summary.local_manifest_repair_needed_count,
summary.local_manifest_blake3_scope
),
format!(
"ZIP 结构校验: {} 个 ZIP 通过 ({})",
summary.zip_structure_verified_count, summary.zip_structure_scope
),
]
}
fn format_bool(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
fn format_bytes(value: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
let value_f = value as f64;
if value_f >= GIB {
format!("{value_f:.2} GiB", value_f = value_f / GIB)
} else if value_f >= MIB {
format!("{value_f:.2} MiB", value_f = value_f / MIB)
} else if value_f >= KIB {
format!("{value_f:.2} KiB", value_f = value_f / KIB)
} else {
format!("{value} B")
}
}
fn platform_label(platform: PatchPlatform) -> &'static str {
match platform {
PatchPlatform::Windows => "Windows",
PatchPlatform::Android => "Android",
}
}
impl HumanReport for OfficialUpdateReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("官方资源同步");
print_field("状态", self.update_status.as_str());
print_field("应用版本", &self.app_version);
print_optional_field("Bundle 版本", self.bundle_version.as_deref());
print_field("连接组", &self.connection_group);
print_field(
"平台",
self.platforms
.iter()
.map(|platform| platform_label(*platform))
.collect::<Vec<_>>()
.join(", "),
);
print_field("需要下载", format_bool(self.should_download));
print_field("首次同步", format_bool(self.is_initial));
print_field("强制刷新", format_bool(self.force));
print_field("本地审计", format_bool(self.audit_local));
print_field("自动修复", format_bool(self.repair));
print_field("dry-run", format_bool(self.dry_run));
print_path_field("输出根目录", &self.output_root);
print_path_field("active release", &self.active_resource_root);
print_path_field("current", &self.current_path);
print_path_field("version state", &self.version_state_path);
print_optional_path_field("staging", self.staging_path.as_ref());
print_optional_path_field("published", self.published_version_path.as_ref());
print_path_field("snapshot", &self.snapshot_path);
print_path_field("manifest", &self.download_manifest);
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref());
print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool));
print_optional_field("计划 URL 数", self.download_url_count);
print_optional_field("资源数", self.resource_count);
print_field("已下载", self.downloaded_count);
print_field("已续传", self.resumed_count);
print_field("已跳过", self.skipped_count);
print_field("传输量", format_bytes(self.transferred_bytes));
print_field("最终大小", format_bytes(self.final_bytes));
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("需修复", self.local_manifest_repair_needed_count);
print_field("官方 hash 校验", self.official_seed_hash_verified_count);
print_verification_summary(&self.verification_summary);
print_field("catalog marker", self.addressables_marker_checked_count);
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
print_list("计划 URL", &self.download_urls, 8);
Ok(())
}
}
impl<T> HumanReport for CommandReport<T>
where
T: Serialize + HumanReport,
{
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
self.data.print_human()
}
}
impl HumanReport for DaemonStartReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
print_path_field("结构化日志", &self.structured_log_path);
Ok(())
}
}
impl HumanReport for DaemonStatusReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("后台状态");
print_field("状态", self.status);
print_field("消息", self.message);
print_field("运行中", format_bool(self.running));
print_optional_field("PID", self.pid);
print_optional_field("后台状态", self.daemon_state.as_deref());
print_field("RPC 可用", format_bool(self.rpc_available));
print_field("失效 PID", format_bool(self.stale_pid_file));
print_field("失效 socket", format_bool(self.stale_socket));
print_optional_field("上次同步", self.last_update_status.as_deref());
print_optional_field("最后成功时间", self.last_success_unix_seconds);
print_optional_field("上次错误", self.last_error.as_deref());
print_optional_field("下次重试秒数", self.next_retry_seconds);
print_optional_field("下次检查时间", self.next_check_unix_seconds);
print_optional_field("当前阶段", self.current_stage.as_deref());
print_optional_field("当前消息", self.current_message.as_deref());
if let Some(progress) = self.download_progress.as_ref() {
print_field("下载进度", format_daemon_download_progress(progress));
}
if let Some(version_state) = self.version_state.as_ref() {
print_daemon_version_state_summary(version_state);
}
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
print_optional_path_field("版本状态", self.version_state_path.as_ref());
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_optional_path_field("日志", self.log_path.as_ref());
print_optional_path_field("结构化日志", self.structured_log_path.as_ref());
let rotated = self
.rotated_structured_log_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
print_list("轮转日志", &rotated, 5);
Ok(())
}
}
impl HumanReport for DaemonStopReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("已停止", format_bool(self.stopped));
print_optional_field("PID", self.pid);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
impl HumanReport for DaemonControlReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("策略", self.strategy);
print_optional_field("旧 PID", self.previous_pid);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
Ok(())
}
}
impl HumanReport for VerifyCommandReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_field("远端状态", &self.remote_update_status);
print_path_field("校验资源目录", &self.verified_resource_root);
print_optional_field("计划 URL 数", self.planned_url_count);
print_field("计划异常数", self.expected_plan_failure_count);
print_field("本地 manifest 项", self.local_manifest_entry_count);
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("本地失败数", self.local_manifest_failure_count);
print_field("官方 hash 对", self.official_hash_pair_count);
print_field("官方 hash 通过", self.official_hash_verified_count);
print_verification_summary(&self.verification_summary);
if !self.official_hash_errors.is_empty() {
print_list("官方 hash 错误", &self.official_hash_errors, 8);
}
if !self.failures.is_empty() {
println!(" 失败项:");
for item in self.failures.iter().take(12) {
println!(" - {} -> {}", item.status, item.destination.display());
}
if self.failures.len() > 12 {
println!(" ... 还有 {} 项", self.failures.len() - 12);
}
}
Ok(())
}
}
impl HumanReport for LogsReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_path_field("日志", &self.log_path);
print_field("存在", format_bool(self.exists));
print_field("为空", format_bool(self.empty));
print_field("字节", self.bytes);
print_field("总行数", self.total_lines);
print_field("返回行数", self.returned_lines);
if !self.content.is_empty() {
println!();
println!("{}", self.content);
}
Ok(())
}
}
impl HumanReport for DoctorReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for CleanStableReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("资源目录", &self.output_root);
print_path_field("状态目录", &self.state_dir);
if !self.removed_paths.is_empty() {
println!(" 已清理:");
for path in &self.removed_paths {
println!(" - {}", path.display());
}
}
if !self.skipped_paths.is_empty() {
println!(" 已跳过:");
for path in &self.skipped_paths {
println!(" - {}", path.display());
}
}
Ok(())
}
}
impl HumanReport for DaemonRpcAck {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_optional_field("force", self.force.map(format_bool));
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
#[derive(Debug, Serialize)]
struct VerificationItemReport {
url: String,
destination: PathBuf,
status: String,
expected_bytes: Option<u64>,
actual_bytes: Option<u64>,
expected_blake3: Option<String>,
actual_blake3: Option<String>,
zip_error: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerifyCommandReport {
command: &'static str,
status: &'static str,
message: &'static str,
healthy: bool,
remote_update_status: String,
verified_resource_root: PathBuf,
planned_url_count: Option<usize>,
expected_plan_failure_count: usize,
local_manifest_entry_count: usize,
local_manifest_verified_count: usize,
local_manifest_failure_count: usize,
official_hash_pair_count: usize,
official_hash_verified_count: usize,
verification_summary: OfficialVerificationSummary,
official_hash_errors: Vec<String>,
failures: Vec<VerificationItemReport>,
}
fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
let mut config = options.config.clone();
config.dry_run = true;
config.plan = true;
config.audit_local = true;
config.repair = false;
config.force = false;
let mut logger = ProgressLogger::new(options.progress);
let update_report =
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
let verified_resource_root = active_official_resource_root(&config.output_root)?;
let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command(
&verified_resource_root,
&config.curl_command,
)
.with_proxy_config(config.curl_proxy.clone())
.verify_local_download_manifest()
.map_err(anyhow::Error::msg)?;
let failures = verification
.items
.iter()
.filter(|item| !item.status.is_verified())
.map(|item| VerificationItemReport {
url: item.url.clone(),
destination: item.destination.clone(),
status: item.status.as_str().to_string(),
expected_bytes: item.expected_bytes,
actual_bytes: item.actual_bytes,
expected_blake3: item.expected_blake3.clone(),
actual_blake3: item.actual_blake3.clone(),
zip_error: item.zip_error.clone(),
})
.collect::<Vec<_>>();
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
&& update_report.local_manifest_repair_needed_count == 0
&& verification.is_clean();
let verification_summary = OfficialVerificationSummary::new(
verification.manifest_blake3_verified_count(),
verification.failure_count(),
verification.official_hash_verified_count,
verification.zip_structure_verified_count(),
);
let report = VerifyCommandReport {
command: "verify",
status: if healthy { "verified" } else { "failed" },
message: if healthy {
"所有当前计划资源和本地 manifest 均通过验证"
} else {
"发现资源或官方 seed hash 校验异常"
},
healthy,
remote_update_status: update_report.update_status.as_str().to_string(),
verified_resource_root,
planned_url_count: update_report.download_url_count,
expected_plan_failure_count: update_report.local_manifest_repair_needed_count,
local_manifest_entry_count: verification.items.len(),
local_manifest_verified_count: verification.verified_count(),
local_manifest_failure_count: verification.failure_count(),
official_hash_pair_count: verification.official_hash_pair_count,
official_hash_verified_count: verification.official_hash_verified_count,
verification_summary,
official_hash_errors: verification.official_hash_errors,
failures,
};
print_report(options.output_format, &report)?;
Ok(healthy)
}
#[derive(Debug, Serialize)]
struct LogsReport {
command: &'static str,
status: &'static str,
message: &'static str,
log_path: PathBuf,
exists: bool,
empty: bool,
bytes: usize,
total_lines: usize,
returned_lines: usize,
content: String,
}
fn run_logs_command(options: &CliOptions) -> anyhow::Result<()> {
if daemon_rpc_available(&options.state_dir) {
let report = daemon_rpc_call(
&options.state_dir,
RPC_METHOD_LOGS,
Some(serde_json::json!({ "tail": options.tail_lines })),
)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let report = build_logs_report(&options.state_dir, options.tail_lines)?;
print_report(options.output_format, &report)?;
Ok(())
}
fn build_logs_report(state_dir: &Path, tail_lines: usize) -> anyhow::Result<LogsReport> {
let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?;
let log_path = status_file
.as_ref()
.map(|status| status.log_path.clone())
.unwrap_or_else(|| daemon_log_path(state_dir));
let bytes = read_file_no_symlink(&log_path, "后台日志").map_err(anyhow::Error::msg)?;
let exists = bytes.is_some();
let bytes = bytes.unwrap_or_default();
let raw = String::from_utf8_lossy(&bytes);
let lines = raw.lines().collect::<Vec<_>>();
let start = lines.len().saturating_sub(tail_lines);
let content = lines[start..].join("\n");
Ok(LogsReport {
command: "logs",
status: if bytes.is_empty() { "empty" } else { "ok" },
message: if bytes.is_empty() {
"后台日志为空或尚未创建"
} else {
"已读取后台日志"
},
log_path,
exists,
empty: bytes.is_empty(),
bytes: bytes.len(),
total_lines: lines.len(),
returned_lines: lines.len().saturating_sub(start),
content,
})
}
#[derive(Debug, Serialize)]
struct DoctorCheck {
name: &'static str,
ok: bool,
message: String,
}
#[derive(Debug, Serialize)]
struct DoctorReport {
command: &'static str,
status: &'static str,
message: &'static str,
healthy: bool,
checks: Vec<DoctorCheck>,
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let mut checks = vec![
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
),
safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
"资源输出目录安全边界通过",
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
"后台状态目录安全边界通过",
),
command_check("curl", &options.config.curl_command),
proxy_check(&options.config.curl_proxy),
command_check("unzip", &options.config.unzip_command),
];
let pid_path = daemon_pid_path(&options.state_dir);
let daemon_running = read_pid_file(&pid_path)
.ok()
.flatten()
.is_some_and(process_exists);
match read_pid_file(&pid_path) {
Ok(Some(pid)) if process_exists(pid) => checks.push(DoctorCheck {
name: "daemon",
ok: true,
message: format!("后台进程正在运行,pid={pid}"),
}),
Ok(Some(pid)) => checks.push(DoctorCheck {
name: "daemon",
ok: true,
message: format!("发现失效 PID 文件,pid={pid};可执行 clean-stable 清理"),
}),
Ok(None) => checks.push(DoctorCheck {
name: "daemon",
ok: true,
message: "后台进程当前未运行".to_string(),
}),
Err(error) => checks.push(DoctorCheck {
name: "daemon",
ok: false,
message: format!("读取后台 PID 失败:{error}"),
}),
}
let socket_path = daemon_socket_path(&options.state_dir);
let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false);
let socket_available = daemon_rpc_available(&options.state_dir);
checks.push(DoctorCheck {
name: "daemon_rpc",
ok: if daemon_running {
socket_available
} else {
true
},
message: if socket_available {
format!("后台 RPC socket 可连接:{}", socket_path.display())
} else if daemon_running {
format!(
"后台进程正在运行,但 RPC socket 不可连接:{}",
socket_path.display()
)
} else if socket_exists {
format!(
"发现失效后台 RPC socket{};可执行 clean-stable 清理",
socket_path.display()
)
} else {
"后台 RPC socket 尚未创建".to_string()
},
});
let lock_path = options.config.lock_path();
checks.push(match classify_pid_lock_file(&lock_path)? {
PidLockState::Missing => DoctorCheck {
name: "resource_lock",
ok: true,
message: "资源目录没有活动锁".to_string(),
},
PidLockState::Active(pid) => DoctorCheck {
name: "resource_lock",
ok: false,
message: format!(
"资源目录锁正在使用:{}owner_pid={pid}",
lock_path.display()
),
},
PidLockState::StalePid(pid) => DoctorCheck {
name: "resource_lock",
ok: false,
message: format!(
"发现失效资源锁:{}owner_pid={pid};可执行 clean-stable 清理",
lock_path.display()
),
},
PidLockState::Corrupt => DoctorCheck {
name: "resource_lock",
ok: false,
message: format!(
"发现损坏资源锁:{};可执行 clean-stable 清理",
lock_path.display()
),
},
});
let control_lock_path = daemon_control_lock_path(&options.state_dir);
checks.push(match classify_pid_lock_file(&control_lock_path)? {
PidLockState::Missing => DoctorCheck {
name: "daemon_control_lock",
ok: true,
message: "后台控制锁没有活动锁".to_string(),
},
PidLockState::Active(pid) => DoctorCheck {
name: "daemon_control_lock",
ok: true,
message: format!(
"后台控制命令正在运行:{}owner_pid={pid}",
control_lock_path.display()
),
},
PidLockState::StalePid(pid) => DoctorCheck {
name: "daemon_control_lock",
ok: false,
message: format!(
"发现失效后台控制锁:{}owner_pid={pid};可执行 clean-stable 清理",
control_lock_path.display()
),
},
PidLockState::Corrupt => DoctorCheck {
name: "daemon_control_lock",
ok: false,
message: format!(
"发现损坏后台控制锁:{};可执行 clean-stable 清理",
control_lock_path.display()
),
},
});
let status_path = daemon_status_path(&options.state_dir);
checks.push(DoctorCheck {
name: "daemon_status",
ok: match read_daemon_status_file(&status_path) {
Ok(Some(_)) | Ok(None) => true,
Err(_) => false,
},
message: if status_path.exists() {
format!("后台状态文件可解析:{}", status_path.display())
} else {
"后台状态文件尚未创建".to_string()
},
});
let healthy = checks.iter().all(|check| check.ok);
let report = DoctorReport {
command: "doctor",
status: if healthy { "ok" } else { "issues_found" },
message: if healthy {
"运行时诊断通过"
} else {
"运行时诊断发现问题"
},
healthy,
checks,
};
print_report(options.output_format, &report)?;
Ok(healthy)
}
fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck {
let (ok, message) = if path.is_dir() {
(true, format!("{ready_message}{}", path.display()))
} else if path.exists() {
(false, format!("路径存在但不是目录:{}", path.display()))
} else {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
(
parent.is_dir(),
if parent.is_dir() {
format!(
"目录尚未创建,但父目录可用,将在首次运行时创建:{}",
path.display()
)
} else {
format!("目录不存在且父目录不可用:{}", path.display())
},
)
};
DoctorCheck { name, ok, message }
}
fn safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck {
match result {
Ok(()) => DoctorCheck {
name,
ok: true,
message: ok_message.to_string(),
},
Err(error) => DoctorCheck {
name,
ok: false,
message: error,
},
}
}
fn command_check(name: &'static str, command: &Path) -> DoctorCheck {
let result = Command::new(command).arg("--version").output();
DoctorCheck {
name,
ok: result.is_ok(),
message: match result {
Ok(output) => format!(
"命令可执行:{}(退出码={:?}",
command.display(),
output.status.code()
),
Err(error) => format!("命令不可执行 {}{error}", command.display()),
},
}
}
fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck {
let resolved = resolve_curl_proxy(config);
// 对解析出的代理 URL(含 auto 模式从环境变量取得的)做 scheme 校验,
// 让 doctor 能在前置阶段暴露拼错的代理配置,而非恒为 ok。
let (ok, message) = match resolved.url.as_deref() {
Some(url) => match validate_proxy_url(url) {
Ok(()) => (true, resolved.human_summary()),
Err(error) => (false, format!("{}{error}", resolved.human_summary())),
},
None => (true, resolved.human_summary()),
};
DoctorCheck {
name: "proxy",
ok,
message,
}
}
#[derive(Debug, Serialize)]
struct CleanStableReport {
command: &'static str,
status: &'static str,
message: &'static str,
output_root: PathBuf,
state_dir: PathBuf,
removed_paths: Vec<PathBuf>,
skipped_paths: Vec<PathBuf>,
}
fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
validate_output_root(&options.config.output_root).map_err(anyhow::Error::msg)?;
validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?;
let status = build_daemon_status_report(&options.state_dir)?;
if status.running || status.rpc_available {
return Err(anyhow::anyhow!(
"后台进程正在运行,不能执行 clean-stable;请先执行 stop"
));
}
let mut removed_paths = Vec::new();
let mut skipped_paths = Vec::new();
for root in [&options.config.output_root, &options.state_dir] {
collect_transient_files(root, &mut removed_paths)?;
}
let pid_path = daemon_pid_path(&options.state_dir);
match classify_pid_lock_file(&pid_path)? {
PidLockState::Missing => {}
PidLockState::Active(_) => skipped_paths.push(pid_path),
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(&pid_path)?;
removed_paths.push(pid_path);
}
}
let socket_path = daemon_socket_path(&options.state_dir);
if daemon_socket_path_exists(&socket_path)? {
if daemon_rpc_available(&options.state_dir) {
skipped_paths.push(socket_path);
} else {
fs::remove_file(&socket_path)?;
removed_paths.push(socket_path);
}
}
let lock_path = options.config.lock_path();
match classify_pid_lock_file(&lock_path)? {
PidLockState::Missing => {}
PidLockState::Active(_) => skipped_paths.push(lock_path),
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(&lock_path)?;
removed_paths.push(lock_path);
}
}
let control_lock_path = daemon_control_lock_path(&options.state_dir);
match classify_pid_lock_file(&control_lock_path)? {
PidLockState::Missing => {}
PidLockState::Active(pid) => {
return Err(anyhow::anyhow!(
"后台控制命令已被锁定 (locked){}owner_pid={pid} 仍在运行",
control_lock_path.display()
));
}
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(&control_lock_path)?;
removed_paths.push(control_lock_path);
}
}
// 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。
let proxy_secret_path = daemon_proxy_secret_path(&options.state_dir);
if proxy_secret_path.exists() {
fs::remove_file(&proxy_secret_path)?;
removed_paths.push(proxy_secret_path);
}
// 后台已停止,清理未被版本状态引用的孤儿 staging 目录。
if let Some(state) = read_version_state(&options.config.version_state_path())? {
removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?);
}
let report = CleanStableReport {
command: "clean-stable",
status: if skipped_paths.is_empty() {
"cleaned"
} else {
"cleaned_with_skips"
},
message: "已清理断点临时文件、临时状态文件和失效锁/PID;不会删除正式资源",
output_root: options.config.output_root.clone(),
state_dir: options.state_dir.clone(),
removed_paths,
skipped_paths,
};
print_report(options.output_format, &report)?;
Ok(())
}
fn collect_transient_files(root: &Path, removed_paths: &mut Vec<PathBuf>) -> anyhow::Result<()> {
if !root.exists() {
return Ok(());
}
let metadata = fs::symlink_metadata(root)?;
if !metadata.is_dir() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
if metadata.is_dir() {
collect_transient_files(&path, removed_paths)?;
continue;
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if file_name.ends_with(".part") || file_name.ends_with(".tmp") {
fs::remove_file(&path)?;
removed_paths.push(path);
}
}
Ok(())
}
fn read_daemon_status_file(path: &Path) -> anyhow::Result<Option<DaemonStatusFile>> {
let Some(bytes) = read_file_no_symlink(path, "后台状态文件").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
Ok(Some(serde_json::from_slice(&bytes)?))
}
fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> {
write_file_atomic(
path,
&serde_json::to_vec_pretty(status)?,
PRIVATE_FILE_MODE,
"后台状态文件",
)
.map_err(anyhow::Error::msg)?;
Ok(())
}
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
let status_path = daemon_status_path(state_dir);
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
version: DAEMON_STATUS_VERSION,
pid: std::process::id(),
state: "started".to_string(),
resource_output_root: OfficialUpdateConfig::default().output_root,
state_dir: state_dir.to_path_buf(),
log_path: daemon_log_path(state_dir),
structured_log_path: Some(daemon_structured_log_path(state_dir)),
started_unix_seconds: unix_seconds_now(),
updated_unix_seconds: unix_seconds_now(),
last_success_unix_seconds: None,
next_check_unix_seconds: None,
last_update_status: None,
last_error: None,
next_retry_seconds: None,
current_stage: None,
current_message: None,
download_progress: None,
pending_scheduled_force: false,
next_forced_refresh_unix_seconds: None,
command: env::args().collect(),
});
status.pid = std::process::id();
status.state = update.state.to_string();
status.updated_unix_seconds = unix_seconds_now();
status.last_update_status = update.last_update_status;
status.last_error = update.last_error;
status.next_retry_seconds = update.next_retry_seconds;
if update.last_success_unix_seconds.is_some() {
status.last_success_unix_seconds = update.last_success_unix_seconds;
}
status.next_check_unix_seconds = update.next_check_unix_seconds;
if update.state != "running" {
status.current_stage = None;
status.current_message = None;
status.download_progress = None;
}
status.pending_scheduled_force = update.pending_scheduled_force;
status.next_forced_refresh_unix_seconds =
system_time_to_unix_seconds(update.next_forced_refresh_at);
write_daemon_status_file(&status_path, &status)
}
fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> anyhow::Result<()> {
let status_path = daemon_status_path(state_dir);
let Some(mut status) = read_daemon_status_file(&status_path)? else {
return Ok(());
};
status.pid = std::process::id();
status.updated_unix_seconds = unix_seconds_now();
status.current_stage = Some(event.stage.to_string());
status.current_message = Some(event.message.clone());
status.download_progress = match (event.download_index, event.download_total) {
(Some(index), Some(total)) => Some(DaemonDownloadProgress {
index,
total,
url: event.download_url.clone().unwrap_or_default(),
status: event.download_status.clone(),
bytes: event.download_bytes,
transferred_bytes: event.download_transferred_bytes,
failure_kind: event.download_failure_kind.clone(),
failure_http_status: event.download_failure_http_status,
failure_retryable: event.download_failure_retryable,
failure_attempts: event.download_failure_attempts,
quarantined: event.download_quarantined,
}),
_ if event.stage != "download" => None,
_ => status.download_progress,
};
write_daemon_status_file(&status_path, &status)
}
fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> {
let status_path = daemon_status_path(state_dir);
let Some(mut status) = read_daemon_status_file(&status_path)? else {
return Ok(());
};
status.pid = std::process::id();
status.state = state.to_string();
status.updated_unix_seconds = unix_seconds_now();
status.next_retry_seconds = None;
write_daemon_status_file(&status_path, &status)
}
fn read_pid_file(path: &Path) -> anyhow::Result<Option<u32>> {
let Some(bytes) = read_file_no_symlink(path, "后台 PID 文件").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let contents = String::from_utf8(bytes)?;
Ok(contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0))
}
fn daemon_pid_path(output_root: &Path) -> PathBuf {
output_root.join(DAEMON_PID_FILE)
}
fn daemon_status_path(output_root: &Path) -> PathBuf {
output_root.join(DAEMON_STATUS_FILE)
}
fn daemon_log_path(output_root: &Path) -> PathBuf {
output_root.join(DAEMON_LOG_FILE)
}
fn daemon_structured_log_path(output_root: &Path) -> PathBuf {
output_root.join(DAEMON_STRUCTURED_LOG_FILE)
}
fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(DAEMON_STRUCTURED_LOG_FILE);
path.with_file_name(format!("{file_name}.{index}"))
}
fn rotated_structured_log_paths(path: &Path) -> Vec<PathBuf> {
(1..=STRUCTURED_LOG_ROTATE_KEEP)
.map(|index| rotated_structured_log_path(path, index))
.filter(|path| path_exists_no_follow(path).unwrap_or(false))
.collect()
}
fn daemon_socket_path(output_root: &Path) -> PathBuf {
output_root.join(DAEMON_SOCKET_FILE)
}
fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
output_root.join(DAEMON_CONTROL_LOCK_FILE)
}
fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_PROXY_SECRET_FILE)
}
/// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。
fn curl_proxy_url(config: &CurlProxyConfig) -> Option<String> {
match config.mode() {
CurlProxyMode::Url(url) => Some(url.clone()),
CurlProxyMode::Auto | CurlProxyMode::Disabled => None,
}
}
/// 将代理凭据写入专用 0600 文件,供 restart/reload 复用;该文件永不进入状态输出。
fn write_daemon_proxy_secret(state_dir: &Path, url: &str) -> anyhow::Result<()> {
write_file_atomic(
&daemon_proxy_secret_path(state_dir),
url.as_bytes(),
PRIVATE_FILE_MODE,
"代理凭据文件",
)
.map_err(anyhow::Error::msg)
}
/// 删除代理凭据文件(best-effort);无代理或拆除后台时调用。
fn clear_daemon_proxy_secret(state_dir: &Path) {
let _ = fs::remove_file(daemon_proxy_secret_path(state_dir));
}
/// 读取代理凭据文件;缺失或为空返回 None。
fn read_daemon_proxy_secret(state_dir: &Path) -> anyhow::Result<Option<String>> {
let Some(bytes) = read_file_no_symlink(&daemon_proxy_secret_path(state_dir), "代理凭据文件")
.map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let url =
String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("代理凭据文件不是有效 UTF-8"))?;
let trimmed = url.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
fn active_official_resource_root(output_root: &Path) -> anyhow::Result<PathBuf> {
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
let metadata = match fs::symlink_metadata(&current_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(output_root.to_path_buf());
}
Err(error) => return Err(error.into()),
};
if !metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"current 已存在但不是 symlink{}",
current_path.display()
));
}
let target = fs::read_link(&current_path)?;
let target = if target.is_absolute() {
target
} else {
output_root.join(target)
};
lexical_absolute(&target).map_err(anyhow::Error::msg)
}
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
let mut args = Vec::new();
let config = &options.config;
if config.auto_discover {
args.push("--auto-discover".to_string());
}
if let Some(source) = config.server_info_source.as_ref() {
match source {
OfficialServerInfoSource::LocalPath(path) => {
args.push("--server-info-path".to_string());
args.push(path.to_string_lossy().to_string());
}
OfficialServerInfoSource::OfficialFile(name) => {
args.push("--server-info-file".to_string());
args.push(name.clone());
}
OfficialServerInfoSource::OfficialUrl(url) => {
args.push("--server-info-url".to_string());
args.push(url.clone());
}
}
}
if let Some(connection_group) = config.connection_group.as_ref() {
args.push("--connection-group".to_string());
args.push(connection_group.clone());
}
if let Some(app_version) = config.app_version.as_ref() {
args.push("--app-version".to_string());
args.push(app_version.clone());
}
args.push("--launcher-version".to_string());
args.push(config.launcher_version.clone());
if let Some(platforms) = config.platforms.as_ref() {
args.push("--platforms".to_string());
args.push(
platforms
.iter()
.map(|platform| match platform {
PatchPlatform::Windows => "Windows",
PatchPlatform::Android => "Android",
})
.collect::<Vec<_>>()
.join(","),
);
}
args.push("--output".to_string());
args.push(config.output_root.to_string_lossy().to_string());
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
args.push("--snapshot".to_string());
args.push(snapshot_path.to_string_lossy().to_string());
}
args.push("--curl".to_string());
args.push(config.curl_command.to_string_lossy().to_string());
match config.curl_proxy.mode() {
CurlProxyMode::Auto if options.proxy_option_explicit => {
args.push("--proxy".to_string());
args.push("auto".to_string());
}
CurlProxyMode::Auto => {}
CurlProxyMode::Disabled => {
args.push("--no-proxy".to_string());
}
CurlProxyMode::Url(_) => {
// 凭据经 PROXY_URL_ENV_VAR 环境变量下传子进程;此处只放不含凭据的 flag,
// 避免代理 URL 进入子进程 argv/proc/<pid>/cmdline)与状态文件 command 字段。
args.push(PROXY_FROM_ENV_FLAG.to_string());
}
}
args.push("--unzip".to_string());
args.push(config.unzip_command.to_string_lossy().to_string());
if config.force {
args.push("--force".to_string());
}
if config.audit_local {
args.push("--audit-local".to_string());
} else {
args.push("--no-audit-local".to_string());
}
if config.repair {
args.push("--repair".to_string());
} else {
args.push("--no-repair".to_string());
}
args.push("--state-dir".to_string());
args.push(options.state_dir.to_string_lossy().to_string());
args.push("--daemon-child".to_string());
args.push("--watch".to_string());
args.push("--interval".to_string());
args.push(format_duration_arg(options.interval));
args.push("--error-retry".to_string());
args.push(format_duration_arg(options.error_retry_interval));
if options.quiet_up_to_date {
args.push("--quiet-up-to-date".to_string());
} else {
args.push("--no-quiet-up-to-date".to_string());
}
if options.progress {
args.push("--progress".to_string());
} else {
args.push("--no-progress".to_string());
}
if options.output_format == OutputFormat::Json {
args.push("--json".to_string());
}
if options.banner {
args.push("--banner".to_string());
} else {
args.push("--no-banner".to_string());
}
args
}
fn format_duration_arg(duration: Duration) -> String {
let millis = duration.as_millis();
if millis.is_multiple_of(3_600_000) {
format!("{}h", millis / 3_600_000)
} else if millis.is_multiple_of(60_000) {
format!("{}m", millis / 60_000)
} else if millis.is_multiple_of(1_000) {
format!("{}s", millis / 1_000)
} else {
format!("{millis}ms")
}
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unix_seconds_after(duration: Duration) -> u64 {
unix_seconds_now().saturating_add(duration.as_secs())
}
fn system_time_to_unix_seconds(time: SystemTime) -> Option<u64> {
time.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
}
#[cfg(unix)]
fn configure_daemon_command(command: &mut Command) {
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
if libc::setsid() < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
});
}
}
#[cfg(not(unix))]
fn configure_daemon_command(_command: &mut Command) {}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
#[cfg(not(unix))]
fn process_exists(_pid: u32) -> bool {
true
}
#[cfg(unix)]
fn terminate_process(pid: u32) -> anyhow::Result<()> {
let pid = libc::pid_t::try_from(pid)?;
if pid <= 0 {
return Err(anyhow::anyhow!("无效的后台进程 pid"));
}
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error().into())
}
}
#[cfg(not(unix))]
fn terminate_process(_pid: u32) -> anyhow::Result<()> {
Err(anyhow::anyhow!("stop 目前只支持 Unix/Linux 平台"))
}
fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool {
let started_at = Instant::now();
while started_at.elapsed() < timeout {
if !process_exists(pid) {
return true;
}
thread::sleep(Duration::from_millis(100));
}
!process_exists(pid)
}
fn wait_for_rpc_stop_or_terminate(pid: u32, timeout: Duration) -> anyhow::Result<bool> {
if wait_for_process_exit(pid, timeout) {
return Ok(false);
}
terminate_process(pid)?;
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 SIGTERM 后仍未停止"));
}
Ok(true)
}
fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime {
forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter)
}
fn next_forced_refresh_after(now: SystemTime) -> SystemTime {
forced_refresh_time(now, ForcedRefreshBoundary::After)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ForcedRefreshBoundary {
AtOrAfter,
After,
}
fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime {
let local_seconds = beijing_local_seconds_since_epoch(now);
let local_day = local_seconds / SECONDS_PER_DAY;
let local_second_of_day = local_seconds % SECONDS_PER_DAY;
for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS {
let matches_boundary = match boundary {
ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day,
ForcedRefreshBoundary::After => scheduled_second > local_second_of_day,
};
if matches_boundary {
return system_time_from_beijing_local_seconds(
local_day
.saturating_mul(SECONDS_PER_DAY)
.saturating_add(scheduled_second),
);
}
}
system_time_from_beijing_local_seconds(
local_day
.saturating_add(1)
.saturating_mul(SECONDS_PER_DAY)
.saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]),
)
}
fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_add(BEIJING_UTC_OFFSET_SECONDS)
}
fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS))
}
fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration {
deadline.duration_since(now).unwrap_or_default()
}
#[derive(Debug, Clone)]
struct ProgressLogger {
enabled: bool,
started_at: Instant,
structured: Option<RotatingStructuredLogger>,
}
impl ProgressLogger {
fn new(enabled: bool) -> Self {
Self {
enabled,
started_at: Instant::now(),
structured: None,
}
}
fn attach_structured_log(&mut self, path: PathBuf) {
self.structured = Some(RotatingStructuredLogger::new(
path,
STRUCTURED_LOG_MAX_BYTES,
STRUCTURED_LOG_ROTATE_KEEP,
));
}
fn log(&mut self, event: OfficialUpdateProgress) {
self.log_event(&event);
}
fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
self.log_text_inner(stage, message.as_ref());
}
fn log_event(&mut self, event: &OfficialUpdateProgress) {
if self.enabled {
eprintln!(
"[+{} 信息] [{}] {}",
format_duration(self.started_at.elapsed()),
localized_stage(event.stage),
event.message
);
}
if let Some(structured) = self.structured.as_mut() {
let _ = structured.write_event(self.started_at.elapsed(), event);
}
}
fn log_text_inner(&mut self, stage: &str, message: &str) {
if !self.enabled {
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
return;
}
eprintln!(
"[+{} 信息] [{}] {}",
format_duration(self.started_at.elapsed()),
localized_stage(stage),
message
);
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
}
}
#[derive(Debug, Clone)]
struct RotatingStructuredLogger {
path: PathBuf,
max_bytes: u64,
keep: usize,
}
impl RotatingStructuredLogger {
fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
Self {
path,
max_bytes,
keep,
}
}
fn write_event(
&mut self,
elapsed: Duration,
event: &OfficialUpdateProgress,
) -> anyhow::Result<()> {
let payload = serde_json::json!({
"timestamp_unix_seconds": unix_seconds_now(),
"elapsed_ms": elapsed.as_millis(),
"level": "info",
"stage": event.stage,
"stage_label": localized_stage(event.stage),
"message": event.message.as_str(),
"download": event.download_index.map(|index| serde_json::json!({
"index": index,
"total": event.download_total.unwrap_or(index),
"url": event.download_url.as_deref(),
"status": event.download_status.as_deref(),
"bytes": event.download_bytes,
"transferred_bytes": event.download_transferred_bytes,
"failure_kind": event.download_failure_kind.as_deref(),
"failure_http_status": event.download_failure_http_status,
"failure_retryable": event.download_failure_retryable,
"failure_attempts": event.download_failure_attempts,
"quarantined": event.download_quarantined,
})),
});
let mut line = serde_json::to_vec(&payload)?;
line.push(b'\n');
self.rotate_if_needed(line.len() as u64)?;
let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志")
.map_err(anyhow::Error::msg)?;
file.write_all(&line)?;
file.flush()?;
Ok(())
}
fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> {
let current_len = match fs::symlink_metadata(&self.path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(anyhow::anyhow!(
"结构化日志不能是 symlink{}",
self.path.display()
))
}
Ok(metadata) if metadata.is_file() => metadata.len(),
Ok(_) => {
return Err(anyhow::anyhow!(
"结构化日志已存在但不是普通文件:{}",
self.path.display()
))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
Err(error) => return Err(error.into()),
};
if current_len.saturating_add(incoming_bytes) <= self.max_bytes {
return Ok(());
}
for index in (1..=self.keep).rev() {
let from = if index == 1 {
self.path.clone()
} else {
rotated_structured_log_path(&self.path, index - 1)
};
let to = rotated_structured_log_path(&self.path, index);
if !path_exists_no_follow(&from)? {
continue;
}
if path_exists_no_follow(&to)? {
fs::remove_file(&to)?;
}
fs::rename(&from, &to)?;
}
Ok(())
}
}
fn stage_to_static(stage: &str) -> &'static str {
match stage {
"start" => "start",
"lock" => "lock",
"bootstrap" => "bootstrap",
"launcher" => "launcher",
"bootstrap-cache" => "bootstrap-cache",
"game-main-config" => "game-main-config",
"metadata" => "metadata",
"server-info" => "server-info",
"discovery" => "discovery",
"markers" => "markers",
"marker" => "marker",
"catalog" => "catalog",
"local-state" => "local-state",
"audit" => "audit",
"decision" => "decision",
"plan" => "plan",
"download" => "download",
"snapshot" => "snapshot",
"publish" => "publish",
"finish" => "finish",
"watch" => "watch",
"daemon" => "daemon",
"dry-run" => "dry-run",
_ => "log",
}
}
fn localized_stage(stage: &str) -> &str {
match stage {
"start" => "启动",
"lock" => "锁",
"bootstrap" => "启动发现",
"launcher" => "启动器",
"bootstrap-cache" => "启动缓存",
"game-main-config" => "游戏配置",
"metadata" => "元数据",
"server-info" => "服务器信息",
"discovery" => "发现",
"markers" => "标记",
"marker" => "标记",
"snapshot" => "快照",
"decision" => "决策",
"plan" => "计划",
"catalog" => "目录",
"inventory" => "清单",
"local-state" => "本地状态",
"audit" => "审计",
"dry-run" => "试运行",
"download" => "下载",
"publish" => "发布",
"resource" => "资源",
"finish" => "完成",
"watch" => "常驻",
"daemon" => "后台",
_ => stage,
}
}
fn is_locked_error(message: &str) -> bool {
message.contains("locked") || message.contains("锁定")
}
fn localize_error_message(message: &str) -> String {
message.to_string()
}
fn print_startup_banner() {
eprintln!("{STARTUP_BANNER}");
}
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
}
fn parse_args() -> anyhow::Result<CliOptions> {
parse_args_from(env::args())
}
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliOptions> {
let mut args = raw_args.into_iter();
let binary = args.next().unwrap_or_else(|| "bat".to_string());
let mut options = CliOptions::default();
while let Some(flag) = args.next() {
match flag.as_str() {
"status" => {
ensure_command_not_set(options.command, "status")?;
options.command = CliCommand::Status;
}
"stop" => {
ensure_command_not_set(options.command, "stop")?;
options.command = CliCommand::Stop;
}
"restart" => {
ensure_command_not_set(options.command, "restart")?;
options.command = CliCommand::Restart;
}
"reload" => {
ensure_command_not_set(options.command, "reload")?;
options.command = CliCommand::Reload;
}
"refresh" => {
ensure_command_not_set(options.command, "refresh")?;
options.command = CliCommand::Refresh;
}
"verify" => {
ensure_command_not_set(options.command, "verify")?;
options.command = CliCommand::Verify;
}
"repair" => {
ensure_command_not_set(options.command, "repair")?;
options.command = CliCommand::Repair;
}
"doctor" => {
ensure_command_not_set(options.command, "doctor")?;
options.command = CliCommand::Doctor;
}
"logs" => {
ensure_command_not_set(options.command, "logs")?;
options.command = CliCommand::Logs;
}
"clean-stable" => {
ensure_command_not_set(options.command, "clean-stable")?;
options.command = CliCommand::CleanStable;
}
"--auto-discover" => {
options.config.auto_discover = true;
options.sync_option_explicit = true;
}
"--server-info-url" => {
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialUrl(
next_option_value(&mut args, &flag)?,
));
options.sync_option_explicit = true;
}
"--server-info-file" => {
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialFile(
next_option_value(&mut args, &flag)?,
));
options.sync_option_explicit = true;
}
"--server-info-path" => {
options.config.server_info_source = Some(OfficialServerInfoSource::LocalPath(
PathBuf::from(next_option_value(&mut args, &flag)?),
));
options.sync_option_explicit = true;
}
"--connection-group" => {
options.config.connection_group = Some(next_option_value(&mut args, &flag)?);
options.sync_option_explicit = true;
}
"--app-version" => {
options.config.app_version = Some(next_option_value(&mut args, &flag)?);
options.sync_option_explicit = true;
}
"--launcher-version" => {
options.config.launcher_version = next_option_value(&mut args, &flag)?;
options.sync_option_explicit = true;
}
"--platforms" => {
let value = next_option_value(&mut args, &flag)?;
options.config.platforms =
Some(parse_platforms(&value).map_err(anyhow::Error::msg)?);
options.sync_option_explicit = true;
}
"--output" => {
options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?);
options.output_explicit = true;
}
"--state-dir" | "--pid-dir" => {
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--snapshot" => {
options.config.snapshot_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.sync_option_explicit = true;
}
"--curl" => {
options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--proxy" => {
options.config.curl_proxy =
parse_proxy_config(&next_option_value(&mut args, &flag)?)?;
options.proxy_option_explicit = true;
}
"--no-proxy" => {
options.config.curl_proxy = CurlProxyConfig::disabled();
options.proxy_option_explicit = true;
}
"--proxy-from-env" => {
// 后台子进程内部 flag:代理凭据从环境变量读取,随后立即清除,
// 避免被 curl 等孙进程继承。
let url = env::var(PROXY_URL_ENV_VAR).map_err(|_| {
anyhow::anyhow!("{PROXY_FROM_ENV_FLAG} 需要设置 {PROXY_URL_ENV_VAR} 环境变量")
})?;
env::remove_var(PROXY_URL_ENV_VAR);
options.config.curl_proxy = CurlProxyConfig::url(url);
options.proxy_option_explicit = true;
}
"--unzip" => {
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--dry-run" => {
options.config.dry_run = true;
options.sync_option_explicit = true;
}
"--plan" => {
options.config.plan = true;
options.sync_option_explicit = true;
}
"--force" => {
options.config.force = true;
options.sync_option_explicit = true;
}
"--audit-local" => {
options.config.audit_local = true;
options.sync_option_explicit = true;
}
"--no-audit-local" => {
options.config.audit_local = false;
options.sync_option_explicit = true;
}
"--repair" => {
options.config.repair = true;
options.sync_option_explicit = true;
}
"--no-repair" => {
options.config.repair = false;
options.sync_option_explicit = true;
}
"--watch" => {
options.watch = true;
options.sync_option_explicit = true;
}
"--daemon" => {
options.daemon = true;
options.sync_option_explicit = true;
}
"--daemon-child" => {
options.daemon_child = true;
options.watch = true;
options.sync_option_explicit = true;
}
"--interval" => {
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
options.sync_option_explicit = true;
}
"--interval-seconds" => {
let seconds = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
options.interval = Duration::from_secs(seconds);
options.sync_option_explicit = true;
}
"--error-retry" => {
options.error_retry_interval =
parse_duration(&next_option_value(&mut args, &flag)?)?;
options.sync_option_explicit = true;
}
"--error-retry-seconds" => {
let seconds = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
options.error_retry_interval = Duration::from_secs(seconds);
options.sync_option_explicit = true;
}
"--quiet-up-to-date" => {
options.quiet_up_to_date = true;
options.quiet_up_to_date_explicit = true;
options.sync_option_explicit = true;
}
"--no-quiet-up-to-date" => {
options.quiet_up_to_date = false;
options.quiet_up_to_date_explicit = true;
options.sync_option_explicit = true;
}
"--progress" => {
options.progress = true;
options.banner = true;
}
"--no-progress" => {
options.progress = false;
options.banner = false;
}
"--json" => {
options.output_format = OutputFormat::Json;
}
"--human" => {
options.output_format = OutputFormat::Human;
}
"--banner" => {
options.banner = true;
}
"--no-banner" => {
options.banner = false;
}
"--tail" => {
options.tail_lines = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 的行数无效:{error}"))?;
if options.tail_lines == 0 {
return Err(anyhow::anyhow!("--tail 必须大于 0"));
}
}
"--help" | "-h" => {
print_usage(&binary);
std::process::exit(0);
}
_ => return Err(anyhow::anyhow!("未知参数:{flag}")),
}
}
match options.command {
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
if options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config)
{
return Err(anyhow::anyhow!(
"status/stop/logs 只读取 --state-dir;资源同步参数和输出目录无关"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::Doctor | CliCommand::CleanStable => {
if options.sync_option_explicit {
return Err(anyhow::anyhow!(
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
if !options.config.auto_discover
&& options.config.server_info_source.is_none()
&& options.config.connection_group.is_none()
&& options.config.app_version.is_none()
{
options.config.auto_discover = true;
}
if matches!(options.command, CliCommand::Verify) {
options.config.dry_run = true;
options.config.plan = true;
options.config.audit_local = true;
options.config.repair = false;
}
}
CliCommand::Restart | CliCommand::Reload => {
if (options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config))
&& !options.config.auto_discover
&& options.config.server_info_source.is_none()
&& options.config.connection_group.is_none()
&& options.config.app_version.is_none()
{
options.config.auto_discover = true;
}
}
CliCommand::Run => {}
}
if options.daemon && options.watch {
return Err(anyhow::anyhow!(
"--daemon 会自动启动 watch 模式,不要同时传 --watch"
));
}
if (options.watch || options.daemon || options.daemon_child) && options.config.dry_run {
return Err(anyhow::anyhow!(
"watch/daemon 模式不能和 --dry-run 同时使用"
));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("watch 检查间隔必须大于 0"));
}
if options.error_retry_interval.is_zero() {
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
}
if (options.watch || options.daemon || options.daemon_child)
&& !options.quiet_up_to_date_explicit
{
options.quiet_up_to_date = true;
}
if matches!(options.command, CliCommand::Verify) {
if options.config.force {
return Err(anyhow::anyhow!(
"verify 只验证本地资源,不能和 --force 同时使用"
));
}
if !options.config.repair && !options.config.audit_local {
return Err(anyhow::anyhow!(
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
));
}
}
if matches!(options.command, CliCommand::Restart | CliCommand::Reload)
&& (options.config.force || options.config.dry_run)
{
return Err(anyhow::anyhow!(
"restart/reload 不能和 --force 或 --dry-run 同时使用"
));
}
if matches!(options.command, CliCommand::Logs) && options.tail_lines == 0 {
return Err(anyhow::anyhow!("logs 的 --tail 必须大于 0"));
}
Ok(options)
}
fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> {
if command == CliCommand::Run {
Ok(())
} else {
Err(anyhow::anyhow!("不支持同时指定多个命令;意外命令:{next}"))
}
}
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
let defaults = OfficialUpdateConfig::default();
config.curl_command != defaults.curl_command
|| config.curl_proxy != defaults.curl_proxy
|| config.unzip_command != defaults.unzip_command
}
fn next_option_value(
args: &mut impl Iterator<Item = String>,
flag: &str,
) -> anyhow::Result<String> {
args.next()
.ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值"))
}
/// curl 支持的代理 scheme。
const SUPPORTED_PROXY_SCHEMES: [&str; 6] =
["http", "https", "socks4", "socks4a", "socks5", "socks5h"];
/// 校验显式代理 URL 的 scheme,尽早拒绝拼错的 scheme(如 `htp://`)。
///
/// 无 `://` 时 curl 默认按 http 处理 `host:port`,此处只要求非空;带 `://` 时
/// scheme 必须是 curl 支持的代理协议,且代理主机部分不能为空。
fn validate_proxy_url(url: &str) -> anyhow::Result<()> {
if let Some((scheme, rest)) = url.split_once("://") {
let scheme_lower = scheme.to_ascii_lowercase();
if !SUPPORTED_PROXY_SCHEMES.contains(&scheme_lower.as_str()) {
return Err(anyhow::anyhow!(
"--proxy 使用了不支持的 scheme{scheme}(支持 {}",
SUPPORTED_PROXY_SCHEMES.join("/")
));
}
if rest.is_empty() {
return Err(anyhow::anyhow!("--proxy 缺少代理主机:{url}"));
}
}
Ok(())
}
fn parse_proxy_config(value: &str) -> anyhow::Result<CurlProxyConfig> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(anyhow::anyhow!("--proxy 不能为空"));
}
match normalized.to_ascii_lowercase().as_str() {
"auto" | "env" => Ok(CurlProxyConfig::auto()),
"none" | "direct" | "off" | "disabled" => Ok(CurlProxyConfig::disabled()),
_ => {
validate_proxy_url(normalized)?;
Ok(CurlProxyConfig::url(normalized.to_string()))
}
}
}
fn print_usage(binary: &str) {
eprintln!("BlueArchiveToolkit official resource sync");
eprintln!();
eprintln!("Usage:");
eprintln!(" {binary} [OPTIONS]");
eprintln!(" {binary} <COMMAND> [OPTIONS]");
eprintln!();
eprintln!("Commands:");
eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
eprintln!(" repair Redownload resources that fail local verification");
eprintln!(" status Show daemon state");
eprintln!(" stop Stop daemon");
eprintln!(
" restart Restart daemon, reusing saved args unless explicit args are passed"
);
eprintln!(" reload Ask daemon to rediscover metadata and force refresh");
eprintln!(" logs Show daemon log tail");
eprintln!(" doctor Run runtime diagnostics");
eprintln!(" clean-stable Remove .part/.tmp/stale lock, pid, and socket files");
eprintln!();
eprintln!("Examples:");
eprintln!(" {binary} --auto-discover --dry-run");
eprintln!(" {binary} --auto-discover --watch");
eprintln!(" {binary} --auto-discover --daemon");
eprintln!(" {binary} status");
eprintln!(" {binary} refresh --force --json");
eprintln!();
eprintln!("Discovery:");
eprintln!(
" --auto-discover Discover app-version, server-info, connection-group"
);
eprintln!(" --server-info-url <URL> Use an official server-info URL");
eprintln!(" --server-info-file <NAME> Use an official server-info file name");
eprintln!(" --server-info-path <PATH> Use a local server-info JSON file");
eprintln!(" --app-version <VERSION> Override app version");
eprintln!(" --connection-group <NAME> Override connection group");
eprintln!(" --launcher-version <VERSION> Launcher metadata API version (default: 1.7.2)");
eprintln!();
eprintln!("Sync:");
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
eprintln!(
" --output <DIR> Resource publish root (default: ./bat-resources)"
);
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
eprintln!(" --curl <PATH> curl executable (default: curl)");
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
eprintln!(" --no-proxy Force direct curl connections");
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
eprintln!(" --dry-run Do not write sync state");
eprintln!(" --plan Include planned URLs in dry-run");
eprintln!(" --force Force download/refresh");
eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit");
eprintln!(" --repair | --no-repair Enable/disable automatic repair");
eprintln!();
eprintln!("Daemon:");
eprintln!(" --watch Run in foreground loop");
eprintln!(" --daemon Start detached watch process");
eprintln!(" --state-dir <DIR> Daemon state dir (default: /tmp/bat-pid)");
eprintln!(" --interval <DURATION> Normal check interval (default: 1h)");
eprintln!(" --error-retry <DURATION> Retry interval after error (default: 60s)");
eprintln!(" --quiet-up-to-date Suppress clean up-to-date reports");
eprintln!(" --no-quiet-up-to-date Always print reports");
eprintln!(" --tail <N> Log lines for logs command (default: 200)");
eprintln!();
eprintln!("Output:");
eprintln!(" --human Human-readable output (default)");
eprintln!(" --json Stable JSON output for scripts");
eprintln!(" --progress | --no-progress Enable/disable stderr progress logs");
eprintln!(" --banner | --no-banner Enable/disable startup banner");
eprintln!(" -h, --help Show this help");
eprintln!();
eprintln!("Defaults:");
eprintln!(" platforms: Windows,Android");
eprintln!(" resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)");
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)");
eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}");
}
fn parse_duration(value: &str) -> anyhow::Result<Duration> {
let value = value.trim();
if value.is_empty() {
return Err(anyhow::anyhow!("时间间隔不能为空"));
}
let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") {
let millis = number
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("毫秒时间间隔无效:{value}{error}"))?;
return Ok(Duration::from_millis(millis));
} else if let Some(number) = value.strip_suffix('s') {
(number, 1)
} else if let Some(number) = value.strip_suffix('m') {
(number, 60)
} else if let Some(number) = value.strip_suffix('h') {
(number, 60 * 60)
} else {
(value, 1)
};
let amount = number
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("时间间隔无效:{value}{error}"))?;
Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
}
fn format_duration(duration: Duration) -> String {
let total_millis = duration.as_millis();
let minutes = total_millis / 60_000;
let seconds = (total_millis / 1_000) % 60;
let millis = total_millis % 1_000;
format!("{minutes:02}:{seconds:02}.{millis:03}")
}
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(parse_platform)
.collect()
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
match value.to_ascii_lowercase().as_str() {
"windows" | "win" => Ok(PatchPlatform::Windows),
"android" => Ok(PatchPlatform::Android),
_ => Err(format!("不支持的平台:{value}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
parse_args_from(values.iter().map(|value| value.to_string()))
}
#[test]
fn parses_auto_discover_sync_args() {
let options = parse(&[
"bat",
"--auto-discover",
"--platforms",
"Windows,Android",
"--snapshot",
"/tmp/snapshot.json",
"--dry-run",
"--plan",
])
.unwrap();
let config = options.config;
assert!(config.auto_discover);
assert_eq!(
config.platforms.as_deref(),
Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice())
);
assert_eq!(
config.snapshot_path,
Some(PathBuf::from("/tmp/snapshot.json"))
);
assert!(config.dry_run);
assert!(config.plan);
assert!(config.audit_local);
assert!(config.repair);
assert!(!options.watch);
assert_eq!(
options.interval,
Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS)
);
assert_eq!(
options.error_retry_interval,
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
);
assert!(!options.quiet_up_to_date);
assert!(!options.quiet_up_to_date_explicit);
assert!(options.progress);
assert!(options.banner);
assert_eq!(options.output_format, OutputFormat::Human);
assert_eq!(config.output_root, PathBuf::from("./bat-resources"));
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
}
#[test]
fn parses_explicit_source_and_disable_repair() {
let options = parse(&[
"bat",
"--server-info-url",
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
"--connection-group",
"Prod-Audit",
"--app-version",
"1.70.0",
"--no-audit-local",
"--no-repair",
"--unzip",
"/usr/bin/unzip",
])
.unwrap();
let config = options.config;
assert!(!config.auto_discover);
assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit"));
assert_eq!(config.app_version.as_deref(), Some("1.70.0"));
assert!(!config.audit_local);
assert!(!config.repair);
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
assert!(matches!(
config.server_info_source,
Some(OfficialServerInfoSource::OfficialUrl(ref url))
if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
));
}
#[test]
fn parses_proxy_options() {
let options = parse(&["bat", "--proxy", "http://127.0.0.1:7890"]).unwrap();
assert!(options.proxy_option_explicit);
assert_eq!(
options.config.curl_proxy.mode(),
&CurlProxyMode::Url("http://127.0.0.1:7890".to_string())
);
let options = parse(&["bat", "--proxy", "none"]).unwrap();
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled);
let options = parse(&["bat", "--no-proxy"]).unwrap();
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled);
let options = parse(&["bat", "--proxy", "auto"]).unwrap();
assert!(options.proxy_option_explicit);
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto);
// socks5 等受支持 scheme 可用。
let options = parse(&["bat", "--proxy", "socks5://127.0.0.1:1080"]).unwrap();
assert_eq!(
options.config.curl_proxy.mode(),
&CurlProxyMode::Url("socks5://127.0.0.1:1080".to_string())
);
}
#[test]
fn rejects_proxy_with_unsupported_scheme() {
let error = parse(&["bat", "--proxy", "htp://127.0.0.1:7890"]).unwrap_err();
assert!(error.to_string().contains("不支持的 scheme"));
// 无 scheme 的 host:port 仍接受(curl 默认按 http 处理)。
assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok());
}
#[test]
fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() {
let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap();
assert!(options.watch);
assert_eq!(options.interval, Duration::from_secs(30 * 60));
assert_eq!(
options.error_retry_interval,
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
);
assert!(options.quiet_up_to_date);
assert!(!options.quiet_up_to_date_explicit);
}
#[test]
fn parses_daemon_mode_as_background_watch() {
let options = parse(&[
"bat",
"--auto-discover",
"--daemon",
"--output",
"/tmp/daemon-output",
"--interval",
"30m",
])
.unwrap();
assert_eq!(options.command, CliCommand::Run);
assert!(options.daemon);
assert!(!options.watch);
assert_eq!(
options.config.output_root,
PathBuf::from("/tmp/daemon-output")
);
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
assert_eq!(options.interval, Duration::from_secs(30 * 60));
assert!(options.quiet_up_to_date);
}
#[test]
fn parses_output_format_flags() {
let options = parse(&["bat", "status", "--json"]).unwrap();
assert_eq!(options.command, CliCommand::Status);
assert_eq!(options.output_format, OutputFormat::Json);
let options = parse(&["bat", "status", "--json", "--human"]).unwrap();
assert_eq!(options.output_format, OutputFormat::Human);
}
#[test]
fn parses_status_and_stop_commands() {
let status = parse(&["bat", "status"]).unwrap();
assert_eq!(status.command, CliCommand::Status);
assert!(!status.watch);
assert!(!status.daemon);
assert!(!status.progress);
assert!(!status.banner);
assert_eq!(status.config.output_root, PathBuf::from("./bat-resources"));
assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap();
assert_eq!(stop.command, CliCommand::Stop);
assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources"));
assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid"));
}
#[test]
fn parses_management_and_resource_commands() {
let refresh = parse(&["bat", "refresh", "--force"]).unwrap();
assert_eq!(refresh.command, CliCommand::Refresh);
assert!(refresh.config.auto_discover);
assert!(refresh.config.force);
let verify = parse(&["bat", "verify"]).unwrap();
assert_eq!(verify.command, CliCommand::Verify);
assert!(verify.config.auto_discover);
assert!(verify.config.dry_run);
assert!(verify.config.plan);
assert!(!verify.config.repair);
let repair = parse(&["bat", "repair"]).unwrap();
assert_eq!(repair.command, CliCommand::Repair);
assert!(repair.config.auto_discover);
for (value, expected) in [
("restart", CliCommand::Restart),
("reload", CliCommand::Reload),
("logs", CliCommand::Logs),
("doctor", CliCommand::Doctor),
("clean-stable", CliCommand::CleanStable),
] {
let options = parse(&["bat", value]).unwrap();
assert_eq!(options.command, expected);
}
}
#[test]
fn parses_logs_tail_and_rejects_force_for_verify() {
let options = parse(&["bat", "logs", "--tail", "25"]).unwrap();
assert_eq!(options.tail_lines, 25);
let error = parse(&["bat", "verify", "--force"]).unwrap_err();
assert!(error.to_string().contains("不能和 --force"));
}
#[test]
fn restart_with_explicit_options_defaults_to_auto_discover() {
let options = parse(&["bat", "restart", "--output", "/tmp/bat-resources"]).unwrap();
assert_eq!(options.command, CliCommand::Restart);
assert!(options.output_explicit);
assert!(options.config.auto_discover);
}
#[test]
fn daemon_child_args_preserve_sync_options() {
let options = parse(&[
"bat",
"--auto-discover",
"--daemon",
"--platforms",
"Windows,Android",
"--output",
"/tmp/daemon-output",
"--state-dir",
"/tmp/daemon-state",
"--curl",
"/usr/bin/curl",
"--proxy",
"http://127.0.0.1:7890",
"--unzip",
"/usr/bin/unzip",
"--interval",
"30m",
"--error-retry",
"45s",
"--json",
"--no-banner",
])
.unwrap();
let args = daemon_child_args(&options);
assert!(args.contains(&"--watch".to_string()));
assert!(args.contains(&"--daemon-child".to_string()));
assert!(!args.contains(&"--daemon".to_string()));
assert!(args
.windows(2)
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--state-dir", "/tmp/daemon-state"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--platforms", "Windows,Android"]));
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
assert!(!args.windows(2).any(|pair| pair[0] == "--proxy"));
assert!(args.windows(2).any(|pair| pair == ["--interval", "30m"]));
assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"]));
assert!(args.contains(&"--quiet-up-to-date".to_string()));
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"--no-banner".to_string()));
}
#[test]
fn curl_proxy_url_extracts_only_url_mode() {
assert_eq!(
curl_proxy_url(&CurlProxyConfig::url("http://user:secret@127.0.0.1:7890")),
Some("http://user:secret@127.0.0.1:7890".to_string())
);
assert_eq!(curl_proxy_url(&CurlProxyConfig::auto()), None);
assert_eq!(curl_proxy_url(&CurlProxyConfig::disabled()), None);
}
#[test]
fn daemon_proxy_secret_round_trip_and_clear() {
let dir = tempfile::TempDir::new().unwrap();
let state_dir = dir.path();
assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None);
write_daemon_proxy_secret(state_dir, "http://user:secret@127.0.0.1:7890").unwrap();
assert_eq!(
read_daemon_proxy_secret(state_dir).unwrap(),
Some("http://user:secret@127.0.0.1:7890".to_string())
);
// 凭据文件以 0600 创建。
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(daemon_proxy_secret_path(state_dir))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
clear_daemon_proxy_secret(state_dir);
assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None);
}
// 序列化会修改进程级 PROXY_URL_ENV_VAR 的测试,避免并行测试相互干扰。
static PROXY_ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn proxy_from_env_flag_reads_and_consumes_env_var() {
let _guard = PROXY_ENV_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
env::set_var(PROXY_URL_ENV_VAR, "http://user:secret@127.0.0.1:7890");
let options = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap();
assert!(options.proxy_option_explicit);
assert_eq!(
options.config.curl_proxy.mode(),
&CurlProxyMode::Url("http://user:secret@127.0.0.1:7890".to_string())
);
// 读取后应从环境中清除,避免被 curl 等孙进程继承。
assert!(env::var(PROXY_URL_ENV_VAR).is_err());
}
#[test]
fn proxy_from_env_flag_errors_when_env_var_missing() {
let _guard = PROXY_ENV_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
env::remove_var(PROXY_URL_ENV_VAR);
let error = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap_err();
assert!(error.to_string().contains(PROXY_URL_ENV_VAR));
}
#[test]
fn parses_explicit_watch_error_retry_interval() {
let options =
parse(&["bat", "--watch", "--interval", "1h", "--error-retry", "45s"]).unwrap();
assert_eq!(options.interval, Duration::from_secs(3600));
assert_eq!(options.error_retry_interval, Duration::from_secs(45));
let options = parse(&["bat", "--watch", "--error-retry-seconds", "30"]).unwrap();
assert_eq!(options.error_retry_interval, Duration::from_secs(30));
}
#[test]
fn parses_progress_flags() {
let options = parse(&["bat"]).unwrap();
assert!(options.progress);
assert!(options.banner);
let options = parse(&["bat", "--no-progress"]).unwrap();
assert!(!options.progress);
assert!(!options.banner);
let options = parse(&["bat", "--no-progress", "--progress"]).unwrap();
assert!(options.progress);
assert!(options.banner);
}
#[test]
fn parses_banner_flags() {
let options = parse(&["bat", "--no-banner"]).unwrap();
assert!(options.progress);
assert!(!options.banner);
let options = parse(&["bat", "--no-progress", "--banner"]).unwrap();
assert!(!options.progress);
assert!(options.banner);
}
#[test]
fn startup_banner_contains_product_name() {
assert!(STARTUP_BANNER.contains("BlueArchiveToolkit"));
}
#[test]
fn verification_summary_output_names_all_validation_types() {
let summary = OfficialVerificationSummary::new(7, 1, 2, 3);
let lines = verification_summary_lines(&summary);
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("官方 .hash 强校验"));
assert!(lines[0].contains("xxHash32"));
assert!(lines[1].contains("本地 BLAKE3 复用校验"));
assert!(lines[1].contains("需修复"));
assert!(lines[2].contains("ZIP 结构校验"));
assert!(lines[2].contains("central directory"));
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["official_hash_verified_count"], 2);
assert_eq!(json["local_manifest_blake3_verified_count"], 7);
assert_eq!(json["local_manifest_repair_needed_count"], 1);
assert_eq!(json["zip_structure_verified_count"], 3);
}
#[test]
fn daemon_rpc_download_progress_formats_as_human_text() {
let progress = serde_json::json!({
"index": 2,
"total": 19,
"url": "https://prod-clientpatch.bluearchiveyostar.com/file.zip",
"status": "finished",
"bytes": 100,
"transferred_bytes": 100
});
let formatted = format_daemon_download_progress_json(&progress).unwrap();
assert_eq!(
formatted,
"2/19 finished https://prod-clientpatch.bluearchiveyostar.com/file.zip"
);
assert!(!formatted.contains('{'));
}
#[test]
fn daemon_rpc_failed_download_progress_formats_as_human_text() {
let progress = serde_json::json!({
"index": 3,
"total": 19,
"url": "https://prod-clientpatch.bluearchiveyostar.com/missing.zip",
"status": "failed",
"failure_kind": "http_404",
"failure_http_status": 404,
"failure_retryable": false,
"failure_attempts": 1,
"quarantined": true
});
let formatted = format_daemon_download_progress_json(&progress).unwrap();
assert_eq!(
formatted,
"3/19 failed kind=http_404 http=404 retryable=false attempts=1 quarantined=true https://prod-clientpatch.bluearchiveyostar.com/missing.zip"
);
assert!(!formatted.contains('{'));
}
#[test]
fn daemon_status_hides_failure_superseded_by_in_progress_version() {
let version = OfficialVersionRecord {
id: "1.70.0-current-attempt".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
.to_string(),
resource_root: PathBuf::from("/tmp/bat/.staging/current"),
snapshot_path: PathBuf::from("/tmp/bat/.staging/current/official-sync-snapshot.json"),
staging_path: Some(PathBuf::from("/tmp/bat/.staging/current")),
version_path: None,
started_unix_seconds: Some(100),
completed_unix_seconds: None,
};
let older_failure = OfficialFailedVersionRecord {
version: OfficialVersionRecord {
id: "1.70.0-older-failure".to_string(),
resource_root: PathBuf::from("/tmp/bat/.staging/older"),
snapshot_path: PathBuf::from("/tmp/bat/.staging/older/official-sync-snapshot.json"),
staging_path: Some(PathBuf::from("/tmp/bat/.staging/older")),
started_unix_seconds: Some(90),
..version.clone()
},
error: "官方 hash 校验失败 fixture".to_string(),
failed_unix_seconds: 95,
};
let state = OfficialVersionState {
in_progress_version: Some(version),
failed_versions: vec![older_failure],
updated_unix_seconds: 100,
..OfficialVersionState::default()
};
assert!(visible_historical_failed_versions(&state).is_empty());
}
#[test]
fn daemon_socket_path_uses_state_dir() {
assert_eq!(
daemon_socket_path(Path::new("/tmp/custom-bat-pid")),
PathBuf::from("/tmp/custom-bat-pid/bat.sock")
);
}
#[cfg(unix)]
#[test]
fn active_official_resource_root_uses_current_symlink() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let output = temp.path().join("resources");
let version = output.join("versions").join("v1");
fs::create_dir_all(&version).unwrap();
symlink(Path::new("versions").join("v1"), output.join("current")).unwrap();
assert_eq!(
active_official_resource_root(&output).unwrap(),
lexical_absolute(&version).unwrap()
);
}
#[cfg(unix)]
#[test]
fn daemon_rpc_rejects_socket_symlink() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let outside_socket = temp.path().join("outside.sock");
let socket_path = daemon_socket_path(&state_dir);
symlink(&outside_socket, &socket_path).unwrap();
assert!(daemon_socket_path_exists(&socket_path).unwrap());
assert!(!daemon_rpc_available(&state_dir));
let error = daemon_rpc_call(&state_dir, RPC_METHOD_STATUS, None).unwrap_err();
assert!(error.to_string().contains("symlink"));
}
#[test]
fn rpc_tail_param_defaults_and_rejects_zero() {
assert_eq!(rpc_tail_param(None, 200).unwrap(), 200);
assert_eq!(
rpc_tail_param(Some(&serde_json::json!({ "tail": 25 })), 200).unwrap(),
25
);
let error = rpc_tail_param(Some(&serde_json::json!({ "tail": 0 })), 200).unwrap_err();
assert!(error.to_string().contains("大于 0"));
}
#[test]
fn json_rpc_request_and_response_are_line_protocol_friendly() {
let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({
"jsonrpc": "2.0",
"id": 7,
"method": "bat.refresh",
"params": { "force": true }
}))
.unwrap();
assert_eq!(request.method, RPC_METHOD_REFRESH);
assert_eq!(rpc_bool_param(request.params.as_ref(), "force"), Some(true));
let response = json_rpc_result(
Some(serde_json::json!(7)),
serde_json::json!({ "ok": true }),
);
let serialized = serde_json::to_string(&response).unwrap();
assert!(serialized.contains("\"jsonrpc\":\"2.0\""));
assert!(serialized.contains("\"ok\":true"));
assert!(!serialized.contains('\n'));
}
#[test]
fn daemon_control_wake_handles_refresh_reload_and_stop() {
let control = new_daemon_control();
daemon_control_request_refresh(&control, true);
assert_eq!(
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
DaemonWake::Refresh { force: true }
);
daemon_control_request_reload(&control);
assert_eq!(
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
DaemonWake::Reload
);
daemon_control_request_refresh(&control, false);
daemon_control_mark_stop_requested(&control);
daemon_control_notify_all(&control);
assert_eq!(
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
DaemonWake::Stop
);
}
#[test]
fn daemon_control_lock_rejects_active_owner() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let first = DaemonControlLock::acquire(&state_dir).unwrap();
let second = DaemonControlLock::acquire(&state_dir).unwrap_err();
assert!(second.to_string().contains("locked"));
drop(first);
assert!(DaemonControlLock::acquire(&state_dir).is_ok());
}
#[test]
fn daemon_control_lock_recovers_stale_and_corrupt_files() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let lock_path = daemon_control_lock_path(&state_dir);
fs::write(&lock_path, "999999999").unwrap();
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
drop(lock);
assert!(!lock_path.exists());
fs::write(&lock_path, "not-a-pid").unwrap();
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
assert_eq!(
fs::read_to_string(&lock_path).unwrap(),
std::process::id().to_string()
);
drop(lock);
assert!(!lock_path.exists());
}
#[cfg(unix)]
#[test]
fn daemon_control_lock_file_is_private() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let lock_path = daemon_control_lock_path(&state_dir);
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
let mode = fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
drop(lock);
}
#[cfg(unix)]
#[test]
fn daemon_status_file_is_private_and_rejects_symlink() {
use std::os::unix::fs::{symlink, PermissionsExt};
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("resources");
fs::create_dir_all(&state_dir).unwrap();
fs::create_dir_all(&output_root).unwrap();
let status_path = daemon_status_path(&state_dir);
let status = test_daemon_status_file(&state_dir, &output_root);
write_daemon_status_file(&status_path, &status).unwrap();
let mode = fs::metadata(&status_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
fs::remove_file(&status_path).unwrap();
let outside = temp.path().join("outside-status.json");
fs::write(&outside, b"outside").unwrap();
symlink(&outside, &status_path).unwrap();
let error = write_daemon_status_file(&status_path, &status).unwrap_err();
assert!(error.to_string().contains("symlink"));
assert_eq!(fs::read(&outside).unwrap(), b"outside");
}
#[test]
fn daemon_status_tracks_progress_success_and_next_check() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("resources");
fs::create_dir_all(&state_dir).unwrap();
fs::create_dir_all(&output_root).unwrap();
write_daemon_status_file(
&daemon_status_path(&state_dir),
&test_daemon_status_file(&state_dir, &output_root),
)
.unwrap();
let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture");
event.download_index = Some(2);
event.download_total = Some(19);
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into());
update_daemon_progress(&state_dir, &event).unwrap();
let report = build_daemon_status_report(&state_dir).unwrap();
assert_eq!(report.current_stage.as_deref(), Some("download"));
assert_eq!(report.download_progress.as_ref().unwrap().index, 2);
assert_eq!(report.download_progress.as_ref().unwrap().total, 19);
let next_check = unix_seconds_after(Duration::from_secs(30));
update_daemon_status(
&state_dir,
DaemonStatusUpdate {
state: "sleeping",
last_update_status: Some("downloaded".to_string()),
last_error: None,
next_retry_seconds: Some(30),
last_success_unix_seconds: Some(123456),
next_check_unix_seconds: Some(next_check),
pending_scheduled_force: false,
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
},
)
.unwrap();
let report = build_daemon_status_report(&state_dir).unwrap();
assert_eq!(report.last_success_unix_seconds, Some(123456));
assert_eq!(report.next_check_unix_seconds, Some(next_check));
assert!(report.current_stage.is_none());
assert!(report.download_progress.is_none());
}
#[test]
fn structured_logger_writes_jsonl_and_rotates() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("bat-events.jsonl");
let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2);
let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture");
event.download_index = Some(1);
event.download_total = Some(2);
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into());
for _ in 0..8 {
logger
.write_event(Duration::from_millis(42), &event)
.unwrap();
}
assert!(path.exists());
assert!(rotated_structured_log_path(&path, 1).exists());
let line = fs::read_to_string(&path)
.unwrap()
.lines()
.next()
.unwrap()
.to_string();
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(value["stage"], "download");
assert_eq!(value["download"]["index"], 1);
assert_eq!(value["download"]["total"], 2);
}
#[cfg(unix)]
#[test]
fn read_pid_file_rejects_symlink() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let outside = temp.path().join("outside.pid");
fs::write(&outside, b"123").unwrap();
let pid_path = daemon_pid_path(&state_dir);
symlink(&outside, &pid_path).unwrap();
let error = read_pid_file(&pid_path).unwrap_err();
assert!(error.to_string().contains("symlink"));
}
#[test]
fn live_daemon_resource_conflict_detects_same_output() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("resources");
fs::create_dir_all(&output_root).unwrap();
write_daemon_status_file(
&daemon_status_path(&state_dir),
&test_daemon_status_file(&state_dir, &output_root),
)
.unwrap();
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
let conflict = live_daemon_resource_conflict(&state_dir, &output_root)
.unwrap()
.unwrap();
assert_eq!(conflict.pid, Some(std::process::id()));
assert_eq!(
conflict.target_root,
normalized_abs_path(&output_root).unwrap()
);
}
#[test]
fn live_daemon_resource_conflict_allows_different_output() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let daemon_output = temp.path().join("daemon-resources");
let manual_output = temp.path().join("manual-resources");
fs::create_dir_all(&daemon_output).unwrap();
fs::create_dir_all(&manual_output).unwrap();
write_daemon_status_file(
&daemon_status_path(&state_dir),
&test_daemon_status_file(&state_dir, &daemon_output),
)
.unwrap();
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
assert!(live_daemon_resource_conflict(&state_dir, &manual_output)
.unwrap()
.is_none());
}
#[test]
fn direct_write_rejects_live_daemon_for_same_output() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("resources");
fs::create_dir_all(&output_root).unwrap();
write_daemon_status_file(
&daemon_status_path(&state_dir),
&test_daemon_status_file(&state_dir, &output_root),
)
.unwrap();
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
let options = CliOptions {
state_dir,
config: OfficialUpdateConfig {
output_root,
..OfficialUpdateConfig::default()
},
..CliOptions::default()
};
let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err();
assert!(error.to_string().contains("同一资源目录"));
assert!(error.to_string().contains("locked"));
}
#[test]
fn clean_stable_removes_corrupt_pid_and_lock_files() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("resources");
let state_dir = temp.path().join("state");
fs::create_dir_all(&output_root).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let resource_lock = output_root.join(".official-sync.lock");
let control_lock = daemon_control_lock_path(&state_dir);
let pid_path = daemon_pid_path(&state_dir);
fs::write(&resource_lock, "not-a-pid").unwrap();
fs::write(&control_lock, "not-a-pid").unwrap();
fs::write(&pid_path, "not-a-pid").unwrap();
let options = CliOptions {
command: CliCommand::CleanStable,
output_format: OutputFormat::Json,
state_dir,
config: OfficialUpdateConfig {
output_root,
..OfficialUpdateConfig::default()
},
..CliOptions::default()
};
run_clean_stable_command(&options).unwrap();
assert!(!resource_lock.exists());
assert!(!control_lock.exists());
assert!(!pid_path.exists());
}
#[test]
fn rpc_stop_wait_reports_already_stopped_without_signal() {
assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap());
}
#[test]
fn refresh_rpc_selection_only_for_default_daemon_shape() {
let options = parse(&["bat", "refresh"]).unwrap();
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
let options = parse(&["bat", "refresh", "--force"]).unwrap();
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
let options = parse(&["bat", "repair"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "repair"));
}
#[test]
fn explicit_no_quiet_up_to_date_overrides_watch_default() {
let options = parse(&[
"bat",
"--watch",
"--no-quiet-up-to-date",
"--interval",
"1h",
])
.unwrap();
assert!(options.watch);
assert!(!options.quiet_up_to_date);
assert!(options.quiet_up_to_date_explicit);
}
#[test]
fn rejects_watch_dry_run_and_zero_interval() {
let error = parse(&["bat", "--watch", "--dry-run"]).unwrap_err();
assert!(error.to_string().contains("不能和 --dry-run"));
let error = parse(&["bat", "--daemon", "--watch"]).unwrap_err();
assert!(error.to_string().contains("不要同时传 --watch"));
let error = parse(&["bat", "status", "--watch"]).unwrap_err();
assert!(error.to_string().contains("status/stop"));
let error = parse(&["bat", "status", "--output", "/tmp/resources"]).unwrap_err();
assert!(error.to_string().contains("--state-dir"));
let error = parse(&["bat", "--watch", "--interval-seconds", "0"]).unwrap_err();
assert!(error.to_string().contains("必须大于 0"));
let error = parse(&["bat", "--watch", "--error-retry-seconds", "0"]).unwrap_err();
assert!(error.to_string().contains("失败重试间隔"));
}
#[test]
fn parses_duration_units() {
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
}
#[test]
fn formats_duration_args_for_child_process() {
assert_eq!(format_duration_arg(Duration::from_secs(3600)), "1h");
assert_eq!(format_duration_arg(Duration::from_secs(90 * 60)), "90m");
assert_eq!(format_duration_arg(Duration::from_secs(45)), "45s");
assert_eq!(format_duration_arg(Duration::from_millis(250)), "250ms");
}
#[test]
fn scheduled_forced_refresh_uses_beijing_local_times() {
let day = 20_000;
assert_eq!(
next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)),
beijing_time(day, 3, 0, 0)
);
assert_eq!(
next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)),
beijing_time(day, 3, 0, 0)
);
assert_eq!(
next_forced_refresh_after(beijing_time(day, 3, 0, 0)),
beijing_time(day, 16, 0, 0)
);
assert_eq!(
next_forced_refresh_after(beijing_time(day, 18, 0, 0)),
beijing_time(day + 1, 3, 0, 0)
);
assert_eq!(
duration_until(
next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)),
beijing_time(day, 17, 45, 0)
),
Duration::from_secs(15 * 60)
);
}
#[test]
fn formats_progress_durations() {
assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007");
assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432");
}
#[test]
fn quiet_up_to_date_suppresses_only_clean_reports() {
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
assert!(should_print_status(OfficialUpdateStatus::Downloaded, true));
assert!(should_print_status(
OfficialUpdateStatus::WouldDownload,
true
));
assert!(should_print_status(OfficialUpdateStatus::UpToDate, false));
}
#[test]
fn status_string_is_stable() {
assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date");
assert_eq!(
OfficialUpdateStatus::WouldDownload.as_str(),
"would_download"
);
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
}
#[test]
fn redacts_proxy_credentials_in_surfaced_command() {
let command = vec![
"/opt/bat".to_string(),
"--auto-discover".to_string(),
"--proxy".to_string(),
"http://user:secret@127.0.0.1:7890".to_string(),
"--output".to_string(),
"/var/lib/bat".to_string(),
];
let redacted = redact_command_proxy_credentials(command);
assert_eq!(redacted[3], "http://<redacted>@127.0.0.1:7890");
assert!(!redacted.join(" ").contains("secret"));
// 非代理参数保持原样。
assert_eq!(redacted[1], "--auto-discover");
assert_eq!(redacted[5], "/var/lib/bat");
}
#[test]
fn redacts_schemeless_proxy_credentials_in_surfaced_command() {
let command = vec![
"/opt/bat".to_string(),
"--proxy".to_string(),
"user:secret@127.0.0.1:7890".to_string(),
];
let redacted = redact_command_proxy_credentials(command);
assert_eq!(redacted[2], "<redacted>@127.0.0.1:7890");
assert!(!redacted.join(" ").contains("secret"));
}
#[test]
fn leaves_command_without_proxy_untouched() {
let command = vec![
"/opt/bat".to_string(),
"--auto-discover".to_string(),
"--proxy".to_string(),
"auto".to_string(),
];
let redacted = redact_command_proxy_credentials(command.clone());
assert_eq!(redacted, command);
}
fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime {
system_time_from_beijing_local_seconds(
day.saturating_mul(SECONDS_PER_DAY)
.saturating_add(hour.saturating_mul(60 * 60))
.saturating_add(minute.saturating_mul(60))
.saturating_add(second),
)
}
fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile {
DaemonStatusFile {
version: DAEMON_STATUS_VERSION,
pid: std::process::id(),
state: "sleeping".to_string(),
resource_output_root: output_root.to_path_buf(),
state_dir: state_dir.to_path_buf(),
log_path: daemon_log_path(state_dir),
structured_log_path: Some(daemon_structured_log_path(state_dir)),
started_unix_seconds: unix_seconds_now(),
updated_unix_seconds: unix_seconds_now(),
last_success_unix_seconds: Some(unix_seconds_now()),
next_check_unix_seconds: Some(unix_seconds_after(Duration::from_secs(60))),
last_update_status: Some("up_to_date".to_string()),
last_error: None,
next_retry_seconds: Some(60),
current_stage: None,
current_message: None,
download_progress: None,
pending_scheduled_force: false,
next_forced_refresh_unix_seconds: None,
command: vec!["bat".to_string(), "--daemon-child".to_string()],
}
}
}