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: String

Asymmetry vs with_credentials_file

with_nkey is synchronous (no .await) and infallible (returns Self, not a Result). Contrast ConnectOptions::with_credentials_file(path).await which is async and returns io::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 / nkey are gated behind the crate’s nkeys cargo feature.
  • nkeys is in async-nats’ default feature set → available out of the box, unless you set default-features = false (then you must re-add nkeys explicitly).

Where the seed is actually validated

  • with_nkey does not validate the seed eagerly — it just stashes it.
  • The real work happens later, at connect time, inside the connector: nkeys::KeyPair::from_seed is called and used to sign the server nonce.
  • A bad/malformed seed therefore surfaces only when you call .connect(url).await — as a ConnectError of kind Authentication, not at with_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() returns None (dormant) the instant NATS_URL is 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 = true

You cannot mutate env vars in tests here

In edition 2024, std::env::set_var and std::env::remove_var became unsafe 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, because forbid(unsafe_code) cannot be overridden by #[allow(unsafe_code)]. (Lint precedence: forbid > deny; an inner allow is 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_URL is unset and connect().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 calling set_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.