anvil_ssh/log.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Structured tracing categories + log/tracing bridge installer
4//! ([FR-65](https://github.com/Spacecraft-Software/Gitway/blob/main/Gitway-PRD-v1.0.md),
5//! FR-69 of Gitway PRD §5.8.4).
6//!
7//! Anvil emits `tracing::*!` events with per-category `target` strings
8//! so a consumer (Gitway's CLI, downstream tooling, integration tests)
9//! can install one [`tracing_subscriber::EnvFilter`] like
10//! `anvil_ssh::kex=trace,anvil_ssh::auth=debug` and get exactly the
11//! depth they want in each category — without parsing message text.
12//!
13//! The categories are stable strings exported here so call sites at
14//! `anvil_ssh::session`, `anvil_ssh::auth`, etc. stay typo-free, and
15//! a downstream `--debug-categories=kex,auth` flag can validate user
16//! input against [`CATEGORIES`] cheaply.
17//!
18//! ## Bridging existing `log!()` calls
19//!
20//! Anvil 0.5.x and its dependencies (russh, ssh-key) emit via the
21//! `log` crate. M15.1 introduces the [`install_log_bridge`] entry
22//! point that funnels every `log::*!` call through the active
23//! `tracing` subscriber, so a consumer who installs a single
24//! tracing-subscriber sees both the new structured events AND the
25//! ~59 legacy `log::*!` call sites without rewriting them. The
26//! migration to native `tracing::*!` calls inside Anvil is post-1.0
27//! housekeeping; the bridge stays in place permanently for russh
28//! and other reverse-deps that stay on `log`.
29//!
30//! Anvil **never installs a subscriber itself** — the library cannot
31//! know whether the consumer wants human-formatted, JSONL,
32//! file-rotated, or OTLP output. See the Gitway CLI for the
33//! reference subscriber install (M15.4).
34//!
35//! ## Example
36//!
37//! ```no_run
38//! // Once, at process startup, BEFORE any `log::*!` or `tracing::*!`
39//! // call is made (so clap-emitted parse errors aren't lost):
40//! anvil_ssh::log::install_log_bridge().expect("bridge already installed?");
41//!
42//! // Then install your tracing subscriber...
43//! // tracing_subscriber::fmt().init();
44//! ```
45
46/// `target =` string for the SSH key-exchange category. Events at
47/// this target dump offered + accepted KEX algorithms, ciphers,
48/// MACs, host-key algorithms, and compression algorithms (FR-66).
49///
50/// Matches the [`tracing::Metadata::target`] convention of using
51/// the `crate_name::module` form so an `EnvFilter` directive like
52/// `anvil_ssh::kex=trace` reads naturally.
53pub const CAT_KEX: &str = "anvil_ssh::kex";
54
55/// `target =` string for the authentication category. Events at
56/// this target record every identity tried with `path`, `fp`,
57/// `alg`, and `verdict=accepted|rejected` structured fields
58/// (FR-66).
59pub const CAT_AUTH: &str = "anvil_ssh::auth";
60
61/// `target =` string for the channel + protocol-message category.
62/// Events at this target record every channel `open` / `close`
63/// with channel ID, plus every protocol message type and size
64/// (FR-66).
65pub const CAT_CHANNEL: &str = "anvil_ssh::channel";
66
67/// `target =` string for the `~/.ssh/config` resolver category.
68/// Events at this target record every directive applied with its
69/// source `file` and `line` number (FR-66).
70pub const CAT_CONFIG: &str = "anvil_ssh::config";
71
72/// `target =` string for the connection-retry / timeout category
73/// (M18, FR-83). Events at this target record each retry attempt
74/// with `attempt`, `reason`, `elapsed_ms`, and (on terminal failure)
75/// a `disposition` field of `fatal` / `exhausted`.
76pub const CAT_RETRY: &str = "anvil_ssh::retry";
77
78/// All Anvil-defined categories, in declaration order. Used by
79/// downstream CLIs (e.g. Gitway's `--debug-categories` flag) to
80/// validate user-supplied category names before building an
81/// `EnvFilter`. Does not include `russh` — that's a synthetic
82/// passthrough recognized by the consumer's filter, not an Anvil
83/// category.
84pub const CATEGORIES: &[&str] = &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG, CAT_RETRY];
85
86/// Installs the `log` → `tracing` bridge.
87///
88/// After this call, every `log::debug!` / `log::info!` / `log::warn!` /
89/// `log::error!` / `log::trace!` invocation — from Anvil itself,
90/// from `russh`, from `ssh-key`, from any reverse-dep that uses the
91/// `log` crate — is forwarded to the active `tracing` subscriber as
92/// a `tracing::Event` at the matching level.
93///
94/// # Idempotency
95///
96/// `tracing_log::LogTracer::init` returns
97/// [`tracing_log::log_tracer::SetLoggerError`] if a `log` logger is
98/// already installed (whether by a prior call here, by `env_logger`,
99/// or by anything else). This wrapper preserves that error so the
100/// caller can decide what to do — Gitway's CLI swallows it because
101/// double-init in tests is harmless; a stricter consumer can panic.
102///
103/// # Ordering
104///
105/// Call this **before** any code that may emit a `log::*!` event,
106/// including `clap`'s `Cli::parse()` (which emits `log::warn!` on
107/// unrecognized flags). Putting it as the first statement in
108/// `main()` is the safe default.
109///
110/// # Errors
111///
112/// Returns the underlying `SetLoggerError` if a `log` logger has
113/// already been installed in this process.
114pub fn install_log_bridge() -> Result<(), tracing_log::log_tracer::SetLoggerError> {
115 tracing_log::LogTracer::init()
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn categories_constants_have_anvil_prefix() {
124 for cat in CATEGORIES {
125 assert!(
126 cat.starts_with("anvil_ssh::"),
127 "category {cat} must use the `anvil_ssh::` target prefix",
128 );
129 }
130 }
131
132 #[test]
133 fn categories_slice_matches_individual_constants() {
134 assert_eq!(
135 CATEGORIES,
136 &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG, CAT_RETRY],
137 );
138 }
139
140 #[test]
141 fn category_constants_are_distinct() {
142 let mut seen: Vec<&&str> = CATEGORIES.iter().collect();
143 seen.sort();
144 seen.dedup();
145 assert_eq!(seen.len(), CATEGORIES.len(), "CATEGORIES has duplicates");
146 }
147
148 #[test]
149 fn install_log_bridge_is_idempotent_after_first_call() {
150 // First install in this test process may succeed or fail
151 // depending on test execution order — `cargo test` runs
152 // tests in parallel and another test (or `env_logger`) may
153 // have installed first. Either outcome is acceptable; what
154 // we require is that a SECOND call returns the same error
155 // shape (`SetLoggerError`), not panic, not silently change
156 // global state.
157 let _ = install_log_bridge();
158 let second = install_log_bridge();
159 // The second call MUST fail because a logger is now set.
160 assert!(
161 second.is_err(),
162 "second install_log_bridge call should fail with SetLoggerError",
163 );
164 }
165}