diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 7009fcb40..7902f12f6 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -492,7 +492,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16fc24bc615b9fd63148f59b218ea58a444b55762f8845da910e23aca686398b" dependencies = [ "thiserror 1.0.63", - "url", ] [[package]] @@ -2782,6 +2781,7 @@ dependencies = [ "bitcoin-hpke", "bitcoin-ohttp", "bitcoin_uri", + "form_urlencoded", "http", "once_cell", "payjoin-test-utils", @@ -2791,7 +2791,6 @@ dependencies = [ "serde_json", "tokio", "tracing", - "url", "web-time", ] @@ -2805,6 +2804,7 @@ dependencies = [ "clap 4.5.46", "config", "dirs", + "form_urlencoded", "http-body-util", "hyper", "hyper-util", @@ -2822,7 +2822,6 @@ dependencies = [ "tokio-rustls", "tracing", "tracing-subscriber", - "url", ] [[package]] diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 7009fcb40..7902f12f6 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -492,7 +492,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16fc24bc615b9fd63148f59b218ea58a444b55762f8845da910e23aca686398b" dependencies = [ "thiserror 1.0.63", - "url", ] [[package]] @@ -2782,6 +2781,7 @@ dependencies = [ "bitcoin-hpke", "bitcoin-ohttp", "bitcoin_uri", + "form_urlencoded", "http", "once_cell", "payjoin-test-utils", @@ -2791,7 +2791,6 @@ dependencies = [ "serde_json", "tokio", "tracing", - "url", "web-time", ] @@ -2805,6 +2804,7 @@ dependencies = [ "clap 4.5.46", "config", "dirs", + "form_urlencoded", "http-body-util", "hyper", "hyper-util", @@ -2822,7 +2822,6 @@ dependencies = [ "tokio-rustls", "tracing", "tracing-subscriber", - "url", ] [[package]] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 9a77f9d41..7475fcb19 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -29,3 +29,9 @@ name = "uri_deserialize_pjuri" path = "fuzz_targets/uri/deserialize_pjuri.rs" doc = false bench = false + +[[bin]] +name = "url_decode_url" +path = "fuzz_targets/url/decode_url.rs" +doc = false +bench = false diff --git a/fuzz/fuzz_targets/url/decode_url.rs b/fuzz/fuzz_targets/url/decode_url.rs new file mode 100644 index 000000000..5a60c27e4 --- /dev/null +++ b/fuzz/fuzz_targets/url/decode_url.rs @@ -0,0 +1,58 @@ +#![no_main] + +use std::str; + +use libfuzzer_sys::fuzz_target; +// Adjust this path to wherever your Url module lives in your crate. +use payjoin::Url; + +fn do_test(data: &[u8]) { + let Ok(s) = str::from_utf8(data) else { return }; + + let Ok(mut url) = Url::parse(s) else { return }; + + let _ = url.scheme(); + let _ = url.has_host(); + let _ = url.domain(); + let _ = url.host_str(); + let _ = url.port(); + let _ = url.path(); + let _ = url.query(); + let _ = url.fragment(); + let _ = url.as_str(); + let _ = url.to_string(); + if let Some(segs) = url.path_segments() { + let _ = segs.collect::>(); + } + + let raw = url.as_str().to_owned(); + if let Ok(reparsed) = Url::parse(&raw) { + assert_eq!( + reparsed.as_str(), + raw, + "round-trip mismatch: first={raw:?} second={:?}", + reparsed.as_str() + ); + } + + url.set_port(Some(8080)); + url.set_port(None); + url.set_fragment(Some("fuzz")); + url.set_fragment(None); + url.set_query(Some("k=v")); + url.set_query(None); + url.query_pairs_mut().append_pair("fuzz_key", "fuzz_val"); + + if let Some(mut segs) = url.path_segments_mut() { + segs.push("fuzz_segment"); + } + + let _ = url.join("relative/path"); + let _ = url.join("/absolute/path"); + let _ = url.join("../dotdot"); + let _ = url.join("https://other.example.com/new"); +} + +fuzz_target!(|data| { + do_test(data); +}); diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index 786b4a760..83f5bb8e8 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -32,6 +32,7 @@ bitcoind-async-client = "0.8.1" clap = { version = "4.5.45", features = ["derive"] } config = "0.15.14" dirs = "6.0.0" +form_urlencoded = { version = "1.2.2" } http-body-util = { version = "0.1.3", optional = true } hyper = { version = "1.6.0", features = ["http1", "server"], optional = true } hyper-util = { version = "0.1.16", optional = true } @@ -51,7 +52,6 @@ tokio-rustls = { version = "0.26.2", features = [ ], default-features = false, optional = true } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -url = { version = "2.5.4", features = ["serde"] } [dev-dependencies] nix = { version = "0.30.1", features = ["aio", "process", "signal"] } diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index 6b7f8acb8..cdef7ca6c 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -4,9 +4,8 @@ use anyhow::Result; use config::builder::DefaultState; use config::{ConfigError, File, FileFormat}; use payjoin::bitcoin::FeeRate; -use payjoin::Version; +use payjoin::{Url, Version}; use serde::Deserialize; -use url::Url; use crate::cli::{Cli, Commands}; use crate::db; @@ -317,10 +316,16 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result = - url::form_urlencoded::parse(query_string.as_bytes()).into_owned().collect(); + form_urlencoded::parse(query_string.as_bytes()).into_owned().collect(); let amount = query_params.get("amount").map(|amt| { Amount::from_btc(amt.parse().expect("Failed to parse amount")).unwrap() }); diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index dd6f2fc7a..31b9b7cfd 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -814,7 +814,7 @@ impl App { async fn unwrap_relay_or_else_fetch( &self, directory: Option, - ) -> Result { + ) -> Result { let directory = directory.map(|url| url.into_url()).transpose()?; let selected_relay = self.relay_manager.lock().expect("Lock should not be poisoned").get_selected_relay(); diff --git a/payjoin-cli/src/app/v2/ohttp.rs b/payjoin-cli/src/app/v2/ohttp.rs index aa9b55d84..bcc8e9357 100644 --- a/payjoin-cli/src/app/v2/ohttp.rs +++ b/payjoin-cli/src/app/v2/ohttp.rs @@ -1,35 +1,36 @@ use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Result}; +use payjoin::Url; use super::Config; #[derive(Debug, Clone)] pub struct RelayManager { - selected_relay: Option, - failed_relays: Vec, + selected_relay: Option, + failed_relays: Vec, } impl RelayManager { pub fn new() -> Self { RelayManager { selected_relay: None, failed_relays: Vec::new() } } - pub fn set_selected_relay(&mut self, relay: url::Url) { self.selected_relay = Some(relay); } + pub fn set_selected_relay(&mut self, relay: Url) { self.selected_relay = Some(relay); } - pub fn get_selected_relay(&self) -> Option { self.selected_relay.clone() } + pub fn get_selected_relay(&self) -> Option { self.selected_relay.clone() } - pub fn add_failed_relay(&mut self, relay: url::Url) { self.failed_relays.push(relay); } + pub fn add_failed_relay(&mut self, relay: Url) { self.failed_relays.push(relay); } - pub fn get_failed_relays(&self) -> Vec { self.failed_relays.clone() } + pub fn get_failed_relays(&self) -> Vec { self.failed_relays.clone() } } pub(crate) struct ValidatedOhttpKeys { pub(crate) ohttp_keys: payjoin::OhttpKeys, - pub(crate) relay_url: url::Url, + pub(crate) relay_url: Url, } pub(crate) async fn unwrap_ohttp_keys_or_else_fetch( config: &Config, - directory: Option, + directory: Option, relay_manager: Arc>, ) -> Result { if let Some(ohttp_keys) = config.v2()?.ohttp_keys.clone() { @@ -48,7 +49,7 @@ pub(crate) async fn unwrap_ohttp_keys_or_else_fetch( async fn fetch_ohttp_keys( config: &Config, - directory: Option, + directory: Option, relay_manager: Arc>, ) -> Result { use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; diff --git a/payjoin-cli/src/cli/mod.rs b/payjoin-cli/src/cli/mod.rs index 1f35979b5..8b5e42fa7 100644 --- a/payjoin-cli/src/cli/mod.rs +++ b/payjoin-cli/src/cli/mod.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use clap::{value_parser, Parser, Subcommand}; use payjoin::bitcoin::amount::ParseAmountError; use payjoin::bitcoin::{Amount, FeeRate}; +use payjoin::Url; use serde::Deserialize; -use url::Url; #[derive(Debug, Clone, Deserialize, Parser)] pub struct Flags { @@ -114,13 +114,13 @@ pub enum Commands { #[cfg(feature = "v1")] /// The `pj=` endpoint to receive the payjoin request - #[arg(long = "pj-endpoint", value_parser = value_parser!(Url))] - pj_endpoint: Option, + #[arg(long = "pj-endpoint", value_parser = parse_boxed_url)] + pj_endpoint: Option>, #[cfg(feature = "v2")] /// The directory to store payjoin requests - #[arg(long = "pj-directory", value_parser = value_parser!(Url))] - pj_directory: Option, + #[arg(long = "pj-directory", value_parser = parse_boxed_url)] + pj_directory: Option>, #[cfg(feature = "v2")] /// The path to the ohttp keys file @@ -144,3 +144,7 @@ pub fn parse_fee_rate_in_sat_per_vb(s: &str) -> Result Result, String> { + s.parse::().map(Box::new).map_err(|e| e.to_string()) +} diff --git a/payjoin-directory/Cargo.toml b/payjoin-directory/Cargo.toml index 5b70dda3c..597e95881 100644 --- a/payjoin-directory/Cargo.toml +++ b/payjoin-directory/Cargo.toml @@ -20,7 +20,7 @@ acme = ["tokio-rustls-acme"] [dependencies] anyhow = "1.0.99" -bhttp = { version = "0.6.1", features = ["http"] } +bhttp = { version = "0.6.1" } bitcoin = { version = "0.32.7", features = ["base64", "rand-std"] } clap = { version = "4.5.45", features = ["derive", "env"] } config = "0.15.14" diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 662b619ec..8f417cc83 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -24,9 +24,9 @@ default = ["v2"] #[doc = "Core features for payjoin state machines"] _core = [ "bitcoin/rand-std", - "dep:http", + "http", "serde_json", - "url/serde", + "form_urlencoded", "bitcoin_uri", "serde", "bitcoin/serde", @@ -43,6 +43,7 @@ _test-utils = [] bhttp = { version = "0.6.1", optional = true } bitcoin = { version = "0.32.7", features = ["base64"] } bitcoin_uri = { version = "0.1.0", optional = true } +form_urlencoded = { version = "1.2.2", optional = true } hpke = { package = "bitcoin-hpke", version = "0.13.0", optional = true } http = { version = "1.3.1", optional = true } ohttp = { package = "bitcoin-ohttp", version = "0.6.0", optional = true } @@ -53,9 +54,6 @@ rustls = { version = "0.23.31", optional = true, default-features = false, featu serde = { version = "1.0.219", default-features = false, optional = true } serde_json = { version = "1.0.142", optional = true } tracing = "0.1.41" -url = { version = "2.5.4", optional = true, default-features = false, features = [ - "serde", -] } [target.'cfg(target_arch = "wasm32")'.dependencies] web-time = "1.1.0" diff --git a/payjoin/src/core/into_url.rs b/payjoin/src/core/into_url.rs index fc4537bf5..613982429 100644 --- a/payjoin/src/core/into_url.rs +++ b/payjoin/src/core/into_url.rs @@ -1,9 +1,9 @@ -use url::{ParseError, Url}; +use crate::core::{Url, UrlParseError}; #[derive(Debug, PartialEq, Eq)] pub enum Error { BadScheme, - ParseError(ParseError), + ParseError(UrlParseError), } impl std::fmt::Display for Error { @@ -19,8 +19,8 @@ impl std::fmt::Display for Error { impl std::error::Error for Error {} -impl From for Error { - fn from(err: ParseError) -> Error { Error::ParseError(err) } +impl From for Error { + fn from(err: UrlParseError) -> Error { Error::ParseError(err) } } type Result = core::result::Result; diff --git a/payjoin/src/core/io.rs b/payjoin/src/core/io.rs index 5a6e8c365..072c56707 100644 --- a/payjoin/src/core/io.rs +++ b/payjoin/src/core/io.rs @@ -23,7 +23,7 @@ pub async fn fetch_ohttp_keys( let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?; let client = Client::builder().proxy(proxy).http1_only().build()?; let res = client - .get(ohttp_keys_url) + .get(ohttp_keys_url.as_str()) .timeout(Duration::from_secs(10)) .header(ACCEPT, "application/ohttp-keys") .send() @@ -56,7 +56,7 @@ pub async fn fetch_ohttp_keys_with_cert( .http1_only() .build()?; let res = client - .get(ohttp_keys_url) + .get(ohttp_keys_url.as_str()) .timeout(Duration::from_secs(10)) .header(ACCEPT, "application/ohttp-keys") .send() @@ -98,8 +98,8 @@ enum InternalErrorInner { InvalidOhttpKeys(String), } -impl From for Error { - fn from(value: url::ParseError) -> Self { +impl From for Error { + fn from(value: crate::core::UrlParseError) -> Self { Self::Internal(InternalError(InternalErrorInner::ParseUrl(value.into()))) } } diff --git a/payjoin/src/core/mod.rs b/payjoin/src/core/mod.rs index 425bbae91..ec64e9963 100644 --- a/payjoin/src/core/mod.rs +++ b/payjoin/src/core/mod.rs @@ -16,6 +16,8 @@ pub mod send; pub use request::*; pub(crate) mod into_url; pub use into_url::{Error as IntoUrlError, IntoUrl}; +pub(crate) mod url; +pub use url::{ParseError as UrlParseError, Url}; #[cfg(feature = "v2")] pub mod time; pub mod uri; diff --git a/payjoin/src/core/ohttp.rs b/payjoin/src/core/ohttp.rs index 2036a9f5a..fcd12a1c5 100644 --- a/payjoin/src/core/ohttp.rs +++ b/payjoin/src/core/ohttp.rs @@ -23,8 +23,8 @@ pub(crate) fn ohttp_encapsulate( let mut ohttp_keys = ohttp_keys.clone(); let ctx = ohttp::ClientRequest::from_config(&mut ohttp_keys)?; - let url = url::Url::parse(target_resource)?; - let authority_bytes = url.host().map_or_else(Vec::new, |host| { + let url = crate::core::Url::parse(target_resource)?; + let authority_bytes = url.host_str().map_or_else(Vec::new, |host| { let mut authority = host.to_string(); if let Some(port) = url.port() { write!(authority, ":{port}").unwrap(); @@ -164,7 +164,7 @@ pub enum OhttpEncapsulationError { Http(http::Error), Ohttp(ohttp::Error), Bhttp(bhttp::Error), - ParseUrl(url::ParseError), + ParseUrl(crate::core::UrlParseError), } impl From for OhttpEncapsulationError { @@ -179,8 +179,8 @@ impl From for OhttpEncapsulationError { fn from(value: bhttp::Error) -> Self { Self::Bhttp(value) } } -impl From for OhttpEncapsulationError { - fn from(value: url::ParseError) -> Self { Self::ParseUrl(value) } +impl From for OhttpEncapsulationError { + fn from(value: crate::core::UrlParseError) -> Self { Self::ParseUrl(value) } } impl fmt::Display for OhttpEncapsulationError { diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 7f97a1fc0..be57e58ad 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -263,7 +263,7 @@ pub(crate) fn parse_payload( let psbt = unchecked_psbt.validate().map_err(InternalPayloadError::InconsistentPsbt)?; tracing::trace!("Received original psbt: {psbt:?}"); - let pairs = url::form_urlencoded::parse(query.as_bytes()); + let pairs = form_urlencoded::parse(query.as_bytes()); let params = Params::from_query_pairs(pairs, supported_versions) .map_err(InternalPayloadError::SenderParams)?; tracing::trace!("Received request with params: {params:?}"); @@ -507,7 +507,7 @@ pub(crate) mod tests { use crate::psbt::NON_WITNESS_INPUT_WEIGHT; pub(crate) fn original_from_test_vector() -> OriginalPayload { - let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); + let pairs = form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::One]) .expect("Could not parse params from query pairs"); OriginalPayload { psbt: PARSED_ORIGINAL_PSBT.clone(), params } diff --git a/payjoin/src/core/receive/optional_parameters.rs b/payjoin/src/core/receive/optional_parameters.rs index 0da46e00a..f0ca4208c 100644 --- a/payjoin/src/core/receive/optional_parameters.rs +++ b/payjoin/src/core/receive/optional_parameters.rs @@ -152,7 +152,7 @@ pub(crate) mod test { #[test] fn test_parse_params() { - let pairs = url::form_urlencoded::parse(b"&maxadditionalfeecontribution=182&additionalfeeoutputindex=0&minfeerate=2&disableoutputsubstitution=true&optimisticmerge=true"); + let pairs = form_urlencoded::parse(b"&maxadditionalfeecontribution=182&additionalfeeoutputindex=0&minfeerate=2&disableoutputsubstitution=true&optimisticmerge=true"); let params = Params::from_query_pairs(pairs, &[Version::One]) .expect("Could not parse params from query pairs"); assert_eq!(params.v, Version::One); diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 7c2e937c3..3f3d4d880 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -405,7 +405,7 @@ mod tests { } fn unchecked_proposal_from_test_vector() -> UncheckedOriginalPayload { - let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); + let pairs = form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::One]) .expect("Could not parse params from query pairs"); UncheckedOriginalPayload { @@ -414,7 +414,7 @@ mod tests { } fn maybe_inputs_owned_from_test_vector() -> MaybeInputsOwned { - let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); + let pairs = form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::One]) .expect("Could not parse params from query pairs"); MaybeInputsOwned { diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index ba49fe120..74884819c 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -39,7 +39,6 @@ pub use session::{ replay_event_log, replay_event_log_async, SessionEvent, SessionHistory, SessionOutcome, SessionStatus, }; -use url::Url; #[cfg(target_arch = "wasm32")] use web_time::Duration; @@ -47,6 +46,7 @@ use super::error::{Error, InputContributionError}; use super::{ common, InternalPayloadError, JsonReply, OutputSubstitutionError, ProtocolError, SelectionError, }; +use crate::core::Url; use crate::error::{InternalReplayError, ReplayError}; use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey}; use crate::ohttp::{ @@ -73,7 +73,7 @@ static TWENTY_FOUR_HOURS_DEFAULT_EXPIRATION: Duration = Duration::from_secs(60 * pub struct SessionContext { #[serde(deserialize_with = "deserialize_address_assume_checked")] address: Address, - directory: url::Url, + directory: Url, ohttp_keys: OhttpKeys, expiration: Time, amount: Option, @@ -1394,7 +1394,7 @@ pub mod test { }); pub(crate) fn unchecked_proposal_v2_from_test_vector() -> UncheckedOriginalPayload { - let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); + let pairs = form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::Two]) .expect("Test utils query params should not fail"); UncheckedOriginalPayload { @@ -1403,7 +1403,7 @@ pub mod test { } pub(crate) fn maybe_inputs_owned_v2_from_test_vector() -> MaybeInputsOwned { - let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); + let pairs = form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::Two]) .expect("Test utils query params should not fail"); MaybeInputsOwned { diff --git a/payjoin/src/core/request.rs b/payjoin/src/core/request.rs index b4db22acb..abe51b611 100644 --- a/payjoin/src/core/request.rs +++ b/payjoin/src/core/request.rs @@ -1,4 +1,4 @@ -use url::Url; +use crate::core::Url; #[cfg(feature = "v1")] const V1_REQ_CONTENT_TYPE: &str = "text/plain"; diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 276d928b4..5aa3521d8 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -20,8 +20,8 @@ use bitcoin::psbt::Psbt; use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; pub use error::{BuildSenderError, ResponseError, ValidationError, WellKnownError}; pub(crate) use error::{InternalBuildSenderError, InternalProposalError, InternalValidationError}; -use url::Url; +use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::{AddressTypeError, PsbtExt, NON_WITNESS_INPUT_WEIGHT}; use crate::Version; @@ -684,9 +684,9 @@ mod test { BoxError, PARSED_ORIGINAL_PSBT, PARSED_PAYJOIN_PROPOSAL, PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO, }; - use url::Url; use super::*; + use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::send::{AdditionalFeeContribution, InternalBuildSenderError, InternalProposalError}; diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index d2ea8f403..120c58a87 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -38,10 +38,10 @@ pub use session::{ replay_event_log, replay_event_log_async, SessionEvent, SessionHistory, SessionOutcome, SessionStatus, }; -use url::Url; use super::error::BuildSenderError; use super::*; +use crate::core::Url; use crate::error::{InternalReplayError, ReplayError}; use crate::hpke::{decrypt_message_b, encrypt_message_a, HpkeSecretKey}; use crate::ohttp::{ohttp_encapsulate, process_get_res, process_post_res}; diff --git a/payjoin/src/core/send/v2/session.rs b/payjoin/src/core/send/v2/session.rs index 1c78c7827..8888243bb 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -172,9 +172,9 @@ pub enum SessionOutcome { mod tests { use bitcoin::{FeeRate, ScriptBuf}; use payjoin_test_utils::{KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC}; - use url::Url; use super::*; + use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::persist::test_utils::{InMemoryAsyncTestPersister, InMemoryTestPersister}; use crate::persist::NoopSessionPersister; @@ -190,7 +190,7 @@ mod tests { fn test_sender_session_event_serialization_roundtrip() { let keypair = HpkeKeyPair::gen_keypair(); let id = crate::uri::ShortId::try_from(&b"12345670"[..]).expect("valid short id"); - let endpoint = url::Url::parse("http://localhost:1234").expect("valid url"); + let endpoint = Url::parse("http://localhost:1234").expect("valid url"); let expiration = Time::from_now(std::time::Duration::from_secs(60)).expect("expiration should be valid"); let pj_param = crate::uri::v2::PjParam::new( diff --git a/payjoin/src/core/uri/mod.rs b/payjoin/src/core/uri/mod.rs index 5c151dbdd..8c6e2a62e 100644 --- a/payjoin/src/core/uri/mod.rs +++ b/payjoin/src/core/uri/mod.rs @@ -49,7 +49,7 @@ impl PjParam { pub fn endpoint(&self) -> String { self.endpoint_url().to_string() } - pub(crate) fn endpoint_url(&self) -> url::Url { + pub(crate) fn endpoint_url(&self) -> crate::core::Url { match self { #[cfg(feature = "v1")] PjParam::V1(url) => url.endpoint(), @@ -70,7 +70,7 @@ impl std::fmt::Display for PjParam { .endpoint() .as_str() .replacen(scheme, &scheme.to_uppercase(), 1) - .replacen(host, &host.to_uppercase(), 1); + .replacen(&host, &host.to_uppercase(), 1); write!(f, "{endpoint_str}") } } diff --git a/payjoin/src/core/uri/v1.rs b/payjoin/src/core/uri/v1.rs index 702a671fb..b373d5b22 100644 --- a/payjoin/src/core/uri/v1.rs +++ b/payjoin/src/core/uri/v1.rs @@ -1,8 +1,7 @@ //! Payjoin v1 URI functionality -use url::Url; - use super::PjParseError; +use crate::core::Url; use crate::uri::error::InternalPjParseError; /// Payjoin v1 parameter containing the endpoint URL diff --git a/payjoin/src/core/uri/v2.rs b/payjoin/src/core/uri/v2.rs index 2346bd816..da97fa8b6 100644 --- a/payjoin/src/core/uri/v2.rs +++ b/payjoin/src/core/uri/v2.rs @@ -4,8 +4,8 @@ use std::collections::BTreeMap; use std::str::FromStr; use bitcoin::bech32::Hrp; -use url::Url; +use crate::core::Url; use crate::hpke::HpkePublicKey; use crate::ohttp::OhttpKeys; use crate::time::{ParseTimeError, Time}; diff --git a/payjoin/src/core/url.rs b/payjoin/src/core/url.rs new file mode 100644 index 000000000..2e731b3f8 --- /dev/null +++ b/payjoin/src/core/url.rs @@ -0,0 +1,531 @@ +use core::fmt; +use core::str::FromStr; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Url { + raw: String, + scheme: String, + cannot_be_a_base: bool, + username: String, + password: Option, + host: Option, + port: Option, + path: String, + query: Option, + fragment: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PathSegments<'a> { + segments: Vec<&'a str>, + index: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub enum Host { + Domain(String), + Ipv4([u8; 4]), + Ipv6([u16; 8]), +} + +impl<'a> Iterator for PathSegments<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option { + if self.index >= self.segments.len() { + None + } else { + let item = self.segments[self.index]; + self.index += 1; + Some(item) + } + } +} + +pub struct PathSegmentsMut<'a> { + url: &'a mut Url, +} + +impl<'a> PathSegmentsMut<'a> { + pub fn push(&mut self, segment: &str) { + if !self.url.path.ends_with('/') && !self.url.path.is_empty() { + self.url.path.push('/'); + } + self.url.path.push_str(segment); + } +} + +impl<'a> Drop for PathSegmentsMut<'a> { + fn drop(&mut self) { self.url.rebuild_raw(); } +} + +impl Url { + pub fn path_segments_mut(&mut self) -> Option> { + Some(PathSegmentsMut { url: self }) + } +} + +pub struct UrlQueryPairs<'a> { + url: &'a mut Url, +} + +impl<'a> UrlQueryPairs<'a> { + pub fn append_pair(&mut self, key: &str, value: &str) -> &mut UrlQueryPairs<'a> { + let new_pair = format!("{}={}", key, value); + if let Some(ref mut query) = self.url.query { + query.push_str(&format!("&{}", new_pair)); + } else { + self.url.query = Some(new_pair); + } + self.url.rebuild_raw(); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + EmptyHost, + InvalidScheme, + InvalidFormat, + InvalidPort, + InvalidCharacter, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParseError::EmptyHost => write!(f, "empty host"), + ParseError::InvalidScheme => write!(f, "invalid scheme"), + ParseError::InvalidFormat => write!(f, "invalid format"), + ParseError::InvalidPort => write!(f, "invalid port"), + ParseError::InvalidCharacter => write!(f, "invalid character"), + } + } +} + +impl std::error::Error for ParseError {} + +impl FromStr for Url { + type Err = ParseError; + + fn from_str(s: &str) -> Result { Self::parse(s) } +} + +impl Url { + pub fn parse(input: &str) -> Result { + let cannot_be_a_base = false; + let (rest, scheme) = parse_scheme(input)?; + let (_rest, host, port, path, query, fragment) = + if let Some(rest) = rest.strip_prefix("://") { + let (rest, host) = parse_host(rest)?; + let (rest, port) = parse_port(rest).unwrap_or((rest, None)); + let (path, query, fragment) = parse_path_query_fragment(rest); + + let is_empty_host = matches!(&host, Host::Domain(s) if s.is_empty()); + if is_empty_host && matches!(scheme.as_str(), "file" | "blob") { + let after_colon = &input[scheme.len() + 1..]; + let (path, query, fragment) = parse_path_query_fragment(after_colon); + (rest, None, None, path, query, fragment) + } else { + (rest, Some(host), port, path, query, fragment) + } + } else if let Some(rest) = rest.strip_prefix(":") { + let (path, query, fragment) = parse_path_query_fragment(rest); + (rest, None, None, path, query, fragment) + } else { + return Err(ParseError::InvalidFormat); + }; + + let host = match host { + Some(ref h) if h.is_empty() => + if matches!(scheme.as_str(), "file" | "blob") { + None + } else { + return Err(ParseError::EmptyHost); + }, + None if !matches!(scheme.as_str(), "file" | "blob") => { + return Err(ParseError::EmptyHost); + } + _ => host, + }; + + let path = if path.is_empty() { "/".to_string() } else { path }; + + let (username, password) = ("".to_string(), None); + + let mut url = Url { + raw: String::new(), + scheme, + cannot_be_a_base, + username, + password, + host, + port, + path, + query, + fragment, + }; + url.rebuild_raw(); + Ok(url) + } + + pub fn scheme(&self) -> &str { &self.scheme } + + pub fn has_host(&self) -> bool { self.host.is_some() } + + pub fn domain(&self) -> Option<&str> { + match &self.host { + Some(Host::Domain(s)) => Some(s.as_str()), + _ => None, + } + } + + pub fn host_str(&self) -> Option { + match &self.host { + Some(Host::Domain(d)) => Some(d.clone()), + Some(Host::Ipv4(octets)) => + Some(format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3])), + _ => None, + } + } + + pub fn port(&self) -> Option { self.port } + + pub fn set_port(&mut self, port: Option) { + self.port = port; + self.rebuild_raw(); + } + + pub fn path(&self) -> &str { &self.path } + + pub fn fragment(&self) -> Option<&str> { self.fragment.as_deref() } + + pub fn set_fragment(&mut self, fragment: Option<&str>) { + self.fragment = fragment.map(|s| s.to_string()); + self.rebuild_raw(); + } + + pub fn path_segments(&self) -> Option> { + if self.path.is_empty() || self.path == "/" { + return Some(PathSegments { segments: vec![], index: 0 }); + } + let segments: Vec<&str> = self.path.trim_start_matches('/').split('/').collect(); + Some(PathSegments { segments, index: 0 }) + } + + pub fn query(&self) -> Option<&str> { self.query.as_deref() } + + pub fn set_query(&mut self, query: Option<&str>) { + self.query = query.map(|s| s.to_string()); + self.rebuild_raw(); + } + + pub fn query_pairs_mut(&mut self) -> UrlQueryPairs<'_> { UrlQueryPairs { url: self } } + + pub fn join(&self, segment: &str) -> Result { + // If the segment is a full URL (scheme://...), parse it independently. + // Only treat it as a full URL if :// appears before any / (i.e. in scheme position). + if let Some(pos) = segment.find("://") { + if !segment[..pos].contains('/') { + return Url::parse(segment); + } + } + + let mut new_url = self.clone(); + + if segment.starts_with('/') { + // Absolute path reference: replace entire path, clear query/fragment + new_url.path = segment.to_string(); + new_url.query = None; + new_url.fragment = None; + } else { + // Relative reference: merge per RFC 3986 + // Remove everything after the last '/' in the base path, then append segment + let base_path = + if let Some(pos) = new_url.path.rfind('/') { &new_url.path[..=pos] } else { "/" }; + let merged = format!("{}{}", base_path, segment); + + // Resolve dot segments + let mut output_segments: Vec<&str> = Vec::new(); + for part in merged.split('/') { + match part { + "." => {} + ".." => { + output_segments.pop(); + } + _ => output_segments.push(part), + } + } + new_url.path = output_segments.join("/"); + if !new_url.path.starts_with('/') { + new_url.path.insert(0, '/'); + } + new_url.query = None; + new_url.fragment = None; + } + + new_url.rebuild_raw(); + Ok(new_url) + } + + fn rebuild_raw(&mut self) { + let mut raw = String::new(); + raw.push_str(&self.scheme); + + if self.host.is_some() { + raw.push_str("://"); + if !self.username.is_empty() || self.password.is_some() { + raw.push_str(&self.username); + if let Some(ref pw) = self.password { + raw.push(':'); + raw.push_str(pw); + } + raw.push('@'); + } + if let Some(ref host) = self.host { + match host { + Host::Domain(s) => raw.push_str(s), + Host::Ipv4(octets) => { + raw.push_str(&format!( + "{}.{}.{}.{}", + octets[0], octets[1], octets[2], octets[3] + )); + } + Host::Ipv6(segments) => { + raw.push('['); + raw.push_str( + &segments + .iter() + .map(|s| format!("{:x}", s)) + .collect::>() + .join(":"), + ); + raw.push(']'); + } + } + } + if let Some(port) = self.port { + raw.push(':'); + raw.push_str(&port.to_string()); + } + } else { + raw.push(':'); + } + + raw.push_str(&self.path); + if let Some(ref query) = self.query { + raw.push('?'); + raw.push_str(query); + } + if let Some(ref fragment) = self.fragment { + raw.push('#'); + raw.push_str(fragment); + } + self.raw = raw; + } +} + +impl Host { + fn is_empty(&self) -> bool { + match self { + Host::Domain(s) => s.is_empty(), + Host::Ipv4(_) => false, + Host::Ipv6(_) => false, + } + } +} + +impl AsRef for Url { + fn as_ref(&self) -> &str { &self.raw } +} + +impl Url { + pub fn as_str(&self) -> &str { &self.raw } +} + +impl fmt::Display for Url { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.raw) } +} + +fn parse_scheme(input: &str) -> Result<(&str, String), ParseError> { + let chars = input.chars(); + let mut scheme = String::new(); + + for c in chars { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '+' | '-' | '.' => { + scheme.push(c); + } + ':' => break, + _ => return Err(ParseError::InvalidCharacter), + } + } + + if scheme.is_empty() { + return Err(ParseError::InvalidScheme); + } + + let scheme = scheme.to_lowercase(); + Ok((&input[scheme.len()..], scheme)) +} + +fn parse_host(input: &str) -> Result<(&str, Host), ParseError> { + // Split at the first ':', '/', '?', or '#' to separate host from port/path/query/fragment + let mut chars = input.char_indices(); + let mut end = input.len(); + for (i, c) in &mut chars { + if c == ':' || c == '/' || c == '?' || c == '#' { + end = i; + break; + } + } + let host_str = &input[..end]; + let rest = &input[end..]; + Ok((rest, Host::Domain(host_str.to_string()))) +} + +fn parse_port(input: &str) -> Result<(&str, Option), ParseError> { + if !input.starts_with(':') { + return Ok((input, None)); + } + + let rest = &input[1..]; + let mut port_str = String::new(); + + for c in rest.chars() { + match c { + '0'..='9' => port_str.push(c), + '/' | '?' | '#' => break, + _ => return Err(ParseError::InvalidPort), + } + } + + if port_str.is_empty() { + return Ok((rest, None)); + } + + let port: u16 = port_str.parse().map_err(|_| ParseError::InvalidPort)?; + let remaining = &rest[port_str.len()..]; + Ok((remaining, Some(port))) +} + +fn parse_path_query_fragment(input: &str) -> (String, Option, Option) { + let mut path = String::new(); + let mut query: Option = None; + let mut fragment: Option = None; + + if let Some(frag_pos) = input.find('#') { + let before_fragment = &input[..frag_pos]; + fragment = Some(input[frag_pos + 1..].to_string()); + + if let Some(q_pos) = before_fragment.find('?') { + path.push_str(&before_fragment[..q_pos]); + let q_part = &before_fragment[q_pos + 1..]; + if let Some(f_pos) = q_part.find('#') { + query = Some(q_part[..f_pos].to_string()); + fragment = Some(q_part[f_pos + 1..].to_string()); + } else { + query = Some(q_part.to_string()); + } + } else { + path.push_str(before_fragment); + } + } else if let Some(q_pos) = input.find('?') { + path.push_str(&input[..q_pos]); + query = Some(input[q_pos + 1..].to_string()); + } else { + path.push_str(input); + } + + (path, query, fragment) +} + +impl serde::Serialize for Url { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.raw) + } +} + +impl<'de> serde::Deserialize<'de> for Url { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Url::from_str(&s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_basic_url() { + let url = Url::parse("http://example.com").unwrap(); + assert_eq!(url.scheme(), "http"); + assert_eq!(url.domain(), Some("example.com")); + assert!(url.has_host()); + } + + #[test] + fn test_parse_url_with_path() { + let url = Url::parse("https://example.com/path/to/resource").unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.domain(), Some("example.com")); + assert_eq!( + url.path_segments().map(|s| s.collect::>()), + Some(vec!["path", "to", "resource"]) + ); + } + + #[test] + fn test_parse_url_with_port() { + let url = Url::parse("http://localhost:8080/path").unwrap(); + assert_eq!(url.scheme(), "http"); + assert_eq!(url.domain(), Some("localhost")); + } + + #[test] + fn test_fragment() { + let url = Url::parse("https://example.com/path#fragment").unwrap(); + assert_eq!(url.fragment(), Some("fragment")); + } + + #[test] + fn test_set_fragment() { + let mut url = Url::parse("https://example.com/path").unwrap(); + url.set_fragment(Some("newfragment")); + assert_eq!(url.fragment(), Some("newfragment")); + assert!(url.as_ref().contains("#newfragment")); + } + + #[test] + fn test_join() { + let base = Url::parse("http://example.com/base/").unwrap(); + let joined = base.join("next").unwrap(); + assert_eq!(joined.path, "/base/next"); + } + + #[test] + fn test_as_str() { + let url = Url::parse("http://example.com").unwrap(); + assert_eq!(url.as_str(), "http://example.com/"); + } + + #[test] + fn test_parse_url_with_port_and_fragment() { + let input = "http://localhost:1234/PATH#FRAGMENT"; + let url = Url::parse(input).unwrap(); + assert_eq!(url.scheme(), "http"); + assert_eq!(url.domain(), Some("localhost")); + assert_eq!(url.port(), Some(1234)); + assert_eq!(url.path(), "/PATH"); + assert_eq!(url.fragment(), Some("FRAGMENT")); + assert_eq!(url.as_str(), "http://localhost:1234/PATH#FRAGMENT"); + } +} diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 0936319c9..13d98669d 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -9,7 +9,7 @@ mod integration { use bitcoin::{Amount, FeeRate, OutPoint, TxIn, TxOut, Weight}; use payjoin::receive::v1::build_v1_pj_uri; use payjoin::receive::InputPair; - use payjoin::{ImplementationError, OutputSubstitution, PjUri, Request, Uri}; + use payjoin::{ImplementationError, OutputSubstitution, PjUri, Request, Uri, Url}; use payjoin_test_utils::corepc_node::vtype::ListUnspentItem; use payjoin_test_utils::corepc_node::AddressType; use payjoin_test_utils::{corepc_node, init_bitcoind_sender_receiver, init_tracing, BoxError}; @@ -1487,7 +1487,7 @@ mod integration { // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) let proposal = payjoin::receive::v1::UncheckedOriginalPayload::from_request( req.body.as_slice(), - url::Url::from_str(&req.url).expect("Could not parse url").query().unwrap_or(""), + Url::from_str(&req.url).expect("Could not parse url").query().unwrap_or(""), headers, )?; let proposal =