bzr 0.4.3

A CLI for Bugzilla, inspired by gh
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
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>,
    /// Accept invalid TLS certificates (self-signed, expired, etc.).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub tls_insecure: bool,
    /// Path to a PEM-encoded CA certificate for this server.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls_ca_cert: Option<PathBuf>,
    /// SHA-256 fingerprint of the pinned server certificate.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls_pin_sha256: Option<String>,
    /// Issuer DN stored alongside the pin for rotation detection.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls_pin_issuer: Option<String>,
    /// Base64-encoded raw DER bytes of the issuer SEQUENCE for
    /// tamper-proof issuer comparison.
    #[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 {
    /// Keyring service name. Defaults to "bzr" when omitted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub service: Option<String>,
    /// Account/username within the service. Defaults to the server name.
    #[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")
                    })?;
                    // Empty string means "default to the server_name"; the
                    // real account is resolved in resolve_api_key() which
                    // has the server name in scope. We cannot use
                    // KeyringRef::account_or_default here because that would
                    // require plumbing the server name through every caller.
                    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 } => {
                // Empty `account` means "default to server_name" (see the
                // sentinel explanation in credential_source()).
                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"))
    }

    /// Resolve the config directory (`<config>/bzr`), creating it `0700` on
    /// first use, and return it. Shared by `write_to_disk` and `update_locked`.
    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)
    }

    /// Read and parse the config from disk WITHOUT validating it or warning on
    /// permissions. Maps a missing file to `Config::default()`. Used by
    /// `update_locked` (which validates the post-mutation state) and by `load`.
    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> {
        // Warn on insecure permissions only on an explicit load (preserves today's
        // behavior); `update_locked`'s internal reload uses `read_unvalidated`, so
        // it warns once (from `write_to_disk`) rather than twice.
        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()
    }

    /// Apply `mutator` to the config under an exclusive advisory lock, with a
    /// reload from disk *inside* the lock so concurrent processes editing
    /// disjoint fields do not clobber each other.
    ///
    /// The lock (`config.lock`, sibling of `config.toml`) is held only across
    /// the in-memory mutation and the atomic write — never across interactive
    /// I/O. The closure must therefore be self-contained and non-interactive:
    /// run any prompt, keyring, or network step *before* calling this. Because
    /// the config is reloaded from disk first, the closure must not rely on
    /// unpersisted in-memory state, and should upsert (create-if-absent) any
    /// server it means to create.
    ///
    /// Returns the freshly-applied config so callers can use the post-write
    /// state without a second read.
    ///
    /// Non-reentrant: a `mutator` that itself calls `update_locked` returns an
    /// error rather than self-deadlocking.
    pub fn update_locked(mutator: impl FnOnce(&mut Config) -> Result<()>) -> Result<Config> {
        Self::update_locked_inner(true, mutator)
    }

    /// Like [`Self::update_locked`] but skips whole-config validation, for the
    /// one caller (`unset-keyring`) that intentionally leaves a server without a
    /// credential source.
    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 };

        // Reload WITHOUT validation. `Config::load` validates unconditionally and
        // rejects a credential-less server (the state `unset-keyring` deliberately
        // leaves on disk). Validating the *reload* would make `update_locked`
        // itself fail just from reading such a config — in particular it would
        // break `update_locked_without_validation` (which must operate on, and
        // leave, a credential-less server). We validate the *post-mutation* state
        // instead (when `validate` is true), matching `save()`'s "validate the
        // whole config before writing" semantics. (Note: the whole-config
        // validation still rejects a write while *any* server is credential-less;
        // that pre-existing rule — also enforced by `Config::load` in every
        // command — is unchanged here and is a separate concern from locking.)
        let mut config = Self::read_unvalidated()?;
        mutator(&mut config)?;
        if validate {
            config.validate()?;
        }
        config.write_to_disk()?;
        Ok(config)
    }

    /// Persist the config **without** running the credential-source validator.
    ///
    /// Used only in tests: seeds a credential-less server to exercise
    /// `update_locked_without_validation`. Applies the same `0o600`/`0o700`
    /// hardening as `save` so a recreated config file is never world-readable.
    #[cfg(test)]
    fn save_without_validation(&self) -> Result<()> {
        self.write_to_disk()
    }

    /// Serialize and write the config to its on-disk path atomically:
    /// a uniquely-named sibling temp is written `0o600`, fsync'd (unix),
    /// renamed over the target (atomic replace), and the directory is
    /// fsync'd (unix) so the rename survives a crash. A concurrent reader
    /// therefore always sees either the complete old or complete new file.
    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(())
    }
}

/// Atomically write `content` to `path`: write a uniquely-named sibling
/// temp file, durably flush it (unix), then rename it over `path`.
/// `rename` replaces the destination atomically on POSIX and on Windows
/// (`MoveFileExW`). The directory is fsync'd on unix so the rename is
/// durable across a crash; on non-unix the concurrent-reader atomicity
/// holds but crash-durability is best-effort.
fn atomic_write(path: &std::path::Path, content: &str) -> Result<()> {
    // Create + write the temp. `create_new` is collision-tolerant: a stale
    // same-pid crash-orphan younger than the reaper's age gate could share
    // the first candidate name, so retry with fresh names before failing.
    let tmp = write_unique_temp(path, content)?;
    // Test-only fault seam: simulate a crash/failure *after* the temp is
    // written but *before* the rename, to deterministically verify that a
    // failed write leaves the previous config intact (CONC-1 atomicity).
    #[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! {
    /// When set, [`atomic_write`] fails after writing the temp but before the
    /// rename. Lets a test prove a failed write does not destroy the old file.
    static FAIL_AFTER_TEMP: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// Arm/disarm the [`atomic_write`] post-temp fault seam (test-only).
#[cfg(test)]
pub(crate) fn set_fail_after_temp(on: bool) {
    FAIL_AFTER_TEMP.with(|f| f.set(on));
}

thread_local! {
    /// True while this thread holds the config lock inside `update_locked`.
    /// `File::lock` (flock) treats two descriptors in one process as
    /// independent, so a nested `update_locked` would self-deadlock; we
    /// reject re-entry instead.
    static LOCK_HELD: Cell<bool> = const { Cell::new(false) };
}

/// Releases the advisory lock and clears the re-entrancy flag on drop, so an
/// early `?` return or a panic inside the critical section cannot leave the
/// lock held or the flag stuck.
struct LockGuard {
    file: fs::File,
}

impl Drop for LockGuard {
    fn drop(&mut self) {
        let _ = self.file.unlock();
        LOCK_HELD.with(|held| held.set(false));
    }
}

/// Open (creating if absent) the `config.lock` file `0600`, ready for an
/// advisory lock. The lock file's *contents* are irrelevant — only the
/// kernel lock on the open description matters — so it is never written to.
#[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)?)
}

/// Take the exclusive advisory lock, giving the user feedback if another
/// `bzr` process already holds it. A bare blocking `lock()` would hang the
/// CLI with no output under contention; instead we `try_lock` first and only
/// fall back to blocking after printing a one-line notice to stderr, so the
/// wait is visible rather than a silent freeze.
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)),
    }
}

/// Max attempts to find an unused temp name. A collision only happens
/// against a stale same-pid orphan younger than the reaper's age gate, so
/// a few retries with fresh counter values is always enough.
const TEMP_CREATE_ATTEMPTS: u32 = 16;

/// The shared filename prefix for this config's sibling temp files:
/// `<config-file-name>.` (e.g. `config.toml.`). Both the temp **creator**
/// ([`candidate_temp_path`]) and the temp **reaper** ([`reap_stale_temps`])
/// derive their match from this one helper, so the two sides cannot drift:
/// every name the creator can produce is one the reaper will recognize.
fn temp_prefix(path: &std::path::Path) -> String {
    let name = path.file_name().unwrap_or_default().to_string_lossy();
    format!("{name}.")
}

/// A candidate sibling temp path: `config.toml.<pid>.<counter>.tmp`. The
/// counter is process-global and monotonic, so each call yields a fresh
/// name; combined with the pid this is unique across concurrent writers.
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),
    }
}

/// Create a fresh `0600` sibling temp (collision-tolerant) and write
/// `content` to it durably. Returns the temp path on success. On an
/// `AlreadyExists` collision with a stale orphan, retries with a fresh
/// name; on a write/flush failure, removes the temp it created and
/// propagates the error.
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) {}

/// How old a temp sibling must be before the reaper treats it as a
/// crash orphan. Comfortably longer than any single atomic write, so a
/// *live* temp belonging to a concurrent `bzr` process is never reaped.
const STALE_TEMP_AGE: std::time::Duration = std::time::Duration::from_secs(3600);

/// Remove crash-orphaned `config.toml.*.tmp` siblings **older than
/// [`STALE_TEMP_AGE`]**. A crash between temp-create and rename leaves a
/// unique-named orphan that no graceful cleanup reaps; sweep old ones so
/// they do not accumulate. The age gate is essential: CONC-1 ships before
/// the CONC-2 lock, so two concurrent processes can each have a fresh
/// in-flight temp — reaping unconditionally would delete the other's live
/// temp and make its `rename` fail (lost write). Only temps untouched for
/// an hour — which no live write produces — are removed. The match prefix
/// comes from [`temp_prefix`], the same source [`candidate_temp_path`] uses.
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;