use std::cell::Cell;
use std::collections::HashMap;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{BzrError, Result};
use crate::types::{ApiMode, AuthMethod, BugTemplate, SavedQuery};
#[derive(Debug, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub struct Config {
pub default_server: Option<String>,
#[serde(default)]
pub servers: HashMap<String, ServerConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub templates: HashMap<String, BugTemplate>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub queries: HashMap<String, SavedQuery>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ServerConfig {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_keyring: Option<KeyringRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_method: Option<AuthMethod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_mode: Option<ApiMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tls_insecure: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_ca_cert: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_pin_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_pin_issuer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_pin_issuer_der: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub struct KeyringRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
}
impl KeyringRef {
pub fn service_or_default(&self) -> &str {
self.service.as_deref().unwrap_or("bzr")
}
pub fn account_or_default<'a>(&'a self, server_name: &'a str) -> &'a str {
self.account.as_deref().unwrap_or(server_name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialSourceKind {
Inline,
Env,
Keyring,
}
#[derive(Debug)]
pub enum CredentialSource<'a> {
Inline(&'a str),
EnvVar(&'a str),
Keyring { service: &'a str, account: &'a str },
}
impl CredentialSource<'_> {
pub fn kind(&self) -> CredentialSourceKind {
match self {
CredentialSource::Inline(_) => CredentialSourceKind::Inline,
CredentialSource::EnvVar(_) => CredentialSourceKind::Env,
CredentialSource::Keyring { .. } => CredentialSourceKind::Keyring,
}
}
}
impl CredentialSourceKind {
pub fn as_str(self) -> &'static str {
match self {
CredentialSourceKind::Inline => "inline",
CredentialSourceKind::Env => "env",
CredentialSourceKind::Keyring => "keyring",
}
}
}
impl ServerConfig {
pub fn tls_config(&self, server_name: &str) -> crate::tls::TlsConfig {
crate::tls::TlsConfig {
insecure: self.tls_insecure,
ca_cert_path: self.tls_ca_cert.clone(),
pin_sha256: self.tls_pin_sha256.clone(),
pin_issuer_der: self.tls_pin_issuer_der.clone(),
server_name: Some(server_name.to_string()),
}
}
pub fn validate(&self, server_name: &str) -> Result<()> {
self.credential_source()
.map(|_| ())
.map_err(|err| BzrError::config(format!("server '{server_name}': {err}")))?;
self.validate_tls(server_name)
}
pub fn credential_source(&self) -> Result<CredentialSource<'_>> {
let count = usize::from(self.api_key.is_some())
+ usize::from(self.api_key_env.is_some())
+ usize::from(self.api_key_keyring.is_some());
match count {
0 => Err(BzrError::config(
"server config must define one of 'api_key', 'api_key_env', or 'api_key_keyring'",
)),
1 => {
if let Some(api_key) = self.api_key.as_deref() {
Ok(CredentialSource::Inline(api_key))
} else if let Some(var_name) = self.api_key_env.as_deref() {
Ok(CredentialSource::EnvVar(var_name))
} else {
let r = self.api_key_keyring.as_ref().ok_or_else(|| {
BzrError::config("internal: keyring credential unexpectedly missing")
})?;
Ok(CredentialSource::Keyring {
service: r.service_or_default(),
account: r.account.as_deref().unwrap_or(""),
})
}
}
_ => Err(BzrError::config(
"server config cannot define multiple API key sources \
(api_key, api_key_env, api_key_keyring)",
)),
}
}
pub fn credential_source_kind(&self) -> Result<CredentialSourceKind> {
Ok(self.credential_source()?.kind())
}
pub fn resolve_api_key(&self, server_name: &str) -> Result<String> {
match self.credential_source()? {
CredentialSource::Inline(api_key) => Ok(api_key.to_string()),
CredentialSource::EnvVar(var_name) => {
let value = std::env::var(var_name).map_err(|_| {
BzrError::config(format!(
"server '{server_name}' uses API key env var '{var_name}', but it is not set"
))
})?;
if value.is_empty() {
return Err(BzrError::config(format!(
"server '{server_name}' uses API key env var '{var_name}', but it is empty"
)));
}
Ok(value)
}
CredentialSource::Keyring { service, account } => {
let account = if account.is_empty() {
server_name
} else {
account
};
crate::credentials::keyring::retrieve(service, account)
}
}
}
pub fn validate_tls(&self, server_name: &str) -> Result<()> {
let ctx = |msg: &str| BzrError::config(format!("server '{server_name}': {msg}"));
if self.tls_insecure && self.tls_ca_cert.is_some() {
return Err(ctx("tls_insecure and tls_ca_cert are mutually exclusive"));
}
if self.tls_insecure && self.tls_pin_sha256.is_some() {
return Err(ctx(
"tls_insecure and tls_pin_sha256 are mutually exclusive",
));
}
if self.tls_ca_cert.is_some() && self.tls_pin_sha256.is_some() {
return Err(ctx("tls_ca_cert and tls_pin_sha256 are mutually exclusive"));
}
if let Some(path) = &self.tls_ca_cert {
if !path.exists() {
return Err(BzrError::config(format!(
"server '{server_name}': tls_ca_cert file not found: {}",
path.display()
)));
}
}
if let Some(pin) = &self.tls_pin_sha256 {
crate::tls::fingerprint::parse_pin(pin)
.map_err(|e| ctx(&format!("invalid tls_pin_sha256: {e}")))?;
}
Ok(())
}
}
impl Config {
pub fn path() -> Result<PathBuf> {
let config_dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(dirs::config_dir)
.ok_or_else(|| BzrError::config("cannot determine config directory"))?;
Ok(config_dir.join("bzr").join("config.toml"))
}
fn ensure_config_dir() -> Result<PathBuf> {
let path = Self::path()?;
let parent = path
.parent()
.ok_or_else(|| BzrError::config("config path has no parent directory"))?
.to_path_buf();
let parent_exists = parent.exists();
fs::create_dir_all(&parent)?;
if !parent_exists {
set_private_directory_permissions(&parent)?;
}
Ok(parent)
}
fn read_unvalidated() -> Result<Config> {
let path = Self::path()?;
match fs::read_to_string(&path) {
Ok(content) => Ok(toml::from_str(&content)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => Err(e.into()),
}
}
pub fn load() -> Result<Config> {
let path = Self::path()?;
if path.exists() {
Self::warn_on_insecure_permissions(&path);
}
let config = Self::read_unvalidated()?;
config.validate()?;
Ok(config)
}
#[cfg(test)]
fn save(&self) -> Result<()> {
self.validate()?;
self.write_to_disk()
}
pub fn update_locked(mutator: impl FnOnce(&mut Config) -> Result<()>) -> Result<Config> {
Self::update_locked_inner(true, mutator)
}
pub fn update_locked_without_validation(
mutator: impl FnOnce(&mut Config) -> Result<()>,
) -> Result<Config> {
Self::update_locked_inner(false, mutator)
}
fn update_locked_inner(
validate: bool,
mutator: impl FnOnce(&mut Config) -> Result<()>,
) -> Result<Config> {
if LOCK_HELD.with(Cell::get) {
return Err(BzrError::config(
"internal error: Config::update_locked called re-entrantly \
(a mutation closure must not write the config itself)",
));
}
let dir = Self::ensure_config_dir()?;
let lock_path = dir.join("config.lock");
let file = open_lock_file(&lock_path)?;
acquire_exclusive_lock(&file, &lock_path)?;
LOCK_HELD.with(|held| held.set(true));
let _guard = LockGuard { file };
let mut config = Self::read_unvalidated()?;
mutator(&mut config)?;
if validate {
config.validate()?;
}
config.write_to_disk()?;
Ok(config)
}
#[cfg(test)]
fn save_without_validation(&self) -> Result<()> {
self.write_to_disk()
}
fn write_to_disk(&self) -> Result<()> {
let _dir = Self::ensure_config_dir()?;
let path = Self::path()?;
reap_stale_temps(&path);
let content = toml::to_string_pretty(self)?;
atomic_write(&path, &content)?;
Self::warn_on_insecure_permissions(&path);
Ok(())
}
pub fn resolve_server<'a>(
&'a self,
server_name: Option<&'a str>,
) -> Result<(&'a str, &'a ServerConfig)> {
let name = self.resolve_server_name_only(server_name)?;
let srv = self
.servers
.get(name)
.ok_or_else(|| BzrError::config(format!("server '{name}' not found in config")))?;
Ok((name, srv))
}
pub fn resolve_server_name_only<'a>(&'a self, server_name: Option<&'a str>) -> Result<&'a str> {
server_name
.or(self.default_server.as_deref())
.ok_or_else(|| {
BzrError::config(
"no server configured. Run `bzr config set-server <name> --url <url> --api-key-env <env-var>` first",
)
})
}
fn warn_on_insecure_permissions(path: &std::path::Path) {
#[cfg(unix)]
{
if let Some(parent) = path.parent() {
warn_if_path_permissions_too_open(parent, 0o077, "config directory");
}
if path.exists() {
warn_if_path_permissions_too_open(path, 0o077, "config file");
}
}
}
fn validate(&self) -> Result<()> {
for (name, server) in &self.servers {
server.validate(name)?;
}
Ok(())
}
}
fn atomic_write(path: &std::path::Path, content: &str) -> Result<()> {
let tmp = write_unique_temp(path, content)?;
#[cfg(test)]
if FAIL_AFTER_TEMP.with(std::cell::Cell::get) {
let _ = fs::remove_file(&tmp);
return Err(BzrError::config("injected post-temp failure (test)"));
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e.into());
}
fsync_parent_dir(path);
Ok(())
}
#[cfg(test)]
thread_local! {
static FAIL_AFTER_TEMP: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
pub(crate) fn set_fail_after_temp(on: bool) {
FAIL_AFTER_TEMP.with(|f| f.set(on));
}
thread_local! {
static LOCK_HELD: Cell<bool> = const { Cell::new(false) };
}
struct LockGuard {
file: fs::File,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = self.file.unlock();
LOCK_HELD.with(|held| held.set(false));
}
}
#[cfg(unix)]
fn open_lock_file(lock_path: &Path) -> Result<fs::File> {
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
Ok(OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(lock_path)?)
}
#[cfg(not(unix))]
fn open_lock_file(lock_path: &Path) -> Result<fs::File> {
use std::fs::OpenOptions;
Ok(OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)?)
}
fn acquire_exclusive_lock(file: &fs::File, lock_path: &Path) -> Result<()> {
let lock_err = |e: std::io::Error| {
BzrError::config(format!("could not lock {}: {e}", lock_path.display()))
};
match file.try_lock() {
Ok(()) => Ok(()),
Err(std::fs::TryLockError::WouldBlock) => {
let _ = writeln!(
std::io::stderr(),
"waiting for another bzr process to finish writing the config…"
);
file.lock().map_err(lock_err)
}
Err(std::fs::TryLockError::Error(e)) => Err(lock_err(e)),
}
}
const TEMP_CREATE_ATTEMPTS: u32 = 16;
fn temp_prefix(path: &std::path::Path) -> String {
let name = path.file_name().unwrap_or_default().to_string_lossy();
format!("{name}.")
}
fn candidate_temp_path(path: &std::path::Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let name = format!("{}{pid}.{n}.tmp", temp_prefix(path));
match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}
fn write_unique_temp(path: &std::path::Path, content: &str) -> Result<PathBuf> {
for _ in 0..TEMP_CREATE_ATTEMPTS {
let tmp = candidate_temp_path(path);
let mut file = match create_new_private(&tmp) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
};
if let Err(e) = file
.write_all(content.as_bytes())
.and_then(|()| file.sync_all())
{
let _ = fs::remove_file(&tmp);
return Err(e.into());
}
return Ok(tmp);
}
Err(BzrError::config(
"could not create a unique config temp file after repeated attempts",
))
}
#[cfg(unix)]
fn create_new_private(tmp: &std::path::Path) -> std::io::Result<fs::File> {
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(tmp)
}
#[cfg(not(unix))]
fn create_new_private(tmp: &std::path::Path) -> std::io::Result<fs::File> {
use std::fs::OpenOptions;
OpenOptions::new().create_new(true).write(true).open(tmp)
}
#[cfg(unix)]
fn fsync_parent_dir(path: &std::path::Path) {
if let Some(dir) = path.parent() {
if let Ok(handle) = fs::File::open(dir) {
let _ = handle.sync_all();
}
}
}
#[cfg(not(unix))]
fn fsync_parent_dir(_path: &std::path::Path) {}
const STALE_TEMP_AGE: std::time::Duration = std::time::Duration::from_secs(3600);
fn reap_stale_temps(path: &std::path::Path) {
let Some(dir) = path.parent() else {
return;
};
let prefix = temp_prefix(path);
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !(name.starts_with(prefix.as_str()) && name.ends_with(".tmp")) {
continue;
}
let is_old = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|mtime| mtime.elapsed().ok())
.is_some_and(|age| age >= STALE_TEMP_AGE);
if is_old {
let _ = fs::remove_file(entry.path());
}
}
}
#[cfg(unix)]
fn set_private_directory_permissions(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[cfg(not(unix))]
fn set_private_directory_permissions(_path: &std::path::Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn warn_if_path_permissions_too_open(path: &std::path::Path, mask: u32, kind: &str) {
use std::os::unix::fs::PermissionsExt;
let Ok(metadata) = fs::metadata(path) else {
return;
};
let mode = metadata.permissions().mode();
if mode & mask == 0 {
return;
}
warn_security(&format!(
"{kind} '{}' has overly broad permissions ({:o}); expected owner-only access. Fix with `chmod {}` '{}'",
path.display(),
mode & 0o777,
if kind == "config directory" { "700" } else { "600" },
path.display()
));
}
#[expect(clippy::print_stderr)]
fn warn_security(message: &str) {
eprintln!("warning: {message}");
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;