app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 34a871959983e573f0ee4b66dd74b42cb04494e6
parent e39fe060df73a71f64482a1f4f2861e90fb111ab
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 07:27:15 +0000

config: enforce strict relay urls

Diffstat:
MCargo.lock | 2+-
Mcrates/launchers/desktop/src/runtime.rs | 34+++++++++++++++++++++-------------
Mcrates/shared/core/Cargo.toml | 2+-
Mcrates/shared/core/src/runtime.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
4 files changed, 95 insertions(+), 31 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5093,6 +5093,7 @@ version = "0.1.0" dependencies = [ "chrono", "radroots_app_models", + "radroots_local_events", "radroots_runtime_paths", "serde", "serde_json", @@ -5100,7 +5101,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "url", ] [[package]] diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -4857,18 +4857,9 @@ fn current_runtime_time_ms() -> Result<i64, AppSqliteError> { fn normalized_app_sync_relay_urls( relay_urls: &[String], ) -> Result<Vec<String>, AppSyncTransportError> { - let mut seen = BTreeSet::new(); - let normalized = relay_urls - .iter() - .filter_map(|relay| { - let relay = relay.trim(); - if relay.is_empty() || !seen.insert(relay.to_owned()) { - None - } else { - Some(relay.to_owned()) - } - }) - .collect::<Vec<_>>(); + let normalized = radroots_local_events::normalize_relay_urls(relay_urls).map_err(|error| { + AppSyncTransportError::failed(format!("invalid direct relay app sync relay url: {error}")) + })?; if normalized.is_empty() { return Err(AppSyncTransportError::unavailable( "direct relay app sync requires at least one configured relay", @@ -7349,7 +7340,6 @@ mod tests { " ws://127.0.0.1:8081 ".to_owned(), "ws://127.0.0.1:8080".to_owned(), "ws://127.0.0.1:8081".to_owned(), - " ".to_owned(), ]) .expect("relay set should normalize"); @@ -7360,6 +7350,24 @@ mod tests { } #[test] + fn runtime_direct_relay_transport_rejects_invalid_configured_relay_urls() { + for relay_url in [ + " ", + "https://relay.example", + "wss://", + "wss://user@relay.example", + "wss://relay.example:abc", + ] { + let error = super::normalized_app_sync_relay_urls(&[relay_url.to_owned()]) + .expect_err("invalid app sync relay url"); + assert!( + error.to_string().contains("relay url"), + "unexpected error for {relay_url}: {error}" + ); + } + } + + #[test] fn order_request_listing_pointer_prefers_configured_listing_relay() { let selected = super::selected_listing_relay( &[ diff --git a/crates/shared/core/Cargo.toml b/crates/shared/core/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] chrono.workspace = true radroots_app_models.workspace = true +radroots_local_events.workspace = true radroots_runtime_paths.workspace = true serde.workspace = true serde_json.workspace = true @@ -17,7 +18,6 @@ thiserror.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true -url = "2" [lints] workspace = true diff --git a/crates/shared/core/src/runtime.rs b/crates/shared/core/src/runtime.rs @@ -1,9 +1,9 @@ use std::{ - collections::BTreeSet, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; +use radroots_local_events::normalize_relay_url; use serde::Serialize; use thiserror::Error; @@ -223,15 +223,23 @@ fn parse_relay_url_set( field: &'static str, value: String, ) -> Result<Vec<String>, AppRuntimeConfigError> { - let mut seen = BTreeSet::new(); let mut relays = Vec::new(); for relay in value.split(',') { let relay = relay.trim(); - if !relay.is_empty() { - validate_relay_url(field, relay)?; - if seen.insert(relay.to_owned()) { - relays.push(relay.to_owned()); + if relay.is_empty() { + return Err(AppRuntimeConfigError::InvalidRelayUrl { + field, + value: relay.to_owned(), + }); + } + let normalized = normalize_app_relay_url(field, relay).map_err(|_| { + AppRuntimeConfigError::InvalidRelayUrl { + field, + value: relay.to_owned(), } + })?; + if !relays.iter().any(|existing| existing == &normalized) { + relays.push(normalized); } } @@ -242,18 +250,14 @@ fn parse_relay_url_set( Ok(relays) } -fn validate_relay_url(field: &'static str, relay: &str) -> Result<(), AppRuntimeConfigError> { - let url = url::Url::parse(relay).map_err(|_| AppRuntimeConfigError::InvalidRelayUrl { +fn normalize_app_relay_url( + field: &'static str, + relay: &str, +) -> Result<String, AppRuntimeConfigError> { + normalize_relay_url(relay).map_err(|_| AppRuntimeConfigError::InvalidRelayUrl { field, value: relay.to_owned(), - })?; - if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() { - return Err(AppRuntimeConfigError::InvalidRelayUrl { - field, - value: relay.to_owned(), - }); - } - Ok(()) + }) } fn require_path_value( @@ -504,6 +508,58 @@ mod tests { } #[test] + fn runtime_config_rejects_malformed_nostr_relay_authority() { + for relay_url in [ + "wss://user@relay.example", + "wss://relay.example:abc", + "wss://2001:db8::1", + "wss://relay.example,,wss://relay-two.example", + ] { + let env = BTreeMap::from([ + (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), + (APP_NOSTR_RELAY_URLS_ENV, relay_url.to_owned()), + ]); + let error = AppRuntimeConfig::from_env_with( + |name| env.get(name).cloned(), + Some(PathBuf::from("/tmp/default-logs")), + ) + .expect_err("malformed relay authority should fail"); + + assert!( + matches!( + error, + AppRuntimeConfigError::InvalidRelayUrl { + field: APP_NOSTR_RELAY_URLS_ENV, + .. + } + ), + "unexpected error for {relay_url}: {error}" + ); + } + } + + #[test] + fn runtime_config_accepts_bracketed_ipv6_nostr_relay_urls() { + let env = BTreeMap::from([ + (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), + ( + APP_NOSTR_RELAY_URLS_ENV, + " wss://[2001:db8::1]:443/relay ".to_owned(), + ), + ]); + let config = AppRuntimeConfig::from_env_with( + |name| env.get(name).cloned(), + Some(PathBuf::from("/tmp/default-logs")), + ) + .expect("ipv6 relay url should resolve"); + + assert_eq!( + config.nostr_relay_urls, + vec!["wss://[2001:db8::1]:443/relay"] + ); + } + + #[test] fn runtime_config_defaults_local_log_root_from_runtime_paths() { let env = test_runtime_env(); let config = AppRuntimeConfig::from_env_with(