Skip to main content

reddb_wire/
conn_string.rs

1//! Connection-string parser shared across `reddb`, `reddb-client`,
2//! `red_client`, and every language driver.
3//!
4//! Pure function over a string; no I/O, no allocation beyond what the
5//! returned [`ConnectionTarget`] needs. The grammar is defined by
6//! `docs/clients/connection-strings.md`; this module is the canonical
7//! parser and is the single source of truth consumed by the rest of
8//! the workspace.
9//!
10//! The parser ports the logic that previously lived in
11//! `drivers/rust/src/connect.rs` (which keeps a thin re-export layer
12//! for backwards compatibility while drivers migrate over). Cluster
13//! URIs (`grpc://primary,replica:port`), default ports per scheme,
14//! and the `?route=primary` override behave identically to the
15//! original.
16
17use std::path::PathBuf;
18
19use url::Url;
20
21/// Stable error code for parser failures.
22///
23/// Mirrors the `ErrorCode` shape used by the language drivers so that
24/// downstream wrappers can map 1:1 without information loss.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ParseErrorKind {
27    /// The input was empty.
28    Empty,
29    /// `url::Url` rejected the string, or a transport-specific
30    /// invariant (missing host, empty cluster entry, bad port…) was
31    /// violated.
32    InvalidUri,
33    /// The scheme is not in the documented vocabulary.
34    UnsupportedScheme,
35    /// A DoS guardrail in [`ConnStringLimits`] was tripped.
36    /// `message` carries the limit name + the offending value so
37    /// downstream wrappers can surface the structured detail.
38    LimitExceeded,
39}
40
41impl ParseErrorKind {
42    pub fn as_str(self) -> &'static str {
43        match self {
44            ParseErrorKind::Empty => "EMPTY",
45            ParseErrorKind::InvalidUri => "INVALID_URI",
46            ParseErrorKind::UnsupportedScheme => "UNSUPPORTED_SCHEME",
47            ParseErrorKind::LimitExceeded => "LIMIT_EXCEEDED",
48        }
49    }
50}
51
52/// Error returned by [`parse`].
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ParseError {
55    pub kind: ParseErrorKind,
56    pub message: String,
57}
58
59impl ParseError {
60    pub fn new(kind: ParseErrorKind, message: impl Into<String>) -> Self {
61        Self {
62            kind,
63            message: message.into(),
64        }
65    }
66}
67
68impl std::fmt::Display for ParseError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "{}: {}", self.kind.as_str(), self.message)
71    }
72}
73
74impl std::error::Error for ParseError {}
75
76/// Default port per documented scheme. Centralised so other crates
77/// (the connector, server-side dispatch) can stay consistent.
78pub const DEFAULT_PORT_RED: u16 = 5050;
79pub const DEFAULT_PORT_GRPC: u16 = 55055;
80pub const DEFAULT_PORT_GRPCS: u16 = 55555;
81/// Default ports for `ws://` / `red+ws://` and `wss://` / `red+wss://` — align with the
82/// standard WS / WSS browser defaults (80 and 443) so a hosted endpoint
83/// like `*.db.reddb.io` works without an explicit port.
84pub const DEFAULT_PORT_WS: u16 = 80;
85pub const DEFAULT_PORT_WSS: u16 = 443;
86
87/// URI schemes accepted by the connection-string parser.
88///
89/// This enum is the connection-layer source for generated agent knowledge:
90/// [`crate::knowledge`] iterates [`SUPPORTED_SCHEMES`] instead of carrying a
91/// separate hand-maintained scheme list.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ConnectionScheme {
94    Memory,
95    File,
96    Red,
97    Reds,
98    RedWs,
99    RedWss,
100    Ws,
101    Wss,
102    Grpc,
103    Grpcs,
104    Http,
105    Https,
106}
107
108/// Stable parser-owned list of supported URI schemes.
109pub const SUPPORTED_SCHEMES: &[ConnectionScheme] = &[
110    ConnectionScheme::Red,
111    ConnectionScheme::Reds,
112    ConnectionScheme::Grpc,
113    ConnectionScheme::Grpcs,
114    ConnectionScheme::Http,
115    ConnectionScheme::Https,
116    ConnectionScheme::Memory,
117    ConnectionScheme::File,
118    ConnectionScheme::RedWs,
119    ConnectionScheme::RedWss,
120    ConnectionScheme::Ws,
121    ConnectionScheme::Wss,
122];
123
124impl ConnectionScheme {
125    pub fn from_uri_scheme(scheme: &str) -> Option<Self> {
126        match scheme {
127            "memory" => Some(Self::Memory),
128            "file" => Some(Self::File),
129            "red" => Some(Self::Red),
130            "reds" => Some(Self::Reds),
131            "red+ws" => Some(Self::RedWs),
132            "red+wss" => Some(Self::RedWss),
133            "ws" => Some(Self::Ws),
134            "wss" => Some(Self::Wss),
135            "grpc" => Some(Self::Grpc),
136            "grpcs" => Some(Self::Grpcs),
137            "http" => Some(Self::Http),
138            "https" => Some(Self::Https),
139            _ => None,
140        }
141    }
142
143    pub fn uri_prefix(self) -> &'static str {
144        match self {
145            Self::Memory => "memory://",
146            Self::File => "file://",
147            Self::Red => "red://",
148            Self::Reds => "reds://",
149            Self::RedWs => "red+ws://",
150            Self::RedWss => "red+wss://",
151            Self::Ws => "ws://",
152            Self::Wss => "wss://",
153            Self::Grpc => "grpc://",
154            Self::Grpcs => "grpcs://",
155            Self::Http => "http://",
156            Self::Https => "https://",
157        }
158    }
159
160    pub fn transport(self) -> &'static str {
161        match self {
162            Self::Memory => "embedded in-memory engine",
163            Self::File => "embedded file-backed engine",
164            Self::Red | Self::Reds => "RedWire TCP",
165            Self::RedWs | Self::RedWss | Self::Ws | Self::Wss => "RedWire WebSocket",
166            Self::Grpc | Self::Grpcs => "gRPC",
167            Self::Http | Self::Https => "HTTP REST",
168        }
169    }
170
171    pub fn mode(self) -> &'static str {
172        match self {
173            Self::Memory | Self::File => "embedded",
174            Self::Red
175            | Self::Reds
176            | Self::RedWs
177            | Self::RedWss
178            | Self::Ws
179            | Self::Wss
180            | Self::Grpc
181            | Self::Grpcs
182            | Self::Http
183            | Self::Https => "remote",
184        }
185    }
186
187    pub fn example(self) -> &'static str {
188        match self {
189            Self::Memory => "memory://",
190            Self::File => "file:///var/lib/reddb/app.db",
191            Self::Red => "red://db.example.com:5050",
192            Self::Reds => "reds://db.example.com:5050",
193            Self::RedWs => "red+ws://db.example.com",
194            Self::RedWss => "red+wss://db.example.com",
195            Self::Ws => "ws://db.example.com",
196            Self::Wss => "wss://db.example.com",
197            Self::Grpc => "grpc://db.example.com:55055",
198            Self::Grpcs => "grpcs://db.example.com:55555",
199            Self::Http => "http://db.example.com:80",
200            Self::Https => "https://db.example.com:443",
201        }
202    }
203
204    pub fn notes(self) -> &'static str {
205        match self {
206            Self::Memory => "Zero-config ephemeral engine, commonly used by local MCP hosts.",
207            Self::File => "Embedded durable engine rooted at the URI path.",
208            Self::Red => "Principal RedWire transport without TLS.",
209            Self::Reds => "Principal RedWire transport with TLS.",
210            Self::RedWs => "Browser-native RedWire over WebSocket without TLS.",
211            Self::RedWss => "Browser-native RedWire over WebSocket with TLS.",
212            Self::Ws => "Browser-friendly alias for RedWire over WebSocket without TLS.",
213            Self::Wss => "Browser-friendly alias for RedWire over WebSocket with TLS.",
214            Self::Grpc => "Compatibility transport for existing gRPC clients.",
215            Self::Grpcs => "TLS variant of the gRPC compatibility transport.",
216            Self::Http => "REST/admin transport without TLS.",
217            Self::Https => "REST/admin transport with TLS.",
218        }
219    }
220}
221
222/// DoS guardrails applied by [`parse`] before any URI work happens.
223///
224/// The connection-string parser is the only entry point an attacker
225/// can reach BEFORE auth, so every limit here is enforced eagerly
226/// and surfaces as a structured [`ParseErrorKind::LimitExceeded`]
227/// error rather than a panic, hang, or unbounded allocation.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct ConnStringLimits {
230    /// Maximum length of the input URI in bytes. Default `8 KiB`.
231    pub max_uri_bytes: usize,
232    /// Maximum number of `key=value` query parameters. Default `32`.
233    pub max_query_params: usize,
234    /// Maximum number of comma-separated cluster hosts allowed in a
235    /// `grpc://`/`grpcs://` cluster URI. Default `64`.
236    pub max_cluster_hosts: usize,
237}
238
239impl Default for ConnStringLimits {
240    fn default() -> Self {
241        Self {
242            max_uri_bytes: 8 * 1024,
243            max_query_params: 32,
244            max_cluster_hosts: 64,
245        }
246    }
247}
248
249/// Normalised target produced by [`parse`].
250///
251/// Variants intentionally mirror the public Rust client target shape
252/// so callers can keep a thin compatibility layer without duplicating
253/// parser behavior.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum ConnectionTarget {
256    /// `memory://` — ephemeral, in-memory backend.
257    Memory,
258    /// `file:///abs/path` — embedded engine on disk.
259    File { path: PathBuf },
260    /// Single remote endpoint over `grpc://` or `grpcs://`. Stored as a
261    /// normalised `http://` or `https://` endpoint for tonic.
262    Grpc { endpoint: String },
263    /// Multi-host gRPC URI: primary + read replicas. Writes hit the
264    /// primary; reads round-robin across replicas unless
265    /// `force_primary` is set.
266    GrpcCluster {
267        primary: String,
268        replicas: Vec<String>,
269        force_primary: bool,
270    },
271    /// `http://host:port` / `https://host:port` — REST endpoint.
272    Http { base_url: String },
273    /// `red://host:port` (plain TCP) or `reds://host:port` (TLS).
274    /// RedWire binary frame protocol per ADR 0001. The connector
275    /// speaks framed binary directly; it does NOT route through
276    /// tonic.
277    RedWire { host: String, port: u16, tls: bool },
278    /// `red+ws://host:port` / `ws://host:port` (plain WS) or
279    /// `red+wss://host:port` / `wss://host:port` (WSS).
280    /// Browser-native WebSocket transport (ADR 0047 direct-when-reachable).
281    /// The UI connects directly — no local RedWire-over-TCP bridge needed.
282    WsNative { host: String, port: u16, tls: bool },
283}
284
285/// Authentication material derived from a connection string or adjacent CLI
286/// fallback. `Debug` deliberately redacts every caller-supplied credential.
287#[derive(Clone, PartialEq, Eq)]
288pub enum ConnectionAuth {
289    Anonymous,
290    Bearer(String),
291    Basic { user: String, pass: String },
292    ApiKey(String),
293}
294
295impl std::fmt::Debug for ConnectionAuth {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        match self {
298            Self::Anonymous => f.write_str("Anonymous"),
299            Self::Bearer(_) => f.debug_tuple("Bearer").field(&"<redacted>").finish(),
300            Self::Basic { .. } => f
301                .debug_struct("Basic")
302                .field("user", &"<redacted>")
303                .field("pass", &"<redacted>")
304                .finish(),
305            Self::ApiKey(_) => f.debug_tuple("ApiKey").field(&"<redacted>").finish(),
306        }
307    }
308}
309
310impl ConnectionAuth {
311    pub fn bearer(token: impl Into<String>) -> Self {
312        Self::Bearer(token.into())
313    }
314
315    pub fn is_bearer(&self) -> bool {
316        matches!(self, Self::Bearer(_))
317    }
318}
319
320/// Connection target plus URL-derived auth metadata.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct ConnectionSpec {
323    pub target: ConnectionTarget,
324    pub auth: ConnectionAuth,
325    pub redacted_uri: String,
326}
327
328/// Parse a connection URI into a [`ConnectionTarget`] under the
329/// default DoS limits.
330///
331/// Pure function, no side effects. Behaviour matches
332/// `drivers/rust/src/connect.rs::parse` 1:1 with two additions:
333///   - Mixed-case schemes (e.g. `Red://`, `REDS://`) are normalised
334///     to lowercase before dispatch.
335///   - Inputs exceeding [`ConnStringLimits`] return a structured
336///     [`ParseErrorKind::LimitExceeded`] error instead of being
337///     processed.
338pub fn parse(uri: &str) -> Result<ConnectionTarget, ParseError> {
339    parse_with_limits(uri, ConnStringLimits::default())
340}
341
342/// Parse a connection URI and derive auth from RFC 3986 userinfo.
343///
344/// The existing [`parse`] function remains target-only for compatibility.
345/// New client-mode entry points should use this richer shape so credentials
346/// are identified and redacted before transport connectors are built.
347pub fn parse_with_auth(uri: &str) -> Result<ConnectionSpec, ParseError> {
348    let normalised = normalise_scheme(uri);
349    let redacted_uri = redact_connection_uri(&normalised);
350    let target =
351        parse(uri).map_err(|err| redact_parse_error(err, uri, &normalised, &redacted_uri))?;
352    let auth = auth_from_uri_userinfo(&normalised)
353        .map_err(|err| redact_parse_error(err, uri, &normalised, &redacted_uri))?;
354    Ok(ConnectionSpec {
355        target,
356        auth,
357        redacted_uri,
358    })
359}
360
361fn redact_parse_error(
362    mut err: ParseError,
363    raw_uri: &str,
364    normalised_uri: &str,
365    redacted_uri: &str,
366) -> ParseError {
367    err.message = err
368        .message
369        .replace(raw_uri, redacted_uri)
370        .replace(normalised_uri, redacted_uri);
371    err
372}
373
374/// Return true for documented embedded aliases that must not resolve to
375/// a remote transport target.
376///
377/// This is intentionally separate from [`parse`]: legacy clients may need
378/// to reject embedded targets before mapping `red://host` onto a remote
379/// compatibility transport.
380pub fn is_embedded_connection_uri(uri: &str) -> bool {
381    let trimmed = uri.trim();
382    matches!(
383        trimmed,
384        "red://" | "red:" | "red:///" | "red://:memory" | "red://:memory:"
385    ) || trimmed.starts_with("red:///")
386}
387
388/// Same as [`parse`] but with caller-supplied DoS guardrails.
389/// Useful for tests that need tighter limits or for callers (a
390/// future admin tool, an offline validator) that need to relax the
391/// defaults.
392pub fn parse_with_limits(
393    uri: &str,
394    limits: ConnStringLimits,
395) -> Result<ConnectionTarget, ParseError> {
396    if uri.is_empty() {
397        return Err(ParseError::new(
398            ParseErrorKind::Empty,
399            "empty connection string",
400        ));
401    }
402
403    if uri.len() > limits.max_uri_bytes {
404        return Err(ParseError::new(
405            ParseErrorKind::LimitExceeded,
406            format!(
407                "max_uri_bytes exceeded: limit={} actual={}",
408                limits.max_uri_bytes,
409                uri.len(),
410            ),
411        ));
412    }
413
414    // Lowercase the scheme so `Red://Host`, `REDS://Host`, etc.
415    // dispatch identically to the canonical lowercase forms. The
416    // host and path retain original casing — host is downcased by
417    // `url::Url` for IDN per spec, path stays verbatim.
418    let normalised = normalise_scheme(uri);
419    let uri = normalised.as_str();
420
421    if uri == "memory://" || uri == "memory:" {
422        return Ok(ConnectionTarget::Memory);
423    }
424
425    if let Some(rest) = uri.strip_prefix("file://") {
426        if rest.is_empty() {
427            return Err(ParseError::new(
428                ParseErrorKind::InvalidUri,
429                "file:// URI is missing a path",
430            ));
431        }
432        return Ok(ConnectionTarget::File {
433            path: file_uri_path(rest),
434        });
435    }
436
437    if let Some(cluster) = try_parse_grpc_cluster(uri, &limits)? {
438        return Ok(cluster);
439    }
440
441    // Before `Url::parse`, because a multi-host authority like
442    // `red://a:1,b:2` fails URL parsing with "invalid port number" — a
443    // message that hides the real problem. `red://` no longer folds to
444    // gRPC (#2159), and RedWire has no multi-host form, so a cluster URI
445    // has to name the transport that supports one.
446    if let Some(rest) = uri
447        .strip_prefix("red://")
448        .or_else(|| uri.strip_prefix("reds://"))
449    {
450        let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
451        if authority.contains(',') {
452            return Err(ParseError::new(
453                ParseErrorKind::InvalidUri,
454                "RedWire does not support comma-separated cluster hosts; \
455                 use grpc:// or grpcs:// for a cluster URI",
456            ));
457        }
458    }
459
460    let parsed = Url::parse(uri)
461        .map_err(|e| ParseError::new(ParseErrorKind::InvalidUri, format!("{e}: {uri}")))?;
462
463    enforce_query_param_limit(&parsed, &limits)?;
464
465    match ConnectionScheme::from_uri_scheme(parsed.scheme()) {
466        Some(ConnectionScheme::Red | ConnectionScheme::Reds) => {
467            let host = parsed.host_str().ok_or_else(|| {
468                ParseError::new(ParseErrorKind::InvalidUri, "red:// URI is missing a host")
469            })?;
470            if host.contains(',') {
471                return Err(ParseError::new(
472                    ParseErrorKind::InvalidUri,
473                    "RedWire does not support comma-separated cluster hosts",
474                ));
475            }
476            let port = parsed.port().unwrap_or(DEFAULT_PORT_RED);
477            Ok(ConnectionTarget::RedWire {
478                host: host.to_string(),
479                port,
480                tls: parsed.scheme() == "reds",
481            })
482        }
483        Some(
484            ConnectionScheme::RedWs
485            | ConnectionScheme::RedWss
486            | ConnectionScheme::Ws
487            | ConnectionScheme::Wss,
488        ) => {
489            let host = parsed.host_str().ok_or_else(|| {
490                ParseError::new(
491                    ParseErrorKind::InvalidUri,
492                    "RedWire WebSocket URI is missing a host",
493                )
494            })?;
495            let tls = parsed.scheme() == "red+wss" || parsed.scheme() == "wss";
496            let port = parsed.port().unwrap_or(if tls {
497                DEFAULT_PORT_WSS
498            } else {
499                DEFAULT_PORT_WS
500            });
501            Ok(ConnectionTarget::WsNative {
502                host: host.to_string(),
503                port,
504                tls,
505            })
506        }
507        Some(ConnectionScheme::Grpc | ConnectionScheme::Grpcs) => {
508            let host = parsed.host_str().ok_or_else(|| {
509                ParseError::new(ParseErrorKind::InvalidUri, "grpc:// URI is missing a host")
510            })?;
511            let port = parsed.port().unwrap_or_else(|| {
512                if parsed.scheme() == "grpcs" {
513                    DEFAULT_PORT_GRPCS
514                } else {
515                    DEFAULT_PORT_GRPC
516                }
517            });
518            let endpoint_scheme = if parsed.scheme() == "grpcs" {
519                "https"
520            } else {
521                "http"
522            };
523            Ok(ConnectionTarget::Grpc {
524                endpoint: format!("{endpoint_scheme}://{host}:{port}"),
525            })
526        }
527        Some(ConnectionScheme::Http | ConnectionScheme::Https) => {
528            let host = parsed.host_str().ok_or_else(|| {
529                ParseError::new(
530                    ParseErrorKind::InvalidUri,
531                    "http(s):// URI is missing a host",
532                )
533            })?;
534            let scheme = parsed.scheme();
535            let port = parsed
536                .port()
537                .unwrap_or(if scheme == "https" { 443 } else { 80 });
538            Ok(ConnectionTarget::Http {
539                base_url: format!("{scheme}://{host}:{port}"),
540            })
541        }
542        Some(ConnectionScheme::Memory | ConnectionScheme::File) | None => Err(ParseError::new(
543            ParseErrorKind::UnsupportedScheme,
544            format!("unsupported scheme: {}", parsed.scheme()),
545        )),
546    }
547}
548
549fn file_uri_path(path: &str) -> PathBuf {
550    #[cfg(windows)]
551    {
552        let bytes = path.as_bytes();
553        // RFC 8089 spells a Windows drive path as file:///C:/path. After
554        // removing `file://`, discard only the URI separator before a drive
555        // letter; retaining it produces the invalid Windows path `\C:\...`.
556        if bytes.len() >= 4
557            && bytes[0] == b'/'
558            && bytes[1].is_ascii_alphabetic()
559            && bytes[2] == b':'
560            && matches!(bytes[3], b'/' | b'\\')
561        {
562            return PathBuf::from(&path[1..]);
563        }
564    }
565    PathBuf::from(path)
566}
567
568/// Lowercase only the scheme portion (everything before the first
569/// `:`), leaving host/path/query untouched. Returns the original
570/// string when no scheme separator is present so the downstream
571/// `Url::parse` path produces the canonical "missing scheme" error
572/// instead of being masked here.
573fn normalise_scheme(uri: &str) -> String {
574    match uri.find(':') {
575        Some(i) => {
576            let scheme = &uri[..i];
577            // Only ASCII alphanumerics + `+ . -` are valid scheme
578            // bytes per RFC 3986. If the prefix violates that we
579            // leave it alone so `Url::parse` can produce the
580            // structured error.
581            if scheme.is_empty()
582                || !scheme
583                    .bytes()
584                    .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'.' || b == b'-')
585            {
586                return uri.to_string();
587            }
588            let mut out = String::with_capacity(uri.len());
589            out.push_str(&scheme.to_ascii_lowercase());
590            out.push_str(&uri[i..]);
591            out
592        }
593        None => uri.to_string(),
594    }
595}
596
597fn auth_from_uri_userinfo(uri: &str) -> Result<ConnectionAuth, ParseError> {
598    if uri == "memory://" || uri == "memory:" || uri.starts_with("file://") {
599        return Ok(ConnectionAuth::Anonymous);
600    }
601    // URL parsers cannot parse multiple independently ported authorities.
602    // Authentication belongs to the entire seed list, not its first host.
603    let mut auth_seed = uri.to_string();
604    if uri.starts_with("grpc://") || uri.starts_with("grpcs://") {
605        if let Some((scheme, rest)) = uri.split_once("://") {
606            let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
607            let authority = &rest[..end];
608            let (userinfo, hosts) = authority
609                .rsplit_once('@')
610                .map(|(userinfo, hosts)| (format!("{userinfo}@"), hosts))
611                .unwrap_or_else(|| (String::new(), authority));
612            if hosts.contains(',') {
613                auth_seed = format!(
614                    "{scheme}://{userinfo}{}{}",
615                    hosts.split(',').next().unwrap_or_default(),
616                    &rest[end..]
617                );
618            }
619        }
620    }
621    let parsed = Url::parse(&auth_seed)
622        .map_err(|e| ParseError::new(ParseErrorKind::InvalidUri, format!("{e}: {uri}")))?;
623    let username = parsed.username();
624    if username.is_empty() {
625        return Ok(ConnectionAuth::Anonymous);
626    }
627    match parsed.password() {
628        Some(pass) => Ok(ConnectionAuth::Basic {
629            user: username.to_string(),
630            pass: pass.to_string(),
631        }),
632        None => Ok(ConnectionAuth::ApiKey(username.to_string())),
633    }
634}
635
636fn redact_connection_uri(uri: &str) -> String {
637    let uri = redact_uri_userinfo(uri);
638    redact_uri_query_secrets(&uri)
639}
640
641fn redact_uri_userinfo(uri: &str) -> String {
642    let Some(scheme_end) = uri.find("://") else {
643        return uri.to_string();
644    };
645    let authority_start = scheme_end + 3;
646    let authority_end = uri[authority_start..]
647        .find(['/', '?', '#'])
648        .map(|i| authority_start + i)
649        .unwrap_or(uri.len());
650    let authority = &uri[authority_start..authority_end];
651    let Some(at) = authority.rfind('@') else {
652        return uri.to_string();
653    };
654    let userinfo = &authority[..at];
655    let replacement = if userinfo.contains(':') {
656        "<redacted>:<redacted>"
657    } else {
658        "<redacted>"
659    };
660    format!(
661        "{}{}{}",
662        &uri[..authority_start],
663        replacement,
664        &uri[authority_start + at..]
665    )
666}
667
668fn redact_uri_query_secrets(uri: &str) -> String {
669    let Some(query_start) = uri.find('?') else {
670        return uri.to_string();
671    };
672    let query_end = uri[query_start + 1..]
673        .find('#')
674        .map(|offset| query_start + 1 + offset)
675        .unwrap_or(uri.len());
676    let query = &uri[query_start + 1..query_end];
677    let mut redacted = String::with_capacity(uri.len());
678    redacted.push_str(&uri[..query_start + 1]);
679    for (index, parameter) in query.split('&').enumerate() {
680        if index > 0 {
681            redacted.push('&');
682        }
683        let (name, value) = parameter.split_once('=').unwrap_or((parameter, ""));
684        redacted.push_str(name);
685        if parameter.contains('=') {
686            redacted.push('=');
687            if sensitive_query_name(name) {
688                redacted.push_str("<redacted>");
689            } else {
690                redacted.push_str(value);
691            }
692        }
693    }
694    redacted.push_str(&uri[query_end..]);
695    redacted
696}
697
698fn sensitive_query_name(raw: &str) -> bool {
699    let mut decoded = Vec::with_capacity(raw.len());
700    let bytes = raw.as_bytes();
701    let mut index = 0;
702    while index < bytes.len() {
703        match bytes[index] {
704            b'%' if index + 2 < bytes.len() => {
705                let high = (bytes[index + 1] as char).to_digit(16);
706                let low = (bytes[index + 2] as char).to_digit(16);
707                if let (Some(high), Some(low)) = (high, low) {
708                    decoded.push(((high << 4) | low) as u8);
709                    index += 3;
710                    continue;
711                }
712                decoded.push(bytes[index]);
713            }
714            b'+' => decoded.push(b' '),
715            byte => decoded.push(byte),
716        }
717        index += 1;
718    }
719    let normalized: String = String::from_utf8_lossy(&decoded)
720        .chars()
721        .filter(|ch| *ch != '_' && *ch != '-')
722        .flat_map(char::to_lowercase)
723        .collect();
724    matches!(
725        normalized.as_str(),
726        "token" | "password" | "pass" | "passwd" | "secret" | "cert" | "key" | "ca" | "apikey"
727    ) || normalized.ends_with("token")
728        || normalized.ends_with("password")
729        || normalized.ends_with("passwd")
730        || normalized.ends_with("secret")
731        || normalized.ends_with("apikey")
732        || normalized.ends_with("privatekey")
733        || normalized.ends_with("keyfile")
734        || normalized.ends_with("certfile")
735        || normalized.ends_with("cafile")
736        || ((normalized.starts_with("ssl") || normalized.starts_with("tls"))
737            && (normalized.ends_with("key")
738                || normalized.ends_with("cert")
739                || normalized.ends_with("ca")))
740}
741
742fn enforce_query_param_limit(url: &Url, limits: &ConnStringLimits) -> Result<(), ParseError> {
743    let Some(q) = url.query() else {
744        return Ok(());
745    };
746    if q.is_empty() {
747        return Ok(());
748    }
749    let count = q.split('&').count();
750    if count > limits.max_query_params {
751        return Err(ParseError::new(
752            ParseErrorKind::LimitExceeded,
753            format!(
754                "max_query_params exceeded: limit={} actual={}",
755                limits.max_query_params, count,
756            ),
757        ));
758    }
759    Ok(())
760}
761
762/// Try to parse a multi-host gRPC URI. `Ok(None)` means "this is a
763/// single-host URI — fall through to the standard parser".
764fn try_parse_grpc_cluster(
765    uri: &str,
766    limits: &ConnStringLimits,
767) -> Result<Option<ConnectionTarget>, ParseError> {
768    let (rest, default_port, endpoint_scheme) = if let Some(r) = uri.strip_prefix("grpc://") {
769        (r, DEFAULT_PORT_GRPC, "http")
770    } else if let Some(r) = uri.strip_prefix("grpcs://") {
771        (r, DEFAULT_PORT_GRPCS, "https")
772    } else {
773        return Ok(None);
774    };
775
776    let (host_part, query_part) = match rest.find('?') {
777        Some(i) => (&rest[..i], Some(&rest[i + 1..])),
778        None => (rest, None),
779    };
780    let host_part = host_part
781        .rsplit_once('@')
782        .map(|(_, hosts)| hosts)
783        .unwrap_or(host_part);
784
785    if !host_part.contains(',') {
786        return Ok(None);
787    }
788
789    let raw_count = host_part.split(',').count();
790    if raw_count > limits.max_cluster_hosts {
791        return Err(ParseError::new(
792            ParseErrorKind::LimitExceeded,
793            format!(
794                "max_cluster_hosts exceeded: limit={} actual={}",
795                limits.max_cluster_hosts, raw_count,
796            ),
797        ));
798    }
799
800    let mut endpoints: Vec<String> = Vec::with_capacity(raw_count);
801    for raw in host_part.split(',') {
802        let raw = raw.trim();
803        if raw.is_empty() {
804            return Err(ParseError::new(
805                ParseErrorKind::InvalidUri,
806                "grpc cluster URI has an empty host entry",
807            ));
808        }
809        // Bracketed IPv6 literal: `[::1]:5050` or `[::1]`.
810        let (host, port) = if let Some(after_bracket) = raw.strip_prefix('[') {
811            let end = after_bracket.find(']').ok_or_else(|| {
812                ParseError::new(
813                    ParseErrorKind::InvalidUri,
814                    format!("unterminated IPv6 bracket in cluster URI: {raw}"),
815                )
816            })?;
817            let host = &after_bracket[..end];
818            let tail = &after_bracket[end + 1..];
819            let port = if tail.is_empty() {
820                default_port
821            } else if let Some(p) = tail.strip_prefix(':') {
822                p.parse::<u16>().map_err(|_| {
823                    ParseError::new(
824                        ParseErrorKind::InvalidUri,
825                        format!("invalid port in cluster URI: {raw}"),
826                    )
827                })?
828            } else {
829                return Err(ParseError::new(
830                    ParseErrorKind::InvalidUri,
831                    format!("trailing junk after IPv6 bracket in cluster URI: {raw}"),
832                ));
833            };
834            (format!("[{host}]"), port)
835        } else {
836            match raw.rsplit_once(':') {
837                Some((h, p)) => {
838                    let port: u16 = p.parse().map_err(|_| {
839                        ParseError::new(
840                            ParseErrorKind::InvalidUri,
841                            format!("invalid port in cluster URI: {raw}"),
842                        )
843                    })?;
844                    (h.to_string(), port)
845                }
846                None => (raw.to_string(), default_port),
847            }
848        };
849        if host.is_empty() || host == "[]" {
850            return Err(ParseError::new(
851                ParseErrorKind::InvalidUri,
852                "grpc cluster URI has an empty host entry",
853            ));
854        }
855        endpoints.push(format!("{endpoint_scheme}://{host}:{port}"));
856    }
857
858    if let Some(q) = query_part {
859        let qcount = if q.is_empty() {
860            0
861        } else {
862            q.split('&').count()
863        };
864        if qcount > limits.max_query_params {
865            return Err(ParseError::new(
866                ParseErrorKind::LimitExceeded,
867                format!(
868                    "max_query_params exceeded: limit={} actual={}",
869                    limits.max_query_params, qcount,
870                ),
871            ));
872        }
873    }
874
875    let force_primary = query_part
876        .map(|q| {
877            q.split('&').any(|kv| {
878                let mut parts = kv.splitn(2, '=');
879                let k = parts.next().unwrap_or("");
880                let v = parts.next().unwrap_or("");
881                k.eq_ignore_ascii_case("route") && v.eq_ignore_ascii_case("primary")
882            })
883        })
884        .unwrap_or(false);
885
886    let mut iter = endpoints.into_iter();
887    let primary = iter.next().expect("split on ',' yields at least one entry");
888    let replicas: Vec<String> = iter.collect();
889
890    Ok(Some(ConnectionTarget::GrpcCluster {
891        primary,
892        replicas,
893        force_primary,
894    }))
895}