Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ env:
CARGO_BUILD_WARNINGS: deny

jobs:
rust-skills2-strict:
name: rust-skills2 strict nightly gate
uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@main

lockfile:
name: Lockfile consistency
runs-on: ubuntu-24.04
Expand Down
151 changes: 73 additions & 78 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ pub enum ConfigError {
Invalid(String),
}

fn i64_default<T>(value: T, name: &str) -> Result<i64, ConfigError>
where
i64: TryFrom<T>,
{
i64::try_from(value)
.map_err(|_| ConfigError::Invalid(format!("{name} default does not fit in i64")))
}

impl Config {
/// Load defaults, an optional TOML file, then `RWF_*` environment
/// overrides (`__` separates nested keys), and validate cross-field
Expand All @@ -167,19 +175,27 @@ impl Config {
/// # Errors
/// Returns [`ConfigError`] for missing explicit files, parse/deserialization
/// failures, or invalid values/invariants.
#[expect(
clippy::cast_possible_wrap,
clippy::cast_lossless,
reason = "documented defaults are small fixed values"
)]
pub fn load() -> Result<Self, ConfigError> {
let config_path = std::env::var("RWF_CONFIG").ok();
if let Some(path) = config_path.as_deref()
Self::load_from(config_path.as_deref(), None)
}

fn load_from(
config_path: Option<&str>,
environment_source: Option<config::Map<String, String>>,
) -> Result<Self, ConfigError> {
if let Some(path) = config_path
&& !Path::new(path).exists()
{
return Err(ConfigError::ConfigPathNotFound(path.to_string()));
}

let environment = config::Environment::with_prefix("RWF")
.prefix_separator("_")
.separator("__")
.try_parsing(true)
.source(environment_source);

let builder = config::Config::builder()
.set_default("gateway.port", 3001_i64)?
.set_default("gateway.proxy_upstream_url", "https://ipapi.co")?
Expand All @@ -190,15 +206,24 @@ impl Config {
.set_default("gateway.session.cookie_secure", true)?
.set_default(
"gateway.sse_broadcast_buffer",
default_gateway_sse_broadcast_buffer() as i64,
i64_default(
default_gateway_sse_broadcast_buffer(),
"gateway.sse_broadcast_buffer",
)?,
)?
.set_default(
"gateway.refresh_token_ttl_secs",
default_gateway_refresh_token_ttl_secs() as i64,
i64_default(
default_gateway_refresh_token_ttl_secs(),
"gateway.refresh_token_ttl_secs",
)?,
)?
.set_default(
"gateway.access_token_ttl_secs",
default_gateway_access_token_ttl_secs() as i64,
i64_default(
default_gateway_access_token_ttl_secs(),
"gateway.access_token_ttl_secs",
)?,
)?
.set_default("live_search.port", 3000_i64)?
.set_default(
Expand All @@ -207,41 +232,48 @@ impl Config {
)?
.set_default(
"live_search.pool_max_connections",
default_pool_max_connections() as i64,
i64::from(default_pool_max_connections()),
)?
.set_default(
"live_search.pool_min_connections",
default_pool_min_connections() as i64,
i64::from(default_pool_min_connections()),
)?
.set_default(
"live_search.pool_acquire_timeout_secs",
default_pool_acquire_timeout_secs() as i64,
i64_default(
default_pool_acquire_timeout_secs(),
"live_search.pool_acquire_timeout_secs",
)?,
)?
.set_default(
"live_search.pool_idle_timeout_secs",
default_pool_idle_timeout_secs() as i64,
i64_default(
default_pool_idle_timeout_secs(),
"live_search.pool_idle_timeout_secs",
)?,
)?
.set_default(
"live_search.pool_max_lifetime_secs",
default_pool_max_lifetime_secs() as i64,
i64_default(
default_pool_max_lifetime_secs(),
"live_search.pool_max_lifetime_secs",
)?,
)?
.set_default(
"live_search.sse_broadcast_buffer",
default_live_search_sse_broadcast_buffer() as i64,
i64_default(
default_live_search_sse_broadcast_buffer(),
"live_search.sse_broadcast_buffer",
)?,
)?
.add_source(
config::File::new(
config_path.as_deref().unwrap_or("config.toml"),
config_path.unwrap_or("config.toml"),
config::FileFormat::Toml,
)
.required(false),
)
.add_source(
config::Environment::with_prefix("RWF")
.prefix_separator("_")
.separator("__")
.try_parsing(true),
);
.add_source(environment);

let cfg: Self = builder.build()?.try_deserialize()?;
cfg.validate()?;
Expand Down Expand Up @@ -298,62 +330,22 @@ impl Config {

#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};

use super::*;

fn env_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}

struct EnvVarGuard {
key: String,
original: Option<String>,
}

impl EnvVarGuard {
#[expect(
unsafe_code,
reason = "process environment mutation is serialized by env_test_lock"
)]
fn set(key: &str, value: &str) -> Self {
let original = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self {
key: key.to_string(),
original,
}
}
}

impl Drop for EnvVarGuard {
#[expect(
unsafe_code,
reason = "process environment mutation is serialized by env_test_lock"
)]
fn drop(&mut self) {
match self.original.as_deref() {
Some(value) => unsafe { std::env::set_var(&self.key, value) },
None => unsafe { std::env::remove_var(&self.key) },
}
}
fn empty_environment() -> config::Map<String, String> {
config::Map::new()
}

fn with_env_var<F, R>(key: &str, value: &str, f: F) -> R
where
F: FnOnce() -> R,
{
let _guard = EnvVarGuard::set(key, value);
f()
fn environment(entries: &[(&str, &str)]) -> config::Map<String, String> {
entries
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect()
}

#[test]
fn defaults_match_documented_values() -> Result<(), ConfigError> {
let _guard = env_test_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cfg = Config::load()?;
let cfg = Config::load_from(None, Some(empty_environment()))?;
assert_eq!(cfg.gateway.port, 3001);
assert_eq!(cfg.live_search.port, 3000);
assert_eq!(cfg.live_search.pool_max_connections, 20);
Expand All @@ -367,19 +359,22 @@ mod tests {

#[test]
fn rwf_config_missing_path_errors() {
let _guard = env_test_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = with_env_var("RWF_CONFIG", "/this/path/does/not/exist.toml", Config::load);
let result = Config::load_from(
Some("/this/path/does/not/exist.toml"),
Some(empty_environment()),
);
assert!(matches!(result, Err(ConfigError::ConfigPathNotFound(_))));
}

#[test]
fn invalid_zero_sse_buffer_is_rejected() {
let _guard = env_test_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = with_env_var("RWF_LIVE_SEARCH__SSE_BROADCAST_BUFFER", "0", Config::load);
let result = Config::load_from(
None,
Some(environment(&[(
"RWF_LIVE_SEARCH__SSE_BROADCAST_BUFFER",
"0",
)])),
);
assert!(matches!(result, Err(ConfigError::Invalid(_))));
}

Expand Down
5 changes: 2 additions & 3 deletions e2e-tests/src/common/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ use testcontainers::{ContainerAsync, GenericImage, ImageExt};
pub struct TestEnv {
pool: PgPool,
connection_string: String,
#[allow(dead_code, reason = "Kept alive for Drop side-effect on TestEnv")]
container: Box<ContainerAsync<GenericImage>>,
_container: Box<ContainerAsync<GenericImage>>,
}

impl std::fmt::Debug for TestEnv {
Expand Down Expand Up @@ -90,7 +89,7 @@ impl TestEnv {
Ok(Self {
pool,
connection_string,
container: Box::new(container),
_container: Box::new(container),
})
}

Expand Down
1 change: 0 additions & 1 deletion e2e-tests/src/common/gateway_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ pub const TEST_ADMIN_USER_ID: &str = "00000000-0000-0000-0000-000000000001";
pub struct GatewayEnv {
addr: SocketAddr,
shutdown: CancellationToken,
#[allow(dead_code, reason = "Kept alive for Drop side-effect on GatewayEnv")]
db: super::db::TestEnv,
}

Expand Down
10 changes: 6 additions & 4 deletions e2e-tests/src/common/once.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,17 @@ pub struct SharedServer<T: Send + Sync + 'static> {
bg_init_once: Once,
}

impl<T: Send + Sync + 'static> Default for SharedServer<T> {
fn default() -> Self {
Self::new()
}
}

impl<T: Send + Sync + 'static> SharedServer<T> {
/// Create a new uninitialised server holder.
///
/// This is `const`-callable so it can be used in a `static` initialiser.
#[must_use]
#[expect(
clippy::new_without_default,
reason = "Default is not meaningful for a once-only initialiser; callers should explicitly opt in via `static SERVER: SharedServer<T> = SharedServer::new()`"
)]
pub const fn new() -> Self {
Self {
cell: OnceCell::const_new(),
Expand Down
62 changes: 39 additions & 23 deletions gateway/benches/auth_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@
//! immediately validate it. This exercises PEM parsing, base64 encoding,
//! base64 decoding, signature creation, and signature verification.
//!
//! # Lints
//!
//! `expect_used` and `default_trait_access` are allowed here because
//! benchmark failure is a programming error.
#![allow(clippy::expect_used, clippy::default_trait_access)]

use std::fmt::Debug;
use std::hint::black_box;

use criterion::{Criterion, criterion_group, criterion_main};
Expand All @@ -23,10 +18,22 @@ use uuid::Uuid;
const BENCH_SEED: [u8; 32] = [0x42u8; 32];

/// Deterministic Ed25519 keypair PEM strings from [`BENCH_SEED`].
fn or_exit<T, E: Debug>(operation: &str, result: Result<T, E>) -> T {
match result {
Ok(value) => value,
Err(error) => {
eprintln!("FATAL: {operation}: {error:?}");
std::process::exit(1);
}
}
}

fn dev_keypair_pems() -> (String, String) {
use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair};
let key_pair =
Ed25519KeyPair::from_seed_unchecked(&BENCH_SEED).expect("Ed25519 keypair from seed");
let key_pair = or_exit(
"construct deterministic Ed25519 keypair",
Ed25519KeyPair::from_seed_unchecked(&BENCH_SEED),
);
let public_key = key_pair.public_key().as_ref().to_vec();
let private_pem = pem_encode("PRIVATE KEY", &ed25519_pkcs8_der(&BENCH_SEED));
let public_pem = pem_encode("PUBLIC KEY", &ed25519_spki_der(&public_key));
Expand All @@ -35,25 +42,34 @@ fn dev_keypair_pems() -> (String, String) {

fn bench_sign_verify(c: &mut Criterion) {
let (private_pem, public_pem) = dev_keypair_pems();
let encoding_key =
EncodingKey::from_ed_pem(private_pem.as_bytes()).expect("valid Ed25519 private key PEM");
let decoding_key =
DecodingKey::from_ed_pem(public_pem.as_bytes()).expect("valid Ed25519 public key PEM");
let encoding_key = or_exit(
"parse benchmark Ed25519 private key",
EncodingKey::from_ed_pem(private_pem.as_bytes()),
);
let decoding_key = or_exit(
"parse benchmark Ed25519 public key",
DecodingKey::from_ed_pem(public_pem.as_bytes()),
);

let bench_user_id =
UserId::try_from(Uuid::from_u128(0xDEAD_BEEF_CAFE_BABE_0123_4567_89AB_CDEF))
.expect("benchmark user id is non-nil");
let bench_user_id = or_exit(
"construct non-zero benchmark user id",
UserId::try_from(Uuid::from_u128(0xDEAD_BEEF_CAFE_BABE_0123_4567_89AB_CDEF)),
);

c.bench_function("sign_verify", |b| {
b.iter(|| {
let token = create_jwt(
black_box(&bench_user_id),
black_box(&encoding_key),
black_box(60 * 60 * 24),
)
.expect("create_jwt should succeed");
let claims = validate_jwt(black_box(&token), black_box(&decoding_key))
.expect("validate_jwt should succeed");
let token = or_exit(
"create benchmark JWT",
create_jwt(
black_box(&bench_user_id),
black_box(&encoding_key),
black_box(60 * 60 * 24),
),
);
let claims = or_exit(
"validate benchmark JWT",
validate_jwt(black_box(&token), black_box(&decoding_key)),
);
let _ = black_box(claims.sub);
});
});
Expand Down
Loading
Loading