use std::path::{Path, PathBuf};
use anyhow::Context as _;
use clap::builder::TypedValueParser as _;
use clap::{Parser, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Transport {
Http,
Stdio,
}
#[derive(Parser)]
#[command(name = "bugwarden", version, about)]
pub struct Cli {
#[arg(long, env = "BUGZILLA_SERVER")]
pub bugzilla_server: String,
#[arg(long, env = "MCP_TRANSPORT", value_enum, default_value = "http")]
pub transport: Transport,
#[arg(long, env = "MCP_HOST", default_value = "127.0.0.1")]
pub host: String,
#[arg(long, env = "MCP_PORT", default_value_t = 8000)]
pub port: u16,
#[arg(long, value_name = "HOST")]
pub allowed_hosts: Vec<String>,
#[arg(long, env = "MCP_API_KEY_HEADER", default_value = "ApiKey")]
pub api_key_header: String,
#[arg(long, env = "BUGZILLA_API_KEY", hide_env_values = true)]
pub api_key: Option<String>,
#[arg(
long,
env = "BUGZILLA_API_KEY_FILE",
value_hint = clap::ValueHint::FilePath,
value_parser = clap::builder::OsStringValueParser::new().map(PathBuf::from)
)]
pub api_key_file: Option<PathBuf>,
#[arg(long)]
pub use_auth_header: bool,
#[arg(long, env = "MCP_READ_ONLY")]
pub read_only: bool,
#[arg(long, env = "BUGWARDEN_POLICY", value_hint = clap::ValueHint::FilePath)]
pub policy: Option<PathBuf>,
#[arg(long, env = "BUGWARDEN_AUDIT_CONFIG", value_hint = clap::ValueHint::FilePath)]
pub audit_config: Option<PathBuf>,
}
impl std::fmt::Debug for Cli {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cli")
.field("bugzilla_server", &self.bugzilla_server)
.field("transport", &self.transport)
.field("host", &self.host)
.field("port", &self.port)
.field("allowed_hosts", &self.allowed_hosts)
.field("api_key_header", &self.api_key_header)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("api_key_file", &self.api_key_file)
.field("use_auth_header", &self.use_auth_header)
.field("read_only", &self.read_only)
.field("policy", &self.policy)
.field("audit_config", &self.audit_config)
.finish()
}
}
pub fn command() -> clap::Command {
use clap::CommandFactory as _;
Cli::command()
}
#[derive(Clone)]
pub enum KeyCustody {
Server(String),
PerRequest,
}
impl Cli {
pub fn resolve_key_custody(&self) -> anyhow::Result<KeyCustody> {
let startup_key = self.api_key.as_deref().filter(|k| !k.is_empty());
let key_file = self
.api_key_file
.as_deref()
.filter(|p| !p.as_os_str().is_empty());
if startup_key.is_some() && key_file.is_some() {
anyhow::bail!(
"--api-key (BUGZILLA_API_KEY) and --api-key-file (BUGZILLA_API_KEY_FILE) \
are mutually exclusive; pass exactly one"
);
}
if let Some(path) = key_file {
let key = read_key_file(path)?;
match self.transport {
Transport::Stdio => tracing::info!("API key: startup key (stdio)"),
Transport::Http => tracing::info!(
"API key: server-held (from {}); the per-request '{}' header \
is not consulted",
path.display(),
self.api_key_header
),
}
return Ok(KeyCustody::Server(key));
}
match self.transport {
Transport::Stdio => match startup_key {
Some(key) => {
tracing::info!("API key: startup key (stdio)");
Ok(KeyCustody::Server(key.to_owned()))
}
None => anyhow::bail!(
"--transport stdio requires --api-key or the BUGZILLA_API_KEY environment variable"
),
},
Transport::Http => {
if self.api_key.is_some() {
tracing::warn!(
"--api-key / BUGZILLA_API_KEY is set but ignored with --transport http \
(clients send the key per-request via the API key header; use \
--api-key-file for a server-held key). Unset it to clean the config."
);
}
tracing::info!(
"API key: per-request via the '{}' header",
self.api_key_header
);
Ok(KeyCustody::PerRequest)
}
}
}
}
fn read_key_file(path: &Path) -> anyhow::Result<String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
tracing::warn!(
path = %path.display(),
mode = format!("{:o}", mode & 0o777),
"API key file is accessible by group or others; \
restrict it to 0600"
);
}
}
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read the API key file {}", path.display()))?;
let key = raw.trim();
if key.is_empty() {
anyhow::bail!("the API key file {} is empty", path.display());
}
Ok(key.to_owned())
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use clap::Parser as _;
use super::*;
fn base_cli(transport: &str) -> Cli {
let mut cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
transport,
]);
cli.api_key = None;
cli.api_key_file = None;
cli
}
fn key_file(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("temp key file");
file.write_all(content.as_bytes()).expect("write key file");
file
}
fn server_key(custody: KeyCustody) -> String {
match custody {
KeyCustody::Server(key) => key,
KeyCustody::PerRequest => panic!("expected the server-held key"),
}
}
fn resolve_err(cli: &Cli) -> anyhow::Error {
match cli.resolve_key_custody() {
Err(e) => e,
Ok(_) => panic!("key custody resolution must fail"),
}
}
#[test]
fn both_key_sources_are_a_startup_error_naming_both_flags() {
for transport in ["stdio", "http"] {
let file = key_file("file-key\n");
let mut cli = base_cli(transport);
cli.api_key = Some("flag-key".into());
cli.api_key_file = Some(file.path().to_path_buf());
let msg = format!("{:#}", resolve_err(&cli));
assert!(
msg.contains("--api-key (BUGZILLA_API_KEY)"),
"unexpected error: {msg}"
);
assert!(
msg.contains("--api-key-file (BUGZILLA_API_KEY_FILE)"),
"unexpected error: {msg}"
);
}
}
#[test]
fn stdio_without_any_key_source_fails_at_resolution() {
let cli = base_cli("stdio");
let msg = format!("{:#}", resolve_err(&cli));
assert!(msg.contains("--transport stdio requires"), "{msg}");
let mut cli = base_cli("stdio");
cli.api_key = Some(String::new());
assert!(cli.resolve_key_custody().is_err());
}
#[test]
fn empty_or_whitespace_key_file_is_an_error_naming_the_path() {
for content in ["", " \n\t \n"] {
let file = key_file(content);
let mut cli = base_cli("http");
cli.api_key_file = Some(file.path().to_path_buf());
let msg = format!("{:#}", resolve_err(&cli));
assert!(
msg.contains(&file.path().display().to_string()),
"the error must name the path: {msg}"
);
}
}
#[test]
fn key_file_content_is_trimmed() {
let file = key_file("the-key\n");
let mut cli = base_cli("http");
cli.api_key_file = Some(file.path().to_path_buf());
let custody = cli.resolve_key_custody().expect("key file resolves");
assert_eq!(server_key(custody), "the-key");
}
#[test]
fn unreadable_key_file_is_an_error_naming_the_path() {
let mut cli = base_cli("http");
cli.api_key_file = Some(PathBuf::from("/nonexistent/bugwarden-key"));
let msg = format!("{:#}", resolve_err(&cli));
assert!(msg.contains("/nonexistent/bugwarden-key"), "{msg}");
}
#[test]
fn stdio_takes_the_key_from_the_file_too() {
let file = key_file("stdio-file-key\n");
let mut cli = base_cli("stdio");
cli.api_key_file = Some(file.path().to_path_buf());
let custody = cli.resolve_key_custody().expect("key file resolves");
assert_eq!(server_key(custody), "stdio-file-key");
}
#[test]
fn http_with_startup_key_alone_stays_per_request() {
let mut cli = base_cli("http");
cli.api_key = Some("flag-key".into());
let custody = cli.resolve_key_custody().expect("http resolves");
assert!(
matches!(custody, KeyCustody::PerRequest),
"http + --api-key must stay per-request"
);
}
#[test]
fn stdio_startup_key_is_server_custody() {
let mut cli = base_cli("stdio");
cli.api_key = Some("startup-key".into());
let custody = cli.resolve_key_custody().expect("stdio resolves");
assert_eq!(server_key(custody), "startup-key");
}
#[test]
fn empty_api_key_file_counts_as_absent() {
let mut cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"http",
"--api-key-file",
"",
]);
cli.api_key = None;
let custody = cli.resolve_key_custody().expect("http resolves");
assert!(
matches!(custody, KeyCustody::PerRequest),
"an empty key file path must not select server-held mode"
);
let mut cli = base_cli("stdio");
cli.api_key_file = Some(PathBuf::new());
let msg = format!("{:#}", resolve_err(&cli));
assert!(msg.contains("--transport stdio requires"), "{msg}");
}
#[test]
fn cli_debug_never_prints_the_startup_key_i12() {
let mut cli = base_cli("stdio");
cli.api_key = Some("SUPERSECRETKEY123".into());
let printed = format!("{cli:?}");
assert!(
!printed.contains("SUPERSECRETKEY123"),
"Debug must redact the key: {printed}"
);
assert!(printed.contains("<redacted>"), "{printed}");
}
#[test]
fn startup_log_states_mode_and_source_never_key_material_i12() {
let file = key_file("hush-key\n");
let mut cli = base_cli("http");
cli.api_key_file = Some(file.path().to_path_buf());
let (custody, logs) = crate::testlog::capture_logs(|| cli.resolve_key_custody());
drop(custody.expect("key file resolves"));
assert!(logs.contains("server-held"), "startup log: {logs}");
assert!(
logs.contains(&file.path().display().to_string()),
"the log must name the source path: {logs}"
);
assert!(
!logs.contains("hush-key"),
"the log must never carry key material (I12): {logs}"
);
let cli = base_cli("http");
let (custody, logs) = crate::testlog::capture_logs(|| cli.resolve_key_custody());
drop(custody.expect("http resolves"));
assert!(
logs.contains("per-request via the 'ApiKey' header"),
"startup log: {logs}"
);
}
}