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/Steelbore/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/// All Anvil-defined categories, in declaration order. Used by
73/// downstream CLIs (e.g. Gitway's `--debug-categories` flag) to
74/// validate user-supplied category names before building an
75/// `EnvFilter`. Does not include `russh` — that's a synthetic
76/// passthrough recognized by the consumer's filter, not an Anvil
77/// category.
78pub const CATEGORIES: &[&str] = &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG];
79
80/// Installs the `log` → `tracing` bridge.
81///
82/// After this call, every `log::debug!` / `log::info!` / `log::warn!` /
83/// `log::error!` / `log::trace!` invocation — from Anvil itself,
84/// from `russh`, from `ssh-key`, from any reverse-dep that uses the
85/// `log` crate — is forwarded to the active `tracing` subscriber as
86/// a `tracing::Event` at the matching level.
87///
88/// # Idempotency
89///
90/// `tracing_log::LogTracer::init` returns
91/// [`tracing_log::log_tracer::SetLoggerError`] if a `log` logger is
92/// already installed (whether by a prior call here, by `env_logger`,
93/// or by anything else). This wrapper preserves that error so the
94/// caller can decide what to do — Gitway's CLI swallows it because
95/// double-init in tests is harmless; a stricter consumer can panic.
96///
97/// # Ordering
98///
99/// Call this **before** any code that may emit a `log::*!` event,
100/// including `clap`'s `Cli::parse()` (which emits `log::warn!` on
101/// unrecognized flags). Putting it as the first statement in
102/// `main()` is the safe default.
103///
104/// # Errors
105///
106/// Returns the underlying `SetLoggerError` if a `log` logger has
107/// already been installed in this process.
108pub fn install_log_bridge() -> Result<(), tracing_log::log_tracer::SetLoggerError> {
109 tracing_log::LogTracer::init()
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn categories_constants_have_anvil_prefix() {
118 for cat in CATEGORIES {
119 assert!(
120 cat.starts_with("anvil_ssh::"),
121 "category {cat} must use the `anvil_ssh::` target prefix",
122 );
123 }
124 }
125
126 #[test]
127 fn categories_slice_matches_individual_constants() {
128 assert_eq!(CATEGORIES, &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG]);
129 }
130
131 #[test]
132 fn category_constants_are_distinct() {
133 let mut seen: Vec<&&str> = CATEGORIES.iter().collect();
134 seen.sort();
135 seen.dedup();
136 assert_eq!(seen.len(), CATEGORIES.len(), "CATEGORIES has duplicates");
137 }
138
139 #[test]
140 fn install_log_bridge_is_idempotent_after_first_call() {
141 // First install in this test process may succeed or fail
142 // depending on test execution order — `cargo test` runs
143 // tests in parallel and another test (or `env_logger`) may
144 // have installed first. Either outcome is acceptable; what
145 // we require is that a SECOND call returns the same error
146 // shape (`SetLoggerError`), not panic, not silently change
147 // global state.
148 let _ = install_log_bridge();
149 let second = install_log_bridge();
150 // The second call MUST fail because a logger is now set.
151 assert!(
152 second.is_err(),
153 "second install_log_bridge call should fail with SetLoggerError",
154 );
155 }
156}