mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:15:14 +08:00
fix(official-sync): 代理凭据改由环境变量下传子进程
彻底收敛 1-2 的两处残留凭据暴露: - 子进程 argv:daemon_child_args 不再把 --proxy <url> 写入子进程参数,改为 只放不含凭据的内部 flag --proxy-from-env,真实 URL 经 BAT_OFFICIAL_SYNC_PROXY_URL 环境变量下传。凭据从世界可读的 /proc/<pid>/cmdline(0444)转移到仅属主可读的 /proc/<pid>/environ(0400);子进程解析后立即 env::remove_var,避免被 curl 等 孙进程继承。 - 状态文件 at-rest:0600 状态文件的 command 字段不再含凭据。restart/reload 复用 所需的 URL 改存专用 0600 文件 bat-proxy.secret,该文件永不进入状态输出或 --json; clean-stable 会在后台停止后清除它。 复用型 restart/reload 从 bat-proxy.secret 还原代理;凭据文件缺失时(如 clean-stable 后)明确报错要求重新传入 --proxy,而非静默丢弃代理。 验证:新增 curl_proxy_url、secret 文件 0600 往返、--proxy-from-env 读取并清除 环境变量的单测;真机起停 daemon 确认凭据不在 cmdline/状态文件/status --json, 仅在 0600 secret 文件与 owner-only environ;复用 restart 成功还原、clean-stable 后复用 restart 明确报错。 对应 issue #18 维护清单 1-2 残留项。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,12 @@ const DAEMON_LOG_FILE: &str = "bat-daemon.log";
|
|||||||
const DAEMON_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl";
|
const DAEMON_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl";
|
||||||
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
||||||
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
|
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 DAEMON_STATUS_VERSION: u32 = 1;
|
||||||
const STRUCTURED_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
const STRUCTURED_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||||
const STRUCTURED_LOG_ROTATE_KEEP: usize = 3;
|
const STRUCTURED_LOG_ROTATE_KEEP: usize = 3;
|
||||||
@@ -1350,13 +1356,21 @@ fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result<DaemonStart
|
|||||||
let resource_output_root = normalized_abs_path(&options.config.output_root)?;
|
let resource_output_root = normalized_abs_path(&options.config.output_root)?;
|
||||||
let state_dir = options.state_dir.clone();
|
let state_dir = options.state_dir.clone();
|
||||||
let args = daemon_child_args(options);
|
let args = daemon_child_args(options);
|
||||||
start_daemon_with_args(state_dir, resource_output_root, args, "后台同步已启动")
|
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(
|
fn start_daemon_with_args(
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
resource_output_root: PathBuf,
|
resource_output_root: PathBuf,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
message: &'static str,
|
message: &'static str,
|
||||||
) -> anyhow::Result<DaemonStartReport> {
|
) -> anyhow::Result<DaemonStartReport> {
|
||||||
validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?;
|
validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?;
|
||||||
@@ -1380,6 +1394,13 @@ fn start_daemon_with_args(
|
|||||||
PidLockState::Missing => {}
|
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 executable = env::current_exe()?;
|
||||||
let log =
|
let log =
|
||||||
open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?;
|
open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?;
|
||||||
@@ -1390,8 +1411,23 @@ fn start_daemon_with_args(
|
|||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::from(log_for_stdout))
|
.stdout(Stdio::from(log_for_stdout))
|
||||||
.stderr(Stdio::from(log));
|
.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);
|
configure_daemon_command(&mut command);
|
||||||
let child = command.spawn()?;
|
let child = match command.spawn() {
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(error) => {
|
||||||
|
// 子进程未能启动,清除刚写入的代理凭据,避免残留。
|
||||||
|
clear_daemon_proxy_secret(&state_dir);
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
|
|
||||||
if let Err(error) = write_file_atomic(
|
if let Err(error) = write_file_atomic(
|
||||||
@@ -1403,6 +1439,7 @@ fn start_daemon_with_args(
|
|||||||
.map_err(anyhow::Error::msg)
|
.map_err(anyhow::Error::msg)
|
||||||
{
|
{
|
||||||
let _ = terminate_process(pid);
|
let _ = terminate_process(pid);
|
||||||
|
clear_daemon_proxy_secret(&state_dir);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
let mut command = Vec::with_capacity(args.len() + 1);
|
let mut command = Vec::with_capacity(args.len() + 1);
|
||||||
@@ -1717,6 +1754,8 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?;
|
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 status_report = build_daemon_status_report(&options.state_dir)?;
|
||||||
let previous_pid = status_report.pid.filter(|pid| process_exists(*pid));
|
let previous_pid = status_report.pid.filter(|pid| process_exists(*pid));
|
||||||
|
|
||||||
@@ -1736,7 +1775,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
|||||||
let _ = stop_daemon_inner(&options.state_dir)?;
|
let _ = stop_daemon_inner(&options.state_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (state_dir, resource_output_root, args, strategy) = if has_explicit_options {
|
let (state_dir, resource_output_root, args, proxy_url, strategy) = if has_explicit_options {
|
||||||
let mut start_options = options.clone();
|
let mut start_options = options.clone();
|
||||||
start_options.daemon = true;
|
start_options.daemon = true;
|
||||||
start_options.watch = false;
|
start_options.watch = false;
|
||||||
@@ -1744,6 +1783,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
|||||||
options.state_dir.clone(),
|
options.state_dir.clone(),
|
||||||
normalized_abs_path(&options.config.output_root)?,
|
normalized_abs_path(&options.config.output_root)?,
|
||||||
daemon_child_args(&start_options),
|
daemon_child_args(&start_options),
|
||||||
|
curl_proxy_url(&options.config.curl_proxy),
|
||||||
"start_with_explicit_options",
|
"start_with_explicit_options",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -1762,10 +1802,21 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
|||||||
"后台状态文件中的 command 参数为空,无法执行 {command_name}"
|
"后台状态文件中的 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(),
|
options.state_dir.clone(),
|
||||||
status_file.resource_output_root,
|
status_file.resource_output_root,
|
||||||
args,
|
args,
|
||||||
|
proxy_url,
|
||||||
"restart_with_existing_command",
|
"restart_with_existing_command",
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -1774,6 +1825,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
|||||||
state_dir.clone(),
|
state_dir.clone(),
|
||||||
resource_output_root.clone(),
|
resource_output_root.clone(),
|
||||||
args,
|
args,
|
||||||
|
proxy_url,
|
||||||
if command_name == "reload" {
|
if command_name == "reload" {
|
||||||
"后台配置已重新加载"
|
"后台配置已重新加载"
|
||||||
} else {
|
} else {
|
||||||
@@ -2920,6 +2972,13 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
let report = CleanStableReport {
|
let report = CleanStableReport {
|
||||||
command: "clean-stable",
|
command: "clean-stable",
|
||||||
status: if skipped_paths.is_empty() {
|
status: if skipped_paths.is_empty() {
|
||||||
@@ -3119,6 +3178,51 @@ fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
|
|||||||
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
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> {
|
fn active_official_resource_root(output_root: &Path) -> anyhow::Result<PathBuf> {
|
||||||
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
|
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
|
||||||
let metadata = match fs::symlink_metadata(¤t_path) {
|
let metadata = match fs::symlink_metadata(¤t_path) {
|
||||||
@@ -3205,9 +3309,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
|||||||
CurlProxyMode::Disabled => {
|
CurlProxyMode::Disabled => {
|
||||||
args.push("--no-proxy".to_string());
|
args.push("--no-proxy".to_string());
|
||||||
}
|
}
|
||||||
CurlProxyMode::Url(url) => {
|
CurlProxyMode::Url(_) => {
|
||||||
args.push("--proxy".to_string());
|
// 凭据经 PROXY_URL_ENV_VAR 环境变量下传子进程;此处只放不含凭据的 flag,
|
||||||
args.push(url.clone());
|
// 避免代理 URL 进入子进程 argv(/proc/<pid>/cmdline)与状态文件 command 字段。
|
||||||
|
args.push(PROXY_FROM_ENV_FLAG.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
args.push("--unzip".to_string());
|
args.push("--unzip".to_string());
|
||||||
@@ -3769,6 +3874,16 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
|
|||||||
options.config.curl_proxy = CurlProxyConfig::disabled();
|
options.config.curl_proxy = CurlProxyConfig::disabled();
|
||||||
options.proxy_option_explicit = true;
|
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" => {
|
"--unzip" => {
|
||||||
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||||
}
|
}
|
||||||
@@ -4408,9 +4523,10 @@ mod tests {
|
|||||||
assert!(args
|
assert!(args
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.any(|pair| pair == ["--platforms", "Windows,Android"]));
|
.any(|pair| pair == ["--platforms", "Windows,Android"]));
|
||||||
assert!(args
|
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
|
||||||
.windows(2)
|
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
|
||||||
.any(|pair| pair == ["--proxy", "http://127.0.0.1:7890"]));
|
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 == ["--interval", "30m"]));
|
||||||
assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"]));
|
assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"]));
|
||||||
assert!(args.contains(&"--quiet-up-to-date".to_string()));
|
assert!(args.contains(&"--quiet-up-to-date".to_string()));
|
||||||
@@ -4418,6 +4534,73 @@ mod tests {
|
|||||||
assert!(args.contains(&"--no-banner".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]
|
#[test]
|
||||||
fn parses_explicit_watch_error_retry_interval() {
|
fn parses_explicit_watch_error_retry_interval() {
|
||||||
let options =
|
let options =
|
||||||
|
|||||||
Reference in New Issue
Block a user