diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index ff994dd..8878668 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -20,12 +20,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、403/404/5xx 分类重试、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。 -7. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 -8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 -9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 -10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 -11. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 -12. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。 +7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,可用 `--proxy ` 显式指定代理,也可用 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出,带认证信息的代理 URL 会脱敏。 +8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 +9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 +10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 +11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 +12. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 +13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。 仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 @@ -130,9 +131,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: - `InMemoryResourceRepository`。 - `SqliteResourceRepository`。 - 官方 pull plan 构建。 -- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。 +- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。 - `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair。 -- `bat`:正式 CLI binary,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。 +- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy`、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。 待完成: diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index afc7670..3b47c24 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -14,6 +14,8 @@ - Linux 生产环境安装或执行官方启动器 - 把已安装客户端目录或官方启动器安装目录当作生产输入 +本地 HTTP/SOCKS 代理只允许作为 curl 传输层配置使用,不改变资源来源判定。无论是否配置代理,下载器仍只接受官方 URL,并拒绝镜像、转写或非官方资源域名。 + 当前默认平台集合是: - `Windows` @@ -130,11 +132,12 @@ 9. 存在 `.part` 临时文件时通过 `curl --continue-at -` 尝试断点续传。 10. 新下载写入 `.part`,成功并通过必要校验后原子 rename 到 staging 内最终路径;断点续传后的 `.zip` 如果结构无效,会删除 `.part` 并重新全量下载。 11. 成功下载后更新本地下载清单。 -12. curl 失败按 HTTP/网络类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。 -13. 单个 URL 最终失败时写入 `official-download-quarantine.json`,发出 Failed progress,并阻止发布不完整资源。 -14. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像。 -15. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。 -16. 非官方 URL 直接拒绝。 +12. curl 默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,保留 `NO_PROXY`;CLI 可用 `--proxy ` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出,带认证信息的代理 URL 在日志中会脱敏。 +13. curl 失败按 HTTP/网络类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。 +14. 单个 URL 最终失败时写入 `official-download-quarantine.json`,发出 Failed progress,并阻止发布不完整资源。 +15. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像。 +16. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。 +17. 非官方 URL 直接拒绝。 路径映射时会做分段清理,并在写入前做输出目录安全校验、相对路径归属校验和现有路径组件 symlink 检查,避免把不安全路径写进输出目录或通过 symlink 跳出输出目录。 @@ -204,7 +207,7 @@ 11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。 12. 将 staging rename 为 `/versions/`,再原子替换 `/current` symlink 指向该 versioned 目录。 -该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 +该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 对应实现主要在: @@ -242,7 +245,7 @@ Linux 生产路径: - pull plan 会同时包含 discovery URLs 和 content URLs - 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 -- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 +- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 - curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN - `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,`bat status` 会暴露版本状态摘要和最近历史失败原因 - 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 2f4d120..1c37033 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -49,6 +49,34 @@ target/release/bat \ 同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 +## 0.1 本地代理 + +`bat` 默认会自动读取当前进程环境中的 curl 代理变量:`HTTPS_PROXY`、`https_proxy`、`ALL_PROXY`、`all_proxy`、`HTTP_PROXY`、`http_proxy`,并保留 `NO_PROXY` / `no_proxy`。同步开始时会输出一条 `proxy` 进度日志;`bat doctor` 也会显示当前代理解析结果。 + +手动指定本地代理: + +```bash +target/release/bat \ + --auto-discover \ + --proxy http://127.0.0.1:7890 +``` + +明确关闭代理并直连: + +```bash +target/release/bat \ + --auto-discover \ + --no-proxy +``` + +确认当前代理配置: + +```bash +target/release/bat doctor --proxy http://127.0.0.1:7890 +``` + +`--proxy auto` 会恢复默认环境变量自动检测;`--proxy none`、`--proxy direct`、`--proxy off` 和 `--proxy disabled` 等价于 `--no-proxy`。如果用 `--daemon` 启动并显式传入代理参数,该参数会写入后台子进程启动参数,后续 `restart` / `reload` 复用同一配置。代理只影响 curl 传输层,资源 URL 仍必须是官方域名;不要把代理配置成镜像、转写或非官方资源来源。 + ## 1. 当前流程 Linux 生产运行时链路只走官方日服 HTTP 资源,不安装、不启动、不依赖官方启动器二进制: @@ -155,6 +183,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - 远端无变化时,默认执行 active release 中的 `official-download-manifest.json` audit。 - 本地文件缺失、路径不一致、size 不一致、BLAKE3 不一致或 ZIP 结构无效时,默认进入 repair 并重下必要文件。 - 官方 seed `.hash` 校验失败会让本轮失败,并清理对应本地 manifest 条目;下一轮会继续把这类文件视为需要 repair,而不是把失败产物当作健康缓存复用。 +- curl 默认自动检测本地代理环境;也可以用 `--proxy ` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出。 - curl 失败会按类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。 - 单个 URL 最终失败后会写入 `official-download-quarantine.json`,progress log、daemon status 和 `bat-events.jsonl` 会记录失败类型、HTTP 状态、是否可重试、尝试次数和 quarantine 状态。 - quarantine 项会跳过本轮发布并让同步失败,避免把不完整 staging 发布到 `current`;下一轮 repair/refresh 成功后会清理对应 quarantine 条目。 @@ -222,7 +251,7 @@ cargo run -p bat-infrastructure --bin bat -- reload cargo run -p bat-infrastructure --bin bat -- stop ``` -`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 +`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`: @@ -249,10 +278,10 @@ cargo run -p bat-infrastructure --bin bat -- clean-stable ``` - `refresh` 执行一次同步检查;`refresh --force` 强制刷新并重新匹配当前官方资源。 -- 如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套资源目录、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程执行;否则作为一次性前台同步运行。一次性前台 `run/watch/refresh/repair` 如果要写入 live daemon 正在管理的同一资源目录,会返回 locked 错误;需要先 `stop` 或改用默认 `refresh` 走 RPC。 +- 如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套资源目录、server-info、connection-group、app-version、platforms、snapshot、curl、proxy 或 unzip 参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程执行;否则作为一次性前台同步运行。一次性前台 `run/watch/refresh/repair` 如果要写入 live daemon 正在管理的同一资源目录,会返回 locked 错误;需要先 `stop` 或改用默认 `refresh` 走 RPC。 - `verify` 只读验证当前官方计划、本地 manifest size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;资源缺失、远端变化或本地损坏时返回非 0。 - `repair` 在有异常资源时尝试重新下载并修复,成功后重新写 manifest 和 snapshot。 -- `doctor` 检查输出目录、后台状态目录、curl/unzip、后台 PID、RPC socket、资源锁和 daemon 控制锁。 +- `doctor` 检查输出目录、后台状态目录、curl/unzip、代理解析、后台 PID、RPC socket、资源锁和 daemon 控制锁。 - `clean-stable` 只能在后台未运行且 RPC socket 不可连接时清理 `.part`、`.tmp`、失效或损坏的 PID、socket、资源锁和 daemon 控制锁,不删除正式资源文件。 如需调整间隔: @@ -265,7 +294,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --error-retry 60s ``` -默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出总体下载进度和单文件开始/完成状态,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。 +默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出总体下载进度和单文件开始/完成状态,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。 生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index ae2f98d..4b07af0 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,10 +1,11 @@ use bat_adapters::official::yostar_jp::PatchPlatform; use bat_infrastructure::{ lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, - validate_output_root, validate_runtime_state_dir, write_file_atomic, - OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, - OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, - OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, + 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; @@ -180,6 +181,7 @@ struct CliOptions { 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, @@ -201,6 +203,7 @@ impl Default for CliOptions { 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, @@ -1686,6 +1689,7 @@ struct DaemonControlReport { 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) { @@ -1839,6 +1843,7 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo && !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() @@ -1847,6 +1852,7 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo && 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 @@ -2459,6 +2465,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result { &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 @@ -2607,6 +2614,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { "后台状态目录安全边界通过", ), command_check("curl", &options.config.curl_command), + proxy_check(&options.config.curl_proxy), command_check("unzip", &options.config.unzip_command), ]; @@ -2814,6 +2822,14 @@ fn command_check(name: &'static str, command: &Path) -> DoctorCheck { } } +fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck { + DoctorCheck { + name: "proxy", + ok: true, + message: resolve_curl_proxy(config).human_summary(), + } +} + #[derive(Debug, Serialize)] struct CleanStableReport { command: &'static str, @@ -3162,6 +3178,20 @@ fn daemon_child_args(options: &CliOptions) -> Vec { } 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(url) => { + args.push("--proxy".to_string()); + args.push(url.clone()); + } + } args.push("--unzip".to_string()); args.push(config.unzip_command.to_string_lossy().to_string()); if config.force { @@ -3712,6 +3742,15 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result "--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; + } "--unzip" => { options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); } @@ -3829,6 +3868,7 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result 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!( @@ -3841,7 +3881,7 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result CliCommand::Doctor | CliCommand::CleanStable => { if options.sync_option_explicit { return Err(anyhow::anyhow!( - "doctor/clean-stable 只接受 --output、--state-dir、--curl 和 --unzip 等诊断参数" + "doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数" )); } options.progress = false; @@ -3865,6 +3905,7 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result 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() @@ -3932,7 +3973,10 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> } fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool { - config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip") + 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( @@ -3943,6 +3987,19 @@ fn next_option_value( .ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值")) } +fn parse_proxy_config(value: &str) -> anyhow::Result { + 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()), + _ => Ok(CurlProxyConfig::url(normalized.to_string())), + } +} + fn print_usage(binary: &str) { eprintln!("BlueArchiveToolkit official resource sync"); eprintln!(); @@ -3989,6 +4046,8 @@ fn print_usage(binary: &str) { ); eprintln!(" --snapshot Override snapshot path (default: /current/official-sync-snapshot.json)"); eprintln!(" --curl curl executable (default: curl)"); + eprintln!(" --proxy curl proxy override (default: auto from env)"); + eprintln!(" --no-proxy Force direct curl connections"); eprintln!(" --unzip unzip executable (default: unzip)"); eprintln!(" --dry-run Do not write sync state"); eprintln!(" --plan Include planned URLs in dry-run"); @@ -4157,6 +4216,26 @@ mod tests { )); } + #[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); + } + #[test] fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() { let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap(); @@ -4285,6 +4364,8 @@ mod tests { "/tmp/daemon-state", "--curl", "/usr/bin/curl", + "--proxy", + "http://127.0.0.1:7890", "--unzip", "/usr/bin/unzip", "--interval", @@ -4309,6 +4390,9 @@ mod tests { assert!(args .windows(2) .any(|pair| pair == ["--platforms", "Windows,Android"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--proxy", "http://127.0.0.1:7890"])); 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())); diff --git a/infrastructure/src/curl_transfer.rs b/infrastructure/src/curl_transfer.rs index 56acd26..fa133c7 100644 --- a/infrastructure/src/curl_transfer.rs +++ b/infrastructure/src/curl_transfer.rs @@ -1,6 +1,186 @@ +use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +const PROXY_ENV_PRIORITY: [&str; 6] = [ + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", +]; +const NO_PROXY_ENV_PRIORITY: [&str; 2] = ["NO_PROXY", "no_proxy"]; + +/// Proxy selection mode for official HTTP transfers executed through `curl`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CurlProxyMode { + /// Detect proxy settings from the current process environment. + Auto, + /// Force direct connections and ignore proxy environment variables. + Disabled, + /// Force a specific proxy URL. + Url(String), +} + +/// Proxy configuration for official HTTP transfers executed through `curl`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CurlProxyConfig { + mode: CurlProxyMode, +} + +impl CurlProxyConfig { + /// Creates automatic proxy detection. + pub fn auto() -> Self { + Self { + mode: CurlProxyMode::Auto, + } + } + + /// Creates a direct-connection configuration. + pub fn disabled() -> Self { + Self { + mode: CurlProxyMode::Disabled, + } + } + + /// Creates a forced proxy configuration. + pub fn url(url: impl Into) -> Self { + Self { + mode: CurlProxyMode::Url(url.into()), + } + } + + /// Returns the proxy mode. + pub fn mode(&self) -> &CurlProxyMode { + &self.mode + } +} + +impl Default for CurlProxyConfig { + fn default() -> Self { + Self::auto() + } +} + +/// Resolved proxy settings for one `curl` command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedCurlProxy { + /// Whether a proxy URL will be passed to `curl`. + pub enabled: bool, + /// Whether direct mode was explicitly requested. + pub disabled: bool, + /// Proxy URL, when one is selected. + pub url: Option, + /// Where this proxy decision came from. + pub source: String, + /// `NO_PROXY`/`no_proxy` value preserved for `curl`, when present. + pub no_proxy: Option, +} + +impl ResolvedCurlProxy { + /// Returns a human-readable summary with credentials redacted. + pub fn human_summary(&self) -> String { + if self.disabled { + return format!("代理已关闭;来源={}", self.source); + } + if let Some(url) = self.url.as_ref() { + let no_proxy = self + .no_proxy + .as_ref() + .map(|value| format!(";no_proxy={value}")) + .unwrap_or_default(); + return format!( + "使用代理 {};来源={}{}", + redact_proxy_url(url), + self.source, + no_proxy + ); + } + format!("未检测到代理;来源={}", self.source) + } +} + +/// Resolves the proxy setting that should be applied to `curl`. +pub fn resolve_curl_proxy(config: &CurlProxyConfig) -> ResolvedCurlProxy { + let pairs = proxy_env_pairs(); + resolve_curl_proxy_from_pairs(config, &pairs) +} + +/// Redacts user info from a proxy URL for diagnostics and logs. +pub fn redact_proxy_url(url: &str) -> String { + let Some((scheme, rest)) = url.split_once("://") else { + return url.to_string(); + }; + let Some(at_index) = rest.find('@') else { + return url.to_string(); + }; + format!("{scheme}://@{}", &rest[at_index + 1..]) +} + +fn proxy_env_pairs() -> Vec<(String, String)> { + PROXY_ENV_PRIORITY + .iter() + .chain(NO_PROXY_ENV_PRIORITY.iter()) + .filter_map(|name| { + env::var(name) + .ok() + .map(|value| ((*name).to_string(), value)) + }) + .collect() +} + +fn resolve_curl_proxy_from_pairs( + config: &CurlProxyConfig, + pairs: &[(String, String)], +) -> ResolvedCurlProxy { + match config.mode() { + CurlProxyMode::Auto => { + let no_proxy = first_env_value(pairs, &NO_PROXY_ENV_PRIORITY).map(|(_, value)| value); + if let Some((name, url)) = first_env_value(pairs, &PROXY_ENV_PRIORITY) { + ResolvedCurlProxy { + enabled: true, + disabled: false, + url: Some(url), + source: name, + no_proxy, + } + } else { + ResolvedCurlProxy { + enabled: false, + disabled: false, + url: None, + source: "auto".to_string(), + no_proxy, + } + } + } + CurlProxyMode::Disabled => ResolvedCurlProxy { + enabled: false, + disabled: true, + url: None, + source: "--no-proxy".to_string(), + no_proxy: None, + }, + CurlProxyMode::Url(url) => ResolvedCurlProxy { + enabled: true, + disabled: false, + url: Some(url.clone()), + source: "--proxy".to_string(), + no_proxy: first_env_value(pairs, &NO_PROXY_ENV_PRIORITY).map(|(_, value)| value), + }, + } +} + +fn first_env_value(pairs: &[(String, String)], priority: &[&str]) -> Option<(String, String)> { + priority.iter().find_map(|name| { + pairs + .iter() + .find(|(candidate, value)| candidate == name && !value.trim().is_empty()) + .map(|(candidate, value)| (candidate.clone(), value.clone())) + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CurlFailureKind { HttpForbidden, @@ -194,18 +374,22 @@ impl std::fmt::Display for CurlRetryError { impl std::error::Error for CurlRetryError {} -pub(crate) fn run_curl_with_retry( +pub(crate) fn run_curl_with_retry_with_proxy( curl_command: &Path, url: &str, destination: Option<&Path>, attempts: usize, + proxy_config: &CurlProxyConfig, mut build_command: impl FnMut() -> Command, ) -> Result { let attempts = attempts.max(1); let mut failures = Vec::new(); + let proxy = resolve_curl_proxy(proxy_config); for attempt in 1..=attempts { - let failure = match build_command().output() { + let mut command = build_command(); + apply_curl_proxy(&mut command, &proxy); + let failure = match command.output() { Ok(output) if output.status.success() => return Ok(output), Ok(output) => CurlFailure::from_output(url, destination, &output), Err(error) => CurlFailure::process_failed(url, destination, curl_command, error), @@ -224,6 +408,31 @@ pub(crate) fn run_curl_with_retry( Err(CurlRetryError { attempts: failures }) } +fn apply_curl_proxy(command: &mut Command, proxy: &ResolvedCurlProxy) { + if proxy.disabled { + remove_proxy_env(command); + command.arg("--noproxy").arg("*"); + return; + } + + if let Some(url) = proxy.url.as_ref() { + remove_proxy_env(command); + command.arg("--proxy").arg(url); + if let Some(no_proxy) = proxy.no_proxy.as_ref() { + command.arg("--noproxy").arg(no_proxy); + } + } +} + +fn remove_proxy_env(command: &mut Command) { + for name in PROXY_ENV_PRIORITY + .iter() + .chain(NO_PROXY_ENV_PRIORITY.iter()) + { + command.env_remove(name); + } +} + fn classify_failure(exit_code: Option, http_status: Option) -> CurlFailureKind { if exit_code == Some(22) { return match http_status { @@ -305,4 +514,66 @@ mod tests { assert_eq!(failure.kind, CurlFailureKind::HttpServerError); assert!(failure.retryable()); } + + #[test] + fn auto_proxy_prefers_https_and_preserves_no_proxy() { + let pairs = vec![ + ( + "HTTP_PROXY".to_string(), + "http://proxy-http:8080".to_string(), + ), + ( + "HTTPS_PROXY".to_string(), + "http://proxy-https:7890".to_string(), + ), + ("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()), + ]; + + let resolved = resolve_curl_proxy_from_pairs(&CurlProxyConfig::auto(), &pairs); + + assert!(resolved.enabled); + assert!(!resolved.disabled); + assert_eq!(resolved.url.as_deref(), Some("http://proxy-https:7890")); + assert_eq!(resolved.source, "HTTPS_PROXY"); + assert_eq!(resolved.no_proxy.as_deref(), Some("localhost,127.0.0.1")); + } + + #[test] + fn disabled_proxy_ignores_environment() { + let pairs = vec![( + "HTTPS_PROXY".to_string(), + "http://proxy-https:7890".to_string(), + )]; + + let resolved = resolve_curl_proxy_from_pairs(&CurlProxyConfig::disabled(), &pairs); + + assert!(!resolved.enabled); + assert!(resolved.disabled); + assert!(resolved.url.is_none()); + assert_eq!(resolved.source, "--no-proxy"); + } + + #[test] + fn explicit_proxy_wins_and_redacts_credentials() { + let pairs = vec![( + "HTTPS_PROXY".to_string(), + "http://environment:7890".to_string(), + )]; + + let resolved = resolve_curl_proxy_from_pairs( + &CurlProxyConfig::url("http://user:secret@127.0.0.1:7890"), + &pairs, + ); + + assert!(resolved.enabled); + assert_eq!( + resolved.url.as_deref(), + Some("http://user:secret@127.0.0.1:7890") + ); + assert_eq!(resolved.source, "--proxy"); + assert_eq!( + redact_proxy_url(resolved.url.as_deref().unwrap()), + "http://@127.0.0.1:7890" + ); + } } diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 31d5416..d0ee02f 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -24,6 +24,9 @@ pub mod resources; mod zip_validation; pub use cas::FileSystemCasRepository; +pub use curl_transfer::{ + redact_proxy_url, resolve_curl_proxy, CurlProxyConfig, CurlProxyMode, ResolvedCurlProxy, +}; pub use import::{ BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport, ResourceImportService, diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 43c77ab..d19d1da 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -1,6 +1,6 @@ //! Official JP resource download execution. -use crate::curl_transfer::{run_curl_with_retry, CurlRetryError}; +use crate::curl_transfer::{run_curl_with_retry_with_proxy, CurlProxyConfig, CurlRetryError}; use crate::official_pull::OfficialResourcePullPlan; use crate::path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, @@ -441,6 +441,7 @@ impl OfficialLocalResourceState { pub struct OfficialResourcePullService { output_root: PathBuf, curl_command: PathBuf, + curl_proxy: CurlProxyConfig, retry_attempts: usize, } @@ -458,10 +459,17 @@ impl OfficialResourcePullService { Self { output_root: output_root.into(), curl_command: curl_command.into(), + curl_proxy: CurlProxyConfig::default(), retry_attempts: DEFAULT_RETRY_ATTEMPTS, } } + /// Sets the proxy configuration for every `curl` transfer. + pub fn with_proxy_config(mut self, curl_proxy: CurlProxyConfig) -> Self { + self.curl_proxy = curl_proxy; + self + } + /// Sets the number of attempts for each `curl` transfer. pub fn with_retry_attempts(mut self, retry_attempts: usize) -> Self { self.retry_attempts = retry_attempts.max(1); @@ -709,8 +717,13 @@ impl OfficialResourcePullService { return Err(format!("拒绝拉取非官方 URL:{url}")); } - let output = - run_curl_with_retry(&self.curl_command, url, None, self.retry_attempts, || { + let output = run_curl_with_retry_with_proxy( + &self.curl_command, + url, + None, + self.retry_attempts, + &self.curl_proxy, + || { let mut command = Command::new(&self.curl_command); command .arg("--fail") @@ -720,8 +733,9 @@ impl OfficialResourcePullService { .arg("--url") .arg(url); command - }) - .map_err(|error| format!("curl 拉取失败 {url}:{error}"))?; + }, + ) + .map_err(|error| format!("curl 拉取失败 {url}:{error}"))?; Ok(output.stdout) } @@ -819,11 +833,12 @@ impl OfficialResourcePullService { destination: &Path, resume: bool, ) -> Result<(), CurlRetryError> { - run_curl_with_retry( + run_curl_with_retry_with_proxy( &self.curl_command, url, Some(destination), self.retry_attempts, + &self.curl_proxy, || { let mut command = Command::new(&self.curl_command); command diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index da8a56e..6551b2f 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -4,7 +4,7 @@ //! artifacts into a temporary directory, extracts only the data needed to read //! `GameMainConfig`, and does not install or execute the official launcher. -use crate::curl_transfer::run_curl_with_retry; +use crate::curl_transfer::{run_curl_with_retry_with_proxy, CurlProxyConfig}; use crate::official_launcher::launcher_package_url; use crate::official_launcher::OfficialLauncherBootstrapService; use crate::official_launcher::{ @@ -49,6 +49,7 @@ pub struct OfficialGameMainConfigBootstrap { pub struct OfficialGameMainConfigBootstrapService { launcher: OfficialLauncherBootstrapService, curl_command: PathBuf, + curl_proxy: CurlProxyConfig, unzip_command: PathBuf, } @@ -73,10 +74,18 @@ impl OfficialGameMainConfigBootstrapService { Self { launcher, curl_command, + curl_proxy: CurlProxyConfig::default(), unzip_command, } } + /// Sets the proxy configuration for launcher and package `curl` transfers. + pub fn with_proxy_config(mut self, curl_proxy: CurlProxyConfig) -> Self { + self.launcher = self.launcher.with_proxy_config(curl_proxy.clone()); + self.curl_proxy = curl_proxy; + self + } + /// Fetches the latest official game ZIP by re-querying the official /// launcher API, extracts `resources.assets`, and decrypts `GameMainConfig`. pub fn fetch_bootstrap(&self) -> Result { @@ -116,11 +125,12 @@ impl OfficialGameMainConfigBootstrapService { } fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> { - run_curl_with_retry( + run_curl_with_retry_with_proxy( &self.curl_command, url, Some(destination), DEFAULT_BOOTSTRAP_DOWNLOAD_RETRY_ATTEMPTS, + &self.curl_proxy, || { let mut command = Command::new(&self.curl_command); command diff --git a/infrastructure/src/official_launcher.rs b/infrastructure/src/official_launcher.rs index d7df5d8..2e5eae0 100644 --- a/infrastructure/src/official_launcher.rs +++ b/infrastructure/src/official_launcher.rs @@ -5,7 +5,9 @@ //! discover the latest Windows client package and manifest, while resource //! updates still come from the game client's server-info flow. -use crate::curl_transfer::run_curl_with_retry as run_curl_command_with_retry; +use crate::curl_transfer::{ + run_curl_with_retry_with_proxy as run_curl_command_with_retry, CurlProxyConfig, +}; use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG}; use md5::{Digest, Md5}; use serde::Deserialize; @@ -80,6 +82,7 @@ pub struct YostarJpLauncherRemoteManifest { #[derive(Debug, Clone)] pub struct OfficialLauncherBootstrapService { curl_command: String, + curl_proxy: CurlProxyConfig, launcher_version: String, retry_attempts: usize, } @@ -97,11 +100,18 @@ impl OfficialLauncherBootstrapService { ) -> Self { Self { curl_command: curl_command.into(), + curl_proxy: CurlProxyConfig::default(), launcher_version: launcher_version.into(), retry_attempts: DEFAULT_LAUNCHER_RETRY_ATTEMPTS, } } + /// Sets the proxy configuration for launcher `curl` transfers. + pub fn with_proxy_config(mut self, curl_proxy: CurlProxyConfig) -> Self { + self.curl_proxy = curl_proxy; + self + } + /// Sets the number of attempts for each launcher `curl` transfer. pub fn with_retry_attempts(mut self, retry_attempts: usize) -> Self { self.retry_attempts = retry_attempts.max(1); @@ -263,6 +273,7 @@ impl OfficialLauncherBootstrapService { url, None, self.retry_attempts, + &self.curl_proxy, &mut build_command, ) .map_err(|error| error.to_string()) diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 2aad143..c3b327c 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -5,6 +5,7 @@ //! callers: discover current official state, compare remote markers, audit the //! local manifest, repair/download when needed, then return a structured report. +use crate::curl_transfer::{resolve_curl_proxy, CurlProxyConfig}; use crate::path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE, @@ -79,6 +80,8 @@ pub struct OfficialUpdateConfig { pub snapshot_path: Option, /// Curl command used by the current infrastructure downloader. pub curl_command: PathBuf, + /// Proxy selection used by all official `curl` transfers. + pub curl_proxy: CurlProxyConfig, /// Unzip command used when a metadata change requires GameMainConfig parsing. pub unzip_command: PathBuf, /// Dry run reports decisions and optional plan URLs without writing sync state. @@ -105,6 +108,7 @@ impl Default for OfficialUpdateConfig { output_root: PathBuf::from("./bat-resources"), snapshot_path: None, curl_command: PathBuf::from("curl"), + curl_proxy: CurlProxyConfig::default(), unzip_command: PathBuf::from("unzip"), dry_run: false, plan: false, @@ -827,6 +831,10 @@ impl OfficialUpdateService { )); check_shutdown_requested(&mut should_cancel)?; validate_update_paths(config).map_err(anyhow::Error::msg)?; + progress(OfficialUpdateProgress::new( + "proxy", + resolve_curl_proxy(&config.curl_proxy).human_summary(), + )); let _lock = if config.dry_run { progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁")); @@ -862,7 +870,8 @@ impl OfficialUpdateService { let fetcher = OfficialResourcePullService::with_curl_command( &active_resource_root, &config.curl_command, - ); + ) + .with_proxy_config(config.curl_proxy.clone()); let snapshot_path = snapshot_path_for(config, &active_resource_root); let bootstrap_cache_path = config.bootstrap_cache_path(); @@ -878,6 +887,7 @@ impl OfficialUpdateService { Some(resolve_bootstrap( &config.launcher_version, &config.curl_command, + &config.curl_proxy, &config.unzip_command, &bootstrap_cache_path, !config.dry_run, @@ -1256,7 +1266,8 @@ impl OfficialUpdateService { let staging_fetcher = OfficialResourcePullService::with_curl_command( &publish_plan.staging_path, &config.curl_command, - ); + ) + .with_proxy_config(config.curl_proxy.clone()); report.staging_path = Some(publish_plan.staging_path.clone()); report.snapshot_path = staging_snapshot_path.clone(); report.download_manifest = staging_fetcher.download_manifest_path(); @@ -2214,6 +2225,7 @@ fn game_main_config_snapshot(config: &YostarJpGameMainConfig) -> GameMainConfigS fn resolve_bootstrap( launcher_version: &str, curl_command: &Path, + curl_proxy: &CurlProxyConfig, unzip_command: &Path, cache_path: &Path, write_cache: bool, @@ -2224,7 +2236,8 @@ fn resolve_bootstrap( let launcher = OfficialLauncherBootstrapService::with_curl_command( launcher_version, curl_command.to_string_lossy().to_string(), - ); + ) + .with_proxy_config(curl_proxy.clone()); progress(OfficialUpdateProgress::new( "launcher", "正在拉取官方启动器游戏配置和远端 manifest", @@ -2272,7 +2285,8 @@ fn resolve_bootstrap( launcher_version, curl_command.to_path_buf(), unzip_command.to_path_buf(), - ); + ) + .with_proxy_config(curl_proxy.clone()); let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::msg)?; check_shutdown_requested(should_cancel)?; let launcher_metadata = LauncherMetadataSnapshot {