Two reusable, gotcha-style facts discovered while wiring NKey auth into the wx-signal NATS publisher (crates/wx-signal/src/nats.rs): the async-nats 0.49 NKey API shape, and why std::env::set_var is unusable in this workspace’s tests.
For Agents
If you are adding NATS auth: use
ConnectOptions::with_nkey(seed)— sync, infallible (section 1). If you are writing a test that needs to flip an env var: you cannot (edition-2024 +forbid(unsafe_code)) — see section 2 below for the workaround.
1. async-nats 0.49 NKey authentication API
Crate: async-nats v0.49.1.
To authenticate with an NKey seed (the secret string starting with S), use the associated constructor:
use async_nats::ConnectOptions;
// SYNC + INFALLIBLE: returns Self, no .await, no Result
let options = ConnectOptions::with_nkey(seed); // seed: StringAsymmetry vs
with_credentials_file
with_nkeyis synchronous (no.await) and infallible (returnsSelf, not aResult). ContrastConnectOptions::with_credentials_file(path).awaitwhich isasyncand returnsio::Result<Self>. Don’t reflexively.await?the NKey path — it won’t compile.
Builder form (for mixing auth methods on an existing ConnectOptions) — same contract, sync, returns Self:
let options = ConnectOptions::new().nkey(seed);Feature gating
with_nkey/nkeyare gated behind the crate’snkeyscargo feature.nkeysis in async-nats’defaultfeature set → available out of the box, unless you setdefault-features = false(then you must re-addnkeysexplicitly).
Where the seed is actually validated
with_nkeydoes not validate the seed eagerly — it just stashes it.- The real work happens later, at connect time, inside the connector:
nkeys::KeyPair::from_seedis called and used to sign the server nonce. - A bad/malformed seed therefore surfaces only when you call
.connect(url).await— as aConnectErrorof kindAuthentication, not atwith_nkey().
How wx-signal uses it (SignalPublisher::connect())
Auth is chosen by precedence, and the function is dormant (returns None) unless a URL is configured:
pub async fn connect() -> Option<Self> {
let url = nonempty(std::env::var("NATS_URL").ok())?; // (1) read URL FIRST; bail if unset → dormant
// ...
let options = if let Some(seed) = nonempty(std::env::var("NATS_NKEY_SEED").ok()) {
ConnectOptions::with_nkey(seed) // (2) NKey wins
} else if let Some(path) = nonempty(std::env::var("NATS_CREDS").ok()) {
ConnectOptions::with_credentials_file(path).await? // (3) creds file (async/fallible)
} else {
ConnectOptions::new() // (4) no-auth fallback
};
options.connect(&url).await /* ... */
}Precedence: env NATS_NKEY_SEED (NKey) > NATS_CREDS (creds file) > no-auth.
Two load-bearing properties
- URL is read first.
connect()returnsNone(dormant) the instantNATS_URLis unset — before any auth or network work. This is what makes the dormancy test possible without touching env vars (see below).- No secrets are ever logged. Seed, creds path, and URL are never emitted to logs/traces. Keep it that way.
2. GOTCHA: std::env::set_var is unsafe in edition 2024
This workspace is edition = "2024" and the workspace lints forbid unsafe code:
# Cargo.toml (workspace root)
[workspace.lints.rust]
unsafe_code = "forbid" # crates opt in via [lints] workspace = trueYou cannot mutate env vars in tests here
In edition 2024,
std::env::set_varandstd::env::remove_varbecameunsafe fn(they are process-global and not thread-safe). So in this workspace they are a hard dead end in tests:
- Calling them bare → “call to unsafe function … requires unsafe block”.
- Wrapping in
unsafe { … }→ also a compile error, becauseforbid(unsafe_code)cannot be overridden by#[allow(unsafe_code)]. (Lint precedence:forbid>deny; an innerallowis ignored, not respected.)
Consequence: you cannot flip env vars to exercise env-driven code paths in tests in this workspace. Full stop. Plan tests around the code’s read order instead of mutating the environment.
Workaround used for the NKey dormancy test
Because connect() reads NATS_URL first and returns None before it ever looks at NATS_NKEY_SEED, the test asserts dormancy without setting any env var:
- Assert
NATS_URLis unset andconnect().await.is_none()→ dormancy holds regardless of the seed. - Additionally assert a dummy non-empty seed passes the
nonempty()gate — to document the NKey scenario inline — again without ever callingset_var.
Bonus clippy trap: used_underscore_binding
Clippy pedantic (enabled workspace-wide: pedantic = { level = "warn", priority = -1 }) fires used_underscore_binding if you name a binding with a leading underscore (e.g. let _dummy = …;) and then use it. If the value is actually used, drop the underscore (let dummy = …;). The underscore prefix is for intentionally-unused bindings only.
Related
- weather-bet — project overview (crate naming:
wx-*) - weather-bet-supabase-migration — same workspace’s
testkit-feature test harness + edition-2024/strict-typing conventions