Prepare sensitive_url for crates.io (#8223)

Another good candidate for publishing separately from Lighthouse is `sensitive_url` as it's a general utility crate and not related to Ethereum. This PR prepares it to be spun out into its own crate.


  I've made the `full` field on `SensitiveUrl` private and instead provided an explicit getter called `.expose_full()`. It's a bit ugly for the diff but I prefer the explicit nature of the getter.
I've also added some extra tests and doc strings along with feature gating `Serialize` and `Deserialize` implementations behind the `serde` feature.


Co-Authored-By: Mac L <mjladson@pm.me>
This commit is contained in:
Mac L
2025-11-05 11:46:32 +04:00
committed by GitHub
parent 7b1cbca264
commit 3066f0bef2
16 changed files with 225 additions and 93 deletions

View File

@@ -1,26 +1,69 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::fmt;
use std::str::FromStr;
use url::Url;
/// Errors that can occur when creating or parsing a `SensitiveUrl`.
#[derive(Debug)]
pub enum SensitiveError {
pub enum Error {
/// The URL cannot be used as a base URL.
InvalidUrl(String),
/// Failed to parse the URL string.
ParseError(url::ParseError),
/// Failed to redact sensitive information from the URL.
RedactError(String),
}
impl fmt::Display for SensitiveError {
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
match self {
Error::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
Error::ParseError(e) => write!(f, "Parse error: {}", e),
Error::RedactError(msg) => write!(f, "Redact error: {}", msg),
}
}
}
// Wrapper around Url which provides a custom `Display` implementation to protect user secrets.
#[derive(Clone, PartialEq)]
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::ParseError(e) => Some(e),
_ => None,
}
}
}
/// A URL wrapper that redacts sensitive information in `Display` and `Debug` output.
///
/// This type stores both the full URL (with credentials, paths, and query parameters)
/// and a redacted version (containing only the scheme, host, and port). The redacted
/// version is used when displaying or debugging to prevent accidental leakage of
/// credentials in logs.
///
/// Note that `SensitiveUrl` specifically does NOT implement `Deref`, meaning you cannot call
/// `Url` methods like `.password()` or `.scheme()` directly on `SensitiveUrl`. You must first
/// explicitly call `.expose_full()`.
///
/// # Examples
///
/// ```
/// use sensitive_url::SensitiveUrl;
///
/// let url = SensitiveUrl::parse("https://user:pass@example.com/api?token=secret").unwrap();
///
/// // Display shows only the redacted version:
/// assert_eq!(url.to_string(), "https://example.com/");
///
/// // But you can still access the full URL when needed:
/// let full = url.expose_full();
/// assert_eq!(full.to_string(), "https://user:pass@example.com/api?token=secret");
/// assert_eq!(full.password(), Some("pass"));
/// ```
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SensitiveUrl {
pub full: Url,
pub redacted: String,
full: Url,
redacted: String,
}
impl fmt::Display for SensitiveUrl {
@@ -31,16 +74,14 @@ impl fmt::Display for SensitiveUrl {
impl fmt::Debug for SensitiveUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.redacted.fmt(f)
}
}
impl AsRef<str> for SensitiveUrl {
fn as_ref(&self) -> &str {
self.redacted.as_str()
f.debug_struct("SensitiveUrl")
.field("redacted", &self.redacted)
// Maintains traditional `Debug` format but hides the 'full' field.
.finish_non_exhaustive()
}
}
#[cfg(feature = "serde")]
impl Serialize for SensitiveUrl {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
@@ -50,6 +91,7 @@ impl Serialize for SensitiveUrl {
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for SensitiveUrl {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -62,7 +104,7 @@ impl<'de> Deserialize<'de> for SensitiveUrl {
}
impl FromStr for SensitiveUrl {
type Err = SensitiveError;
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
@@ -70,26 +112,28 @@ impl FromStr for SensitiveUrl {
}
impl SensitiveUrl {
pub fn parse(url: &str) -> Result<Self, SensitiveError> {
let surl = Url::parse(url).map_err(SensitiveError::ParseError)?;
/// Attempts to parse a `&str` into a `SensitiveUrl`.
pub fn parse(url: &str) -> Result<Self, Error> {
let surl = Url::parse(url).map_err(Error::ParseError)?;
SensitiveUrl::new(surl)
}
pub fn new(full: Url) -> Result<Self, SensitiveError> {
/// Creates a `SensitiveUrl` from an existing `Url`.
pub fn new(full: Url) -> Result<Self, Error> {
let mut redacted = full.clone();
redacted
.path_segments_mut()
.map_err(|_| SensitiveError::InvalidUrl("URL cannot be a base.".to_string()))?
.map_err(|_| Error::InvalidUrl("URL cannot be a base.".to_string()))?
.clear();
redacted.set_query(None);
if redacted.has_authority() {
redacted.set_username("").map_err(|_| {
SensitiveError::RedactError("Unable to redact username.".to_string())
})?;
redacted.set_password(None).map_err(|_| {
SensitiveError::RedactError("Unable to redact password.".to_string())
})?;
redacted
.set_username("")
.map_err(|_| Error::RedactError("Unable to redact username.".to_string()))?;
redacted
.set_password(None)
.map_err(|_| Error::RedactError("Unable to redact password.".to_string()))?;
}
Ok(Self {
@@ -97,6 +141,16 @@ impl SensitiveUrl {
redacted: redacted.to_string(),
})
}
/// Returns a reference to the full, unredacted URL.
pub fn expose_full(&self) -> &Url {
&self.full
}
/// Returns the redacted URL as a `&str`.
pub fn redacted(&self) -> &str {
&self.redacted
}
}
#[cfg(test)]
@@ -105,16 +159,81 @@ mod tests {
#[test]
fn redact_remote_url() {
let full = "https://project:secret@example.com/example?somequery";
let full = "https://user:pass@example.com/example?somequery";
let surl = SensitiveUrl::parse(full).unwrap();
assert_eq!(surl.to_string(), "https://example.com/");
assert_eq!(surl.full.to_string(), full);
assert_eq!(surl.expose_full().to_string(), full);
}
#[test]
fn redact_localhost_url() {
let full = "http://localhost:5052/";
let full = "http://user:pass@localhost:5052/";
let surl = SensitiveUrl::parse(full).unwrap();
assert_eq!(surl.to_string(), "http://localhost:5052/");
assert_eq!(surl.full.to_string(), full);
assert_eq!(surl.expose_full().to_string(), full);
}
#[test]
fn test_no_credentials() {
let full = "https://example.com/path";
let surl = SensitiveUrl::parse(full).unwrap();
assert_eq!(surl.to_string(), "https://example.com/");
assert_eq!(surl.expose_full().to_string(), full);
}
#[test]
fn test_display() {
let full = "https://user:pass@example.com/api?token=secret";
let surl = SensitiveUrl::parse(full).unwrap();
let display = surl.to_string();
assert_eq!(display, "https://example.com/");
}
#[test]
fn test_debug() {
let full = "https://user:pass@example.com/api?token=secret";
let surl = SensitiveUrl::parse(full).unwrap();
let debug = format!("{:?}", surl);
assert_eq!(
debug,
"SensitiveUrl { redacted: \"https://example.com/\", .. }"
);
}
#[cfg(feature = "serde")]
mod serde_tests {
use super::*;
#[test]
fn test_serialize() {
let full = "https://user:pass@example.com/api?token=secret";
let surl = SensitiveUrl::parse(full).unwrap();
let json = serde_json::to_string(&surl).unwrap();
assert_eq!(json, format!("\"{}\"", full));
}
#[test]
fn test_deserialize() {
let full = "https://user:pass@example.com/api?token=secret";
let json = format!("\"{}\"", full);
let surl: SensitiveUrl = serde_json::from_str(&json).unwrap();
assert_eq!(surl.expose_full().as_str(), full);
}
#[test]
fn test_roundtrip() {
let full = "https://user:pass@example.com/api?token=secret";
let original = SensitiveUrl::parse(full).unwrap();
let json = serde_json::to_string(&original).unwrap();
let deserialized: SensitiveUrl = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.expose_full(), original.expose_full());
}
}
}