diff --git a/src/de.rs b/src/de.rs index 9a44b77..2970dde 100644 --- a/src/de.rs +++ b/src/de.rs @@ -19,7 +19,29 @@ use percent_encoding_rfc3986::PercentDecodeError; impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T> { /// Implements deserialization. + /// + /// Fails immediately on an unclaimed `req-` parameter, per BIP21. fn deserialize_raw(string: &'a str) -> Result> { + Self::deserialize_raw_impl(string, false) + } + + /// Like [`Self::deserialize_raw`], but does not fail on an unclaimed + /// `req-` parameter. Instead its key is recorded and made available + /// afterward through [`Uri::unsatisfied_requirements`]. + /// + /// This is a deliberately separate, opt-in entry point: the ordinary + /// `FromStr`/`TryFrom<&str>` parse path (via [`Self::deserialize_raw`]) + /// keeps rejecting an unclaimed `req-` parameter outright, unchanged. + fn deserialize_raw_permissive(string: &'a str) -> Result> { + Self::deserialize_raw_impl(string, true) + } + + /// Shared implementation for [`Self::deserialize_raw`] and + /// [`Self::deserialize_raw_permissive`]. `permissive` selects what + /// happens to an unclaimed `req-` parameter: `false` fails immediately + /// (today's behavior, unchanged), `true` records its key into + /// `unsatisfied_requirements` and keeps parsing. + fn deserialize_raw_impl(string: &'a str, permissive: bool) -> Result> { const SCHEME: &str = "bitcoin:"; if string.len() < SCHEME.len() { return Err(Error::Uri(UriError(UriErrorInner::TooShort))); @@ -41,6 +63,7 @@ impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T let mut amount = None; let mut label = None; let mut message = None; + let mut unsatisfied_requirements = alloc::vec::Vec::new(); if let Some(params) = params { // [RFC 3986 ยง 3.4](https://www.rfc-editor.org/rfc/rfc3986#section-3.4): // @@ -84,7 +107,11 @@ impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T let decoder = Param::decode(value).map_err(Error::percent_decode(key))?; let is_known = deserializer.deserialize_borrowed(extra_key, decoder).map_err(Error::Extras)?; if is_known == ParamKind::Unknown && extra_key.starts_with("req-") { - return Err(Error::Uri(UriError(UriErrorInner::UnknownRequiredParameter(extra_key.to_owned())))); + if permissive { + unsatisfied_requirements.push(extra_key.to_owned()); + } else { + return Err(Error::Uri(UriError(UriErrorInner::UnknownRequiredParameter(extra_key.to_owned())))); + } } }, } @@ -98,6 +125,7 @@ impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T label, message, extras, + unsatisfied_requirements, }) } } @@ -113,6 +141,7 @@ impl Uri<'_, NetVal, T> { label: self.label.map(|label| label.decode_into_owned()), message: self.message.map(|message| message.decode_into_owned()), extras: self.extras, + unsatisfied_requirements: self.unsatisfied_requirements, } } } @@ -322,6 +351,34 @@ impl<'a, T: DeserializeParams<'a>> TryFrom<&'a str> for Uri<'a, bitcoin::address } } +impl DeserializeParams<'de>> Uri<'_, bitcoin::address::NetworkUnchecked, T> { + /// Parses like `s.parse::>()`, except an unclaimed + /// `req-` parameter does not fail parsing. + /// + /// Its key is recorded instead, and can be read back afterward with + /// [`Uri::unsatisfied_requirements`]. This lets a caller decide for + /// itself, after seeing what was actually unsatisfied, whether to + /// accept the URI anyway - for example when the caller understands a + /// requirement that `T`'s `Extras` implementation does not claim, or + /// wants to surface the specific unmet requirement(s) to a user instead + /// of a generic parse failure. + /// + /// **Warning**: like `FromStr`, this may needlessly allocate; consider + /// [`Uri::try_parse_permissive`] for zero-copy parsing instead. + pub fn parse_permissive(s: &str) -> Result, Error> { + Uri::deserialize_raw_permissive(s).map(Uri::into_static) + } +} + +impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T> { + /// Zero-copy counterpart of [`Uri::parse_permissive`]. + /// + /// See [`Uri::parse_permissive`] for what "permissive" means here. + pub fn try_parse_permissive(s: &'a str) -> Result> { + Self::deserialize_raw_permissive(s) + } +} + /// **Warning**: this implementation may needlessly allocate, consider using `TryFrom<&str>` instead. impl DeserializeParams<'de>> TryFrom for Uri<'_, bitcoin::address::NetworkUnchecked, T> { type Error = Error; @@ -355,6 +412,7 @@ impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T label: self.label, message: self.message, extras: self.extras, + unsatisfied_requirements: self.unsatisfied_requirements, }) } @@ -366,6 +424,7 @@ impl<'a, T: DeserializeParams<'a>> Uri<'a, bitcoin::address::NetworkUnchecked, T label: self.label, message: self.message, extras: self.extras, + unsatisfied_requirements: self.unsatisfied_requirements, } } } diff --git a/src/lib.rs b/src/lib.rs index 7a37b7e..265faf1 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,6 +100,16 @@ where /// Extra fields that can occur in a BIP21 URI. pub extras: Extras, + + /// `req-` parameter keys that were present in the URI but not claimed + /// by `extras`. + /// + /// This is only ever non-empty on a `Uri` produced by + /// [`Uri::parse_permissive`]. The ordinary `FromStr`/`TryFrom<&str>` + /// parse path rejects an unclaimed `req-` parameter outright (per + /// BIP21), so a `Uri` obtained that way never has one left to report + /// here. + unsatisfied_requirements: alloc::vec::Vec, } impl Uri<'_, NetVal, T> { @@ -114,6 +124,7 @@ impl Uri<'_, NetVal, T> { label: None, message: None, extras: Default::default(), + unsatisfied_requirements: alloc::vec::Vec::new(), } } } @@ -130,8 +141,20 @@ impl Uri<'_, NetVal, T> { label: None, message: None, extras, + unsatisfied_requirements: alloc::vec::Vec::new(), } } + + /// Returns the `req-` parameter keys that were present in the URI but + /// not claimed by `extras`. + /// + /// Always empty unless this `Uri` was produced by + /// [`Uri::parse_permissive`]: parsing via `FromStr`/`TryFrom<&str>` + /// already fails the moment an unclaimed `req-` parameter is seen, so + /// there is never one left to report through this method. + pub fn unsatisfied_requirements(&self) -> &[String] { + &self.unsatisfied_requirements + } } /// Abstracted stringly parameter in the URI. @@ -503,4 +526,95 @@ mod tests { let uri = duplicate_message.parse::>(); assert!(uri.is_err()); } + + #[test] + fn ordinary_parse_never_reports_unsatisfied_requirements() { + // `FromStr`/`TryFrom<&str>` still fail immediately on an unclaimed + // `req-`, so a `Uri` obtained that way never has one to report - + // `unsatisfied_requirements()` stays empty for every already-passing + // parse, e.g. one with no `req-` params at all. + let input = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?label=Luke-Jr"; + let uri = input.parse::>().unwrap().require_network(bitcoin::Network::Bitcoin).unwrap(); + assert!(uri.unsatisfied_requirements().is_empty()); + } + + #[test] + fn parse_permissive_records_an_unclaimed_req_key_instead_of_failing() { + let input = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?req-somethingyoudontunderstand=50"; + let uri = Uri::<'_, _>::parse_permissive(input) + .unwrap() + .require_network(bitcoin::Network::Bitcoin) + .unwrap(); + assert_eq!(uri.unsatisfied_requirements(), ["req-somethingyoudontunderstand"]); + } + + #[allow(clippy::inconsistent_digit_grouping)] // Use sats/bitcoin when grouping. + #[test] + fn parse_permissive_still_parses_normally_when_nothing_is_unsatisfied() { + let input = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?amount=20.3&label=Luke-Jr"; + let uri = Uri::<'_, _>::parse_permissive(input) + .unwrap() + .require_network(bitcoin::Network::Bitcoin) + .unwrap(); + assert!(uri.unsatisfied_requirements().is_empty()); + assert_eq!(uri.amount, Some(bitcoin::Amount::from_sat(20_30_000_000))); + } + + #[test] + fn try_parse_permissive_is_zero_copy_and_records_unclaimed_req_keys() { + let input = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?req-somethingyoudontunderstand=50"; + let uri = Uri::<'_, _>::try_parse_permissive(input).unwrap(); + assert_eq!(uri.unsatisfied_requirements(), ["req-somethingyoudontunderstand"]); + } + + /// A minimal `Extras` that claims exactly one `req-` key, to check that + /// claiming one `req-` key does not excuse an unrelated unclaimed one: + /// only the unclaimed one should show up in `unsatisfied_requirements`. + #[derive(Debug, Default, Clone)] + struct ReqFooExtras; + + #[derive(Debug, Default, Clone)] + struct ReqFooState; + + impl crate::de::DeserializationError for ReqFooExtras { + type Error = core::convert::Infallible; + } + + impl crate::de::DeserializeParams<'_> for ReqFooExtras { + type DeserializationState = ReqFooState; + } + + impl crate::de::DeserializationState<'_> for ReqFooState { + type Value = ReqFooExtras; + + fn is_param_known(&self, key: &str) -> bool { + key == "req-foo" + } + + fn deserialize_temp(&mut self, key: &str, _value: crate::Param<'_>) -> Result { + Ok(if key == "req-foo" { + crate::de::ParamKind::Known + } else { + crate::de::ParamKind::Unknown + }) + } + + fn finalize(self) -> Result { + Ok(ReqFooExtras) + } + } + + #[test] + fn parse_permissive_records_only_unclaimed_req_keys_not_claimed_ones() { + let input = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?req-foo=50&req-bar=1"; + let uri = Uri::<'_, _, ReqFooExtras>::parse_permissive(input) + .unwrap() + .require_network(bitcoin::Network::Bitcoin) + .unwrap(); + assert_eq!(uri.unsatisfied_requirements(), ["req-bar"]); + + // The strict path still fails on this same input. + let strict = input.parse::>(); + assert!(strict.is_err()); + } }