Files
alkssh/src/observe.rs
T
glm-5.3-flash 0d3fd48f5d feat(client): land the dial surface — the authenticated connection handle + the fail-closed client handler skeleton (w4-dial)
The client direction's substrate (client.md §The dial surface), the
mirror of w2-server-mount: dial(DialConfig) runs the fail-closed
four-step sequence — decrypt-at-dial key load (every KeySource
posture a distinct no-dial outcome), the bounded russh client Config
from Limits (keepalive 15 s / 3, the wall clock always Some, window/
packet/buffer verbatim), the injected host-key policy at
check_server_key (RejectAll connects to nothing; UnknownKey maps to
DialError::HostKeyRejected — its only producer), and exactly one
publickey-only auth attempt — and returns the authenticated
SshConnection handle everything else in the wave rides.

- DialConfig amendment (the sanctioned course-correction, recorded):
  the required `username` field — the USERAUTH_REQUEST's user string;
  no crate default, never identity, never logged (ADR-004 decision 5).
- The client Handler skeleton (src/client_handler.rs): every
  server_channel_open_* hook overridden explicit-over-inherited with
  a clean AdministrativelyProhibited reject (never russh's
  accept-and-drop default, ADR-003 §4); should_accept_unknown_
  server_channel the explicit false; decline events on the crate
  vocabulary (no-acceptor / never-feature reasons).
- The in-process fake sshd (tests/common/sshd_double.rs): the
  client-direction contract vehicle — a plain russh server on a real
  loopback TCP listener, accept-set identity resolution, the auth_
  succeeded handle carrier for the server-initiated-open rows, the
  per-session outcome channel for the clean-close rows.
- tests/client_dial.rs: 10 contract rows — dial success, the five
  key-source no-dial postures, RejectAll / pin-mismatch / case-2
  policy rows, refused auth, the malformed-address dial-time refusal,
  the bounded-config observable, the six-hook decline wire rows with
  decline events, and the smoke: alkssh-client ↔ alkssh-server over a
  real TCP loopback (real-sshd dial stays wave-5 e2e).

Verification: cargo test + per-feature + --all-features (14 binaries,
all green), cargo clippy ×2 (-D warnings), cargo fmt --check,
cargo doc --no-deps — all clean. taskgraph validate: 35 tasks valid.
2026-10-29 14:07:15 +00:00

557 lines
24 KiB
Rust

//! The decline-path + serving-path event vocabulary
//! (`w3-decline-tracing` — the wave-2 gate's booked finding): the
//! observability surface every decline site in `src/` emits through,
//! and the serving-path lifecycle skeleton ([`serving_path_started`] /
//! [`serving_path_ended`] / [`serving_path_failed`]) the wave-3
//! mappings inherit.
//!
//! ## The family posture read (2026-10-28)
//!
//! Read before choosing this shape — no alkssh-private dialect:
//!
//! - `alktty` (`adapter.rs`, `session.rs`, `local/pipe.rs`,
//! `local/pty.rs`): free `tracing::{debug, warn}` imports,
//! message-last lowercase sentences with a component prefix
//! (`"tty: backend stdin write failed: {e}"`), no spans, no macros.
//! - `alktunnels` (`producer.rs`, `consumer.rs`, `params.rs`): the
//! same free-fn posture, structured fields where identity exists
//! (`listen = …`, `error = %e`, `reason = …`), levels `debug` for
//! structural observations, `warn` for recoverable failures,
//! `error` for abnormal pump terminations.
//! - `alkcall` (`protocol/*`, `channels/*`): the widest use — the same
//! fields convention (`channel_id`, `request_id`, `error = %e`),
//! component-prefixed messages (`"channel client: …"`,
//! `"open wrapper: …"`), `debug`/`warn` only.
//! - `alkhttp` (the closest structural analog): `warn`/`error` with
//! `error = %e` (`"channels session ended"`).
//! - `alkgit`: no tracing at all at this writing.
//!
//! Never observed in the family: `info!` / `trace!` events, subscriber
//! setup in library code, macro or span wrappers — so none here
//! either. Adopted verbatim: free `tracing` fn calls, message-last
//! lowercase with the `"decline: …"` / `"serving path …"` phrasing,
//! flat per-event fields (`feature_build`, `row`, `channel_id`,
//! `decline_class`, `reason`, `exec_route`, `error`).
//!
//! ## The event vocabulary
//!
//! Every event carries exactly:
//!
//! - `feature_build = FEATURE_BUILD` — the crate's compile-time
//! feature set as a stable string (`"none"`, `"git"`,
//! `"session+git"`, …; features joined in the order `session, git,
//! tunnel, sftp`), so a captured event is self-describing per
//! build. The `sftp` feature gates no route, but ships in the
//! build string: it is part of what the assembly deployed.
//! - `row` — the greppable wire row the event is about: the channel
//! type for open-level (e.g. `"session"`, `"direct-tcpip"`, `"x11"`),
//! the request name for request-level (e.g. `"pty_request"`,
//! `"tcpip_forward"`), the handler hook name for auth (e.g.
//! `"auth_publickey"`), the session-row hook names for the
//! serving-path lifecycle.
//! - `channel_id` — the wire channel id (u32), on every event of a
//! channel; the auth class has none (the auth hooks carry no
//! channel at this surface).
//!
//! Decline events additionally carry exactly:
//!
//! - `decline_class` — `"open"` | `"request"` | `"serving"` |
//! `"arrival"` | `"auth"`:
//! the two wire decline classes of `mapping.md` §Wire decline
//! reasons, plus the serving-path class (this wave the seam stub's
//! decline *is* the serving path; the wave-3 mappings emit the same
//! class from their real spawned paths) and the auth class (the
//! disposition declines of ADR-004).
//! - `reason` — the decline cause, mirroring the architecture's own
//! classifications: `"unfeatured"` (feature-gate absence),
//! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`,
//! `"connect-failed"` (`w3-tunnel-direct`: the tunnel rows'
//! cannot-bridge class — the failed fabric open, the wave-2 seam
//! stub's reason surviving as the real decline; also the
//! `forwarded-tcpip` stub's standing decline class),
//! `"seam-unconfigured"` (the config-driven row decline — the git
//! row and, now, the session rows (feature on, seam unset;
//! `w3-git-mapping` / `w3-tty-session`) and the tunnel rows' absent
//! fabric handle even under the feature (`w3-tunnel-direct`; the
//! unset-declines posture)),
//! `"shell-unconfigured"` / `"wrapper-unconfigured"` (`w3-tty-
//! session`: the OQ-08 unset-field rows — feature on, the
//! serve-config field unset, never a fallback),
//! `"negotiation-failed"` (`w3-tty-session`: the alktty
//! negotiation error frame the bridge received — the session never
//! started; the frame's JSON detail stays alktty-side),
//! `"unknown-service"` / `"service-only"` / `"invalid-repo-id"`
//! (the exec grammar's git declinables — the parse core's two and
//! the git row's validation gate; no service token or repo string
//! ever rides — only the vocabulary value),
//! `"exec-oversize"` (the pre-parse `max_exec_command_len` cap),
//! `"invalid-target"` (`w3-tunnel-direct`: the direct rows'
//! impossible wire target — an out-of-range `direct-tcpip` port),
//! `"unix-unavailable"` (`w3-tunnel-direct`: the OQ-11 runtime-Unix
//! gate — the off-Unix `direct-streamlocal` decline, answered
//! before any fabric call),
//! `"no-identity"` / `"channel-not-held"` (the git row's serving-
//! path guards — unreachable in practice, fail-closed; the tunnel
//! rows' open-level `no-identity` guard rides the same value —
//! reachable never, on the wire, for the same reason),
//! `"unimplemented"` (the subsystem rows — OQ-13),
//! `"declined-by-design"` (env, OQ-09),
//! `"correlation-miss"` / `"cap-exceeded"` / `"accept-refused"` /
//! `"unvanilla-inbound"` (`w3-tunnel-listen`: the `-R` leg's arrival
//! declines — the class whose events carry the minted `listen` token
//! instead of a channel id — and the unvanilla inbound
//! `forwarded-tcpip` open),
//! `"listen-failed"` / `"no-such-listen"` (`w3-tunnel-listen`: the
//! listen dial's failed/bounded outcome and the cancel forms'
//! not-found pair) — the global-request and arrival rows, and on the
//! auth class `"method-unimplemented"` / `"cert-unsupported"` /
//! `"non-ed25519"` / `"fingerprint-unresolved"`.
//! Client-side (the dial handler's `server_channel_open_*`
//! skeleton, `w4-dial`): `"no-acceptor"` (the decline-all default —
//! a server-initiated open with no registered acceptor; the
//! registry is `w4-reverse-listen`'s) and `"never-feature"` (the
//! x11/agent rows — ADR-003 §5).
//! - `exec_route` — only on `exec_request`-row events: the dispatch
//! route the parse routed to (`"git"` | `"pipe"`), so a git row's
//! decline is distinguishable from a pipe row's in combined builds.
//! Other rows carry none (a `None` field is not recorded by
//! `tracing`).
//!
//! Levels: declines and the lifecycle's started/ended emit at
//! `debug` — a decline is the door's normal, honest posture (vanilla
//! clients probe unfeatured rows routinely; nothing abnormal
//! happened), matching the family's structural-observation level.
//! Only [`serving_path_failed`] is `warn` — a serving path that could
//! not complete its wire reporting is a real failure (the family's
//! recoverable-failure level).
//!
//! ## Safety posture (conventions 6, OQ-03)
//!
//! **No secret material ever enters an event; never the username.**
//! Structurally enforced, not recalled: the helpers take only
//! `&'static str` vocabulary values and the wire channel id — there
//! is no parameter a username, command string, env name/value, repo
//! path, key, or fingerprint could ride. In particular *the
//! fingerprint stays out of the events* (`auth.md` §Dispositions: log
//! exposure is deployment policy — the crate's events are
//! safe-by-default at every level, so the fingerprint is a
//! deployer-side computation over its own logs, never crate-emitted).
//! The `error` field carries only russh protocol errors. The wire
//! rows in `tests/observe.rs` pin the posture: the username never
//! appears in any captured field. If a future decline site needs
//! request-argument detail (e.g. the git grammar's decline reasons in
//! `w3-git-mapping`), it must add an explicitly-vocabulary'd field and
//! record its safety reasoning — dynamic strings do not ride this
//! module by default.
/// The compile-time feature set, as a stable string (`"none"`,
/// `"session"`, `"session+git"`, …). Features join in the order
/// `session, git, tunnel, sftp`; the featureless build is `"none"`.
pub(crate) const FEATURE_BUILD: &str = feature_build();
const fn feature_build() -> &'static str {
match (
cfg!(feature = "session"),
cfg!(feature = "git"),
cfg!(feature = "tunnel"),
cfg!(feature = "sftp"),
) {
(false, false, false, false) => "none",
(true, false, false, false) => "session",
(false, true, false, false) => "git",
(false, false, true, false) => "tunnel",
(false, false, false, true) => "sftp",
(true, true, false, false) => "session+git",
(true, false, true, false) => "session+tunnel",
(true, false, false, true) => "session+sftp",
(false, true, true, false) => "git+tunnel",
(false, true, false, true) => "git+sftp",
(false, false, true, true) => "tunnel+sftp",
(true, true, true, false) => "session+git+tunnel",
(true, true, false, true) => "session+git+sftp",
(true, false, true, true) => "session+tunnel+sftp",
(false, true, true, true) => "git+tunnel+sftp",
(true, true, true, true) => "session+git+tunnel+sftp",
}
}
/// An open-level decline: the channel-open gate refused an open
/// (`unfeatured` / `never-feature` / `cap-exceeded` / the tunnel seam
/// stub's `connect-failed`). The wire answer is the open-failure code
/// ([`crate::dispatch`]); this is the event for the decision behind it.
pub(crate) fn decline_open(row: &'static str, reason: &'static str, channel_id: u32) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
channel_id,
decline_class = "open",
reason,
"decline: channel open rejected"
);
}
/// A request-level decline on a session channel: a capability the
/// build does not serve (`subsystem` / `env` / `x11` / agent, an
/// unfeatured parse route) or a not-yet-landed listen form. The wire
/// answer is `channel_failure` (the channel stays usable) or the
/// denied-request reply — the event is the deciding context.
pub(crate) fn decline_request(
row: &'static str,
reason: &'static str,
channel_id: u32,
exec_route: Option<&'static str>,
) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
channel_id,
decline_class = "request",
reason,
exec_route,
"decline: channel request rejected"
);
}
/// An auth-class decline: a disposition of the ADR-004 bridge turned
/// the method away. No channel id exists at this surface; the
/// *fingerprint never rides the event* (see §Safety posture) — the
/// `reason` codes distinguish the decline causes without exposing who.
pub(crate) fn decline_auth(row: &'static str, reason: &'static str) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
decline_class = "auth",
reason,
"decline: auth disposition rejected"
);
}
/// A request-level decline on a **global** request — the `-R` listen
/// forms (`mapping.md` §Global-request rows). No channel id exists at
/// this surface; the fields are otherwise the `request` vocabulary.
pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
decline_class = "request",
reason,
"decline: global request rejected"
);
}
/// A decline on the `-R` leg's **arrival** surface (`w3-tunnel-
/// listen`): a fabric inbound forwarded open that cannot become an
/// SSH channel — `correlation-miss` (no route for the minted token —
/// teardown-at-birth), `cap-exceeded` (the per-arrival admission), or
/// `accept-refused` (the vanilla client rejected the SSH open — the
/// count released, never a half-open channel). No SSH channel id
/// exists yet at this surface (that is the point of the declines), so
/// the minted `listen` token — the correlation key itself — rides
/// instead; the token is bookkeeping metadata (the producer's minted
/// correlation number), never secret material.
#[cfg(feature = "tunnel")]
pub(crate) fn decline_arrival(row: &'static str, reason: &'static str, listen: u32) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
listen,
decline_class = "arrival",
reason,
"decline: forwarded arrival rejected"
);
}
/// A serving-path decline: a routed row whose serving task answers
/// failure-then-close rather than serving. This wave that is the seam
/// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3
/// mappings emit it from their real spawned paths wherever serving
/// itself cannot proceed — the class the spawned paths inherit.
#[cfg(any(feature = "git", feature = "session"))]
pub(crate) fn decline_serving(
row: &'static str,
reason: &'static str,
channel_id: u32,
exec_route: Option<&'static str>,
) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
channel_id,
decline_class = "serving",
reason,
exec_route,
"decline: serving path rejected"
);
}
/// Spawned-path started — emitted as the spawned task's first action,
/// so "started" is the task's own honest start, not the hook's spawn
/// call. The mapping tasks' serving paths inherit this shape.
#[cfg(any(feature = "git", feature = "session", feature = "tunnel"))]
pub(crate) fn serving_path_started(
row: &'static str,
channel_id: u32,
exec_route: Option<&'static str>,
) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
channel_id,
exec_route,
"serving path started"
);
}
/// Spawned-path ended — the serving future finished (cleanly, or with
/// the decline it was wired to answer); emitted *after* the path's
/// wire reporting has completed.
#[cfg(any(feature = "git", feature = "session", feature = "tunnel"))]
pub(crate) fn serving_path_ended(
row: &'static str,
channel_id: u32,
exec_route: Option<&'static str>,
) {
tracing::debug!(
feature_build = FEATURE_BUILD,
row,
channel_id,
exec_route,
"serving path ended"
);
}
/// Spawned-path failure — the serving path could not complete its
/// wire reporting (the session handle's sends failed: the session is
/// gone, or the report never landed). The vocabulary's one `warn` —
/// the family's recoverable-failure level. Only russh protocol errors
/// ride the `error` field (§Safety posture); the mapping tasks emit
/// this from their real paths when serving fails before reporting.
/// (The tunnel bridge never calls this: its wire reporting is
/// open-level — `accept`/`reject` cannot fail send-wise — so the fn
/// stays gated to the git/session rows.)
#[cfg(any(feature = "git", feature = "session"))]
pub(crate) fn serving_path_failed<E: std::fmt::Debug>(
row: &'static str,
channel_id: u32,
error: &E,
exec_route: Option<&'static str>,
) {
tracing::warn!(
feature_build = FEATURE_BUILD,
row,
channel_id,
exec_route,
error = ?error,
"serving path failed"
);
}
#[cfg(test)]
mod tests {
use tracing::subscriber::with_default;
/// A fake error shape — the failure row only pins that a Debug
/// error value rides the `error` field.
#[derive(Debug)]
#[cfg(any(feature = "git", feature = "session"))]
struct Probe;
#[cfg(any(feature = "git", feature = "session"))]
static PROBE: Probe = Probe;
use super::*;
/// A minimal field-capturing visitor shared by these rows and the
/// wire rows in `tests/observe.rs` (the test convention recorded
/// in the task notes — the family has no event-capture precedent,
/// only alktunnels' fmt-subscriber dev-dep for debug output, so
/// the convention is a hand-rolled minimal [`tracing::Subscriber`]
/// — no `tracing-subscriber` dependency).
struct Visitor(Vec<(String, String)>);
impl tracing::field::Visit for Visitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.push((field.name().to_owned(), value.to_owned()));
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.push((field.name().to_owned(), format!("{value:?}")));
}
}
/// Captures the fields of the one event the emit fn fires, via
/// the thread-local default subscriber (`with_default` — these
/// are direct helper rows; the spawned-task surfaces are driven
/// over the wire in `tests/observe.rs` against the global
/// capture).
fn capture(emit: impl FnOnce()) -> Vec<(String, String)> {
struct Capture(std::sync::Mutex<Vec<(String, String)>>);
impl tracing::Subscriber for Capture {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id {
tracing::Id::from_u64(1)
}
fn record(&self, _span: &tracing::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
let mut v = Visitor(Vec::new());
event.record(&mut v);
self.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.append(&mut v.0);
}
fn enter(&self, _span: &tracing::Id) {}
fn exit(&self, _span: &tracing::Id) {}
fn clone_span(&self, _id: &tracing::Id) -> tracing::Id {
tracing::Id::from_u64(1)
}
}
let holder = std::sync::Arc::new(Capture(Default::default()));
with_default(std::sync::Arc::clone(&holder), || {
let prior = holder.0.lock().unwrap().len();
emit();
assert!(holder.0.lock().unwrap().len() > prior);
});
holder.0.lock().unwrap().clone()
}
fn field<'a>(fields: &'a [(String, String)], name: &str) -> &'a str {
fields
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
.unwrap_or_else(|| panic!("no field {name} in {fields:?}"))
}
/// - *Contract stamp:* the event vocabulary (`w3-decline-tracing`)
/// — an open decline carries exactly the base shape, values
/// included.
#[test]
fn decline_open_carries_the_full_vocabulary() {
let fields = capture(|| decline_open("x11", "never-feature", 7));
assert_eq!(field(&fields, "feature_build"), FEATURE_BUILD);
assert_eq!(field(&fields, "row"), "x11");
assert_eq!(field(&fields, "channel_id"), "7");
assert_eq!(field(&fields, "decline_class"), "open");
assert_eq!(field(&fields, "reason"), "never-feature");
assert_eq!(field(&fields, "message"), "decline: channel open rejected");
let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
assert_eq!(
names,
vec![
"message".to_owned(),
"feature_build".to_owned(),
"row".to_owned(),
"channel_id".to_owned(),
"decline_class".to_owned(),
"reason".to_owned(),
],
"an open decline carries exactly the vocabulary, nothing else \
(the message field first — the event! macro's shape)"
);
}
/// - *Contract stamp:* the `exec_route` field — present on the
/// exec row, absent elsewhere (a `None` field is not recorded
/// by `tracing`).
#[test]
fn decline_request_carries_the_route_when_the_parse_routed() {
let fields = capture(|| decline_request("exec_request", "unfeatured", 3, Some("git")));
assert_eq!(field(&fields, "decline_class"), "request");
assert_eq!(field(&fields, "exec_route"), "git");
let fields = capture(|| decline_request("subsystem_request", "unimplemented", 3, None));
assert_eq!(field(&fields, "decline_class"), "request");
assert!(
!fields.iter().any(|(n, _)| n == "exec_route"),
"a None route field is not recorded at all"
);
}
#[test]
fn decline_auth_carries_no_channel() {
let fields = capture(|| decline_auth("auth_password", "method-unimplemented"));
let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
assert!(!names.iter().any(|n| n.starts_with("channel")));
assert_eq!(field(&fields, "decline_class"), "auth");
assert_eq!(field(&fields, "row"), "auth_password");
assert_eq!(field(&fields, "reason"), "method-unimplemented");
}
/// - *Contract stamp:* the `exec_route` field — present on the
/// exec row, absent elsewhere (a `None` field is not recorded
/// by `tracing`). The pty/shell lifecycle rows carry no route.
#[cfg(any(feature = "git", feature = "session"))]
#[test]
fn serving_path_lifecycle_carries_started_ended_and_the_warn_failure() {
let fields = capture(|| serving_path_started("pty_request", 1, None));
assert_eq!(field(&fields, "message"), "serving path started");
let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
assert!(
!names.iter().any(|n| n == "decline_class"),
"lifecycle events carry no decline class"
);
let fields = capture(|| serving_path_ended("exec_request", 1, Some("pipe")));
assert_eq!(field(&fields, "exec_route"), "pipe");
assert_eq!(field(&fields, "message"), "serving path ended");
let fields = capture(|| serving_path_failed("shell_request", 1, &PROBE, None));
assert_eq!(field(&fields, "message"), "serving path failed");
assert_eq!(field(&fields, "error"), "Probe");
}
/// - *Contract stamp:* the arrival vocabulary (`w3-tunnel-listen`)
/// — an arrival decline carries the minted `listen` token
/// instead of a channel id (none exists yet — that is the point
/// of the declines; the token is bookkeeping metadata, never
/// secret material).
#[cfg(feature = "tunnel")]
#[test]
fn decline_arrival_carries_the_listen_token_not_a_channel_id() {
let fields = capture(|| decline_arrival("forwarded-tcpip", "correlation-miss", 9182));
assert_eq!(field(&fields, "feature_build"), FEATURE_BUILD);
assert_eq!(field(&fields, "row"), "forwarded-tcpip");
assert_eq!(field(&fields, "listen"), "9182");
assert_eq!(field(&fields, "decline_class"), "arrival");
assert_eq!(field(&fields, "reason"), "correlation-miss");
assert_eq!(
field(&fields, "message"),
"decline: forwarded arrival rejected"
);
let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
assert!(
!names.iter().any(|n| n == "channel_id"),
"an arrival decline carries the listen token, never a channel id"
);
}
/// The feature-build string: every enabled feature appears; a
/// featureless build reads `none`. The expected half is derived
/// from `cfg!` independently of the crate's match — divergence
/// breaks this row loudly.
#[test]
fn feature_build_string_reflects_the_cfgs() {
let mut expected = String::new();
for (on, name) in [
(cfg!(feature = "session"), "session"),
(cfg!(feature = "git"), "git"),
(cfg!(feature = "tunnel"), "tunnel"),
(cfg!(feature = "sftp"), "sftp"),
] {
if on {
if !expected.is_empty() {
expected.push('+');
}
expected.push_str(name);
}
}
if expected.is_empty() {
expected.push_str("none");
}
assert_eq!(FEATURE_BUILD, expected);
}
}