diff --git a/mullvad-relay-selector/src/relay_selector/mod.rs b/mullvad-relay-selector/src/relay_selector/mod.rs index bb98c6ad2a84..7cec9f05cb70 100644 --- a/mullvad-relay-selector/src/relay_selector/mod.rs +++ b/mullvad-relay-selector/src/relay_selector/mod.rs @@ -38,6 +38,7 @@ use mullvad_types::{ }; use std::{ borrow::Borrow, + ops::RangeInclusive, sync::{Arc, LazyLock, Mutex, RwLock}, }; use std::{net::IpAddr, ops::Deref}; @@ -881,7 +882,7 @@ impl RelaySelector { fn criteria(&self, predicate: Predicate) -> Vec> { match predicate { Predicate::Singlehop(constraints) => { - let mut singlehop_criteria = self.entry_criteria(constraints.clone()); + let entry_criteria = self.entry_criteria(constraints.clone()); let ownership = Criteria::new(move |relay| { matcher::filter_on_ownership(constraints.general.ownership.as_ref(), relay) @@ -894,8 +895,7 @@ impl RelaySelector { let active = Criteria::new(|relay: &WireguardRelay| relay.active.if_false(Reason::Inactive)); let location = self.location_criteria(constraints.general.location); - singlehop_criteria.extend([active, location, ownership, providers]); - singlehop_criteria + vec![entry_criteria, active, location, ownership, providers] } Predicate::Autohop(constraints) => { // This case is identical to `singlehop`, except that it does not generally care about obfuscation, DAITA, etc. @@ -955,7 +955,7 @@ impl RelaySelector { }; // Check criteria that apply specifically to entries - let can_be_used_as_entry = Criteria::flatten(self.entry_criteria(constraints)); + let can_be_used_as_entry = self.entry_criteria(constraints); let criteria = can_be_used_as_exit.and( // The relay must also be a valid entry. @@ -993,7 +993,7 @@ impl RelaySelector { // Except for the `can_be_used_as_exit` condition, the remainder of the work is // ~equiv to `Predicate::Singlehop`. - let mut criteria = self.entry_criteria(entry.clone()); + let criteria = self.entry_criteria(entry.clone()); let ownership = Criteria::new(move |relay| { matcher::filter_on_ownership(entry.general.ownership.as_ref(), relay) .if_false(Reason::Ownership) @@ -1006,8 +1006,14 @@ impl RelaySelector { let active = Criteria::new(|relay: &WireguardRelay| relay.active.if_false(Reason::Inactive)); let location = self.location_criteria(entry.general.location); - criteria.extend([active, location, can_be_used_as_entry, ownership, providers]); - criteria + vec![ + criteria, + active, + location, + can_be_used_as_entry, + ownership, + providers, + ] } Predicate::Exit(MultihopConstraints { entry, exit }) => { // If an entry is already selected, it should be rejected as a possible exit relay. @@ -1059,147 +1065,35 @@ impl RelaySelector { } /// All criteria that apply for specifically for entry relays. - fn entry_criteria(&self, constraints: EntryConstraints) -> Vec> { - let wg_endpoint_ip_version = - Criteria::new(move |relay: &WireguardRelay| match constraints.ip_version { + /// + /// Here we have to consider extra entry constraints, such as DAITA, obfuscation etc. + fn entry_criteria(&self, constraints: EntryConstraints) -> Criteria<'_, WireguardRelay> { + let daita_on = constraints.daita.as_ref().map(|settings| settings.enabled); + let daita = Criteria::new(move |relay| { + matcher::filter_on_daita(daita_on, relay).if_false(Reason::Daita) + }); + + let shadowsocks_port_ranges = + self.relay_list(|rl| rl.wireguard.shadowsocks_port_ranges.clone()); + + let obfuscation_ipversion_port = Criteria::new(move |relay: &WireguardRelay| { + let wg_endpoint_ip_version = match constraints.ip_version { Constraint::Any => Verdict::Accept, Constraint::Only(IpVersion::V4) => Verdict::Accept, Constraint::Only(IpVersion::V6) => { relay.ipv6_addr_in.is_some().if_false(Reason::IpVersion) } - }); + }; - // Here we have to consider extra entry constraints, such as DAITA, obfuscation etc. - let constraints_clone = constraints.clone(); - let obfuscation_ipversion_port = Criteria::new(move |relay: &WireguardRelay| { - match self.obfuscation_criteria(relay, &constraints_clone) { - ObfuscationVerdict::AcceptWireguardEndpoint => wg_endpoint_ip_version.eval(relay), - ObfuscationVerdict::AcceptSeparateEndpoint => Verdict::Accept, + match obfuscation_criteria(&shadowsocks_port_ranges, relay, &constraints) { + ObfuscationVerdict::AcceptWireguardEndpoint => wg_endpoint_ip_version, + ObfuscationVerdict::AcceptObfuscationEndpoint => Verdict::Accept, ObfuscationVerdict::Reject(reason) => { - Verdict::reject(reason).and(wg_endpoint_ip_version.eval(relay)) + Verdict::reject(reason).and(wg_endpoint_ip_version) } } }); - - let daita = Criteria::new(move |relay| { - let daita_on = constraints.daita.as_ref().map(|settings| settings.enabled); - matcher::filter_on_daita(daita_on, relay).if_false(Reason::Daita) - }); - - vec![daita, obfuscation_ipversion_port] - } - - fn obfuscation_criteria( - &self, - relay: &WireguardRelay, - EntryConstraints { - obfuscation_settings, - ip_version, - .. - }: &EntryConstraints, - ) -> ObfuscationVerdict { - /// Returns `Ok(())` if any IP in `ip_list` matches `requested_ip_version`, - /// or `Err(Some(ip_version))` if switching to `ip_version` would yield a match (`Err(None)` otherwise). - fn any_ip_matches_version( - requested_ip_version: &Constraint, - ip_list: impl IntoIterator>, - ) -> Result<(), Option> { - let (has_ipv4, has_ipv6) = - ip_list.into_iter().fold((false, false), |(v4, v6), addr| { - (v4 || addr.borrow().is_ipv4(), v6 || addr.borrow().is_ipv6()) - }); - match requested_ip_version { - Constraint::Any if has_ipv4 || has_ipv6 => Ok(()), - Constraint::Only(IpVersion::V4) if has_ipv4 => Ok(()), - Constraint::Only(IpVersion::V6) if has_ipv6 => Ok(()), - // No match — report whether the *other* IP version is available. - Constraint::Any => Err(None), - Constraint::Only(IpVersion::V4) => Err(Some(IpAvailability::Ipv6)), - Constraint::Only(IpVersion::V6) => Err(Some(IpAvailability::Ipv4)), - } - } - - use ObfuscationVerdict::*; - match obfuscation_settings { - // Possible edge case that we have not implemented: - // - User has set IPv6=only and anti-censorship=auto - // - A relay doesn't have an IPv6 for its wg endpoint, but it does have an IPv6 extra shadowsocks addr. - // In this scenario, we could conceivably allow the relay by enabling shadowsocks to resolve the IP constraint. - // This would negatively affect the performance of the connection, so we have chosen to discard the relay for now. - Constraint::Any => AcceptWireguardEndpoint, - Constraint::Only(settings) => { - use mullvad_types::relay_constraints::SelectedObfuscation::*; - match settings.selected_obfuscation { - Shadowsocks => { - // The relay may have IPs specifically meant for shadowsocks, - // which we want to use if possible. - let ss_extra_addrs = &relay.endpoint().shadowsocks_extra_addr_in; - // Check if any of them matches the requested IP version. - match any_ip_matches_version(ip_version, ss_extra_addrs) { - Ok(()) => AcceptSeparateEndpoint, - // Otherwise, we must fall back to using the WireGuard endpoint. - Err(other_ip_matches) => { - // A few ports on the wg endpoint are dedicated to shadowsocks. - // If a specific port is requested and it lies outside this range, - // then we cannot resolve the constraint. - let cannot_use_wg_endpoint = - settings.shadowsocks.port.is_only_and(|port| { - !self.relay_list(|rl| { - rl.wireguard - .shadowsocks_port_ranges - .iter() - .any(|range| range.contains(&port)) - }) - }); - match (cannot_use_wg_endpoint, other_ip_matches) { - (false, None | Some(_)) => { - // Port is usable on WireGuard endpoint, so fall back to it - AcceptWireguardEndpoint - } - (true, Some(_)) => { - // Switching IP version would unblock the relay. - // Note that the relay could also be unblocked by removing the port constraint - // so that a normal WireGuard endpoint can be used IFF that endpoint - // is available with the requested IP version. We cannot represent this, so we - // opt to only inform the user about the IP version. - Reject(Reason::IpVersion) - } - (true, None) => { - // No extra addresses are available at all, the the port must be changed - // so that a Wireguard endpoint can be used. This endpoint must - // then also be available with the requested IP version. - Reject(Reason::Port) - } - } - } - } - } - Quic => { - // TODO: Refactor using `if-let guards` once 1.95 is stable. - let Some(quic) = relay.endpoint().quic() else { - // QUIC is disabled - return Reject(Reason::Obfuscation); - }; - match any_ip_matches_version(ip_version, quic.in_addr()) { - Ok(()) => AcceptSeparateEndpoint, - // Switching IP version would unblock the relay. - Err(Some(_)) => Reject(Reason::IpVersion), - // The relay has quic but no IPv4 or IPv6 addresses to use it. - // This scenario should be unreachable, but treat it as if obfuscation was - // unavailable just in case. - Err(None) => Reject(Reason::Obfuscation), - } - } - // LWO is only enabled on some relays - Lwo if relay.endpoint().lwo => AcceptWireguardEndpoint, - Lwo => Reject(Reason::Obfuscation), - // Other relays are always valid - // TODO:^ This might not be true. We might want to consider the selected port for - // udp2tcp & wireguard port .. - Off | Auto | WireguardPort | Udp2Tcp => AcceptWireguardEndpoint, - } - } - } + daita.and(obfuscation_ipversion_port) } fn location_criteria( @@ -1217,12 +1111,125 @@ impl RelaySelector { } } +/// Verdict for connecting using an obfuscation method. enum ObfuscationVerdict { + /// Connect to the relay's "normal" WireGuard IP address. AcceptWireguardEndpoint, - AcceptSeparateEndpoint, + /// Connect to the relay using an IP address dedicated to + /// this obfuscation method. + AcceptObfuscationEndpoint, + /// The requested obfuscation cannot be resolved on the relay + /// with the given port or IP version. Reject(Reason), } +fn obfuscation_criteria( + shadowsocks_port_ranges: &[RangeInclusive], + relay: &WireguardRelay, + EntryConstraints { + obfuscation_settings, + ip_version, + .. + }: &EntryConstraints, +) -> ObfuscationVerdict { + /// Whether the requested IP version (IPv4/IPv6) matches any of the given addresses. + enum IpVersionMatch { + Ok, + /// No IP matches the request version, but some does match the _other_ version. + Other, + /// No IP matches any version, i.e. the list of IP addresses was empty. + None, + } + fn any_ip_matches_version( + requested_ip_version: &Constraint, + ip_list: impl IntoIterator>, + ) -> IpVersionMatch { + let (has_ipv4, has_ipv6) = ip_list.into_iter().fold((false, false), |(v4, v6), addr| { + (v4 || addr.borrow().is_ipv4(), v6 || addr.borrow().is_ipv6()) + }); + match requested_ip_version { + Constraint::Any if has_ipv4 || has_ipv6 => IpVersionMatch::Ok, + Constraint::Only(IpVersion::V4) if has_ipv4 => IpVersionMatch::Ok, + Constraint::Only(IpVersion::V6) if has_ipv6 => IpVersionMatch::Ok, + Constraint::Only(IpVersion::V4) if has_ipv6 => IpVersionMatch::Other, + Constraint::Only(IpVersion::V6) if has_ipv4 => IpVersionMatch::Other, + _ => IpVersionMatch::None, + } + } + + use ObfuscationVerdict::*; + match obfuscation_settings { + // Possible edge case that we have not implemented: + // - User has set IPv6=only and anti-censorship=auto + // - A relay doesn't have an IPv6 for its wg endpoint, but it does have an IPv6 extra shadowsocks addr. + // In this scenario, we could conceivably allow the relay by enabling shadowsocks to resolve the IP constraint. + // This would negatively affect the performance of the connection, so we have chosen to discard the relay for now. + Constraint::Any => AcceptWireguardEndpoint, + Constraint::Only(settings) => { + use mullvad_types::relay_constraints::SelectedObfuscation::*; + match settings.selected_obfuscation { + Shadowsocks => { + // The relay may have IPs specifically meant for shadowsocks. + // Use them if they match the requested IP version. + match any_ip_matches_version( + ip_version, + &relay.endpoint().shadowsocks_extra_addr_in, + ) { + IpVersionMatch::Ok => AcceptObfuscationEndpoint, + // Check if we can fall back to using the WireGuard endpoint instead. + // A few port ranges on it are dedicated to shadowsocks. If a specific port + // is requested it must lie within these ranges. + _ if settings.shadowsocks.port.is_any_or(|port| { + shadowsocks_port_ranges + .iter() + .any(|range| range.contains(&port)) + }) => + { + AcceptWireguardEndpoint + } + // -- We cannot resolve the relay on any endpoint, so reject it -- + + // Switching IP version would unblock the relay, so give that as the reject reason. + // Note that the relay could also be unblocked by removing the port constraint + // so that a normal WireGuard endpoint can be used IFF that endpoint + // is available with the requested IP version. We cannot represent this, so we + // opt to only inform the user about the IP version. + IpVersionMatch::Other => Reject(Reason::IpVersion), + // No extra addresses are available at all, the port must be changed + // so that a Wireguard endpoint can be used. This endpoint must + // then also be available with the requested IP version, which + // is checked for outside this function. + IpVersionMatch::None => Reject(Reason::Port), + } + } + Quic => { + // TODO: Refactor using `if-let guards` once 1.95 is stable. + let Some(quic) = relay.endpoint().quic() else { + // QUIC is disabled + return Reject(Reason::Obfuscation); + }; + match any_ip_matches_version(ip_version, quic.in_addr()) { + IpVersionMatch::Ok => AcceptObfuscationEndpoint, + // Switching IP version would unblock the relay. + IpVersionMatch::Other => Reject(Reason::IpVersion), + // The relay has quic but no IPv4 or IPv6 addresses to use it. + // This scenario should be unreachable, but treat it as if obfuscation was + // unavailable just in case. + IpVersionMatch::None => Reject(Reason::Obfuscation), + } + } + // LWO is only enabled on some relays + Lwo if relay.endpoint().lwo => AcceptWireguardEndpoint, + Lwo => Reject(Reason::Obfuscation), + // Other relays are always valid + // TODO:^ This might not be true. We might want to consider the selected port for + // udp2tcp & wireguard port .. + Off | Auto | WireguardPort | Udp2Tcp => AcceptWireguardEndpoint, + } + } + } +} + /// A criteria is a function from a _single_ constraint and a relay to a [`Verdict`]. /// /// Multiple [`Criteria`] can be evaluated against a single relay at once by [`Criteria::eval`]. A @@ -1273,11 +1280,6 @@ impl<'a> Criteria<'a, WireguardRelay> { .map(|criteria| criteria.eval(relay)) .fold(Verdict::Accept, Verdict::and) } - - /// Flatten a nested structure of different criteria into one. - fn flatten(criterias: Vec) -> Self { - Criteria::new(move |relay| Criteria::fold(criterias.iter(), relay)) - } } /// If a relay is accepted or rejected. If it is rejected, all [reasons](Reason) for that judgement diff --git a/mullvad-relay-selector/tests/relay_selector.rs b/mullvad-relay-selector/tests/relay_selector.rs index 0f2630bcae84..cc7ea9d05a89 100644 --- a/mullvad-relay-selector/tests/relay_selector.rs +++ b/mullvad-relay-selector/tests/relay_selector.rs @@ -1647,44 +1647,156 @@ mod partition_relays { /// Test that filtering on obfuscation works. #[test] fn obfuscation() { - let relay_selector = relay_selector(); - // test_selecting_over_quic - let quic = ObfuscationSettings { - selected_obfuscation: SelectedObfuscation::Quic, + // Setup relay selector + let mut relay_list = RelayListBuilder::new(); + relay_list.add_relay("basic"); + relay_list.add_relay("basic_ipv6").ipv6_addr_in = Some(Ipv6Addr::UNSPECIFIED); + relay_list.add_relay("lwo").endpoint_data.lwo = true; + relay_list.add_relay("quic_ipv4").endpoint_data.quic = Some(Quic::new( + vec1![Ipv4Addr::UNSPECIFIED.into()], + String::new(), + String::new(), + )); + relay_list.add_relay("quic_ipv6").endpoint_data.quic = Some(Quic::new( + vec1![Ipv6Addr::UNSPECIFIED.into()], + String::new(), + String::new(), + )); + + relay_list.inner.wireguard.shadowsocks_port_ranges = vec![100..=200]; + relay_list + .add_relay("shadowsocks_extra_ipv6") + .endpoint_data + .shadowsocks_extra_addr_in = HashSet::from([Ipv6Addr::UNSPECIFIED.into()]); + let relay_selector = RelaySelector::from(relay_list); + + // "Auto" matches all relays + let constraints = EntryConstraints::default().obfuscation(ObfuscationSettings { + selected_obfuscation: SelectedObfuscation::Auto, ..Default::default() - }; - // test_selecting_over_lwo - let lwo = ObfuscationSettings { - selected_obfuscation: SelectedObfuscation::Lwo, + }); + let RelayPartitions { + matches: _, + discards, + } = relay_selector.partition_relays(Predicate::Singlehop(constraints)); + assert!(discards.is_empty()); + + // Quic with Ipv4 constraint + let constraints = EntryConstraints::default() + .obfuscation(ObfuscationSettings { + selected_obfuscation: SelectedObfuscation::Quic, + ..Default::default() + }) + .ip_version(IpVersion::V4); + let RelayPartitions { matches, discards } = + relay_selector.partition_relays(Predicate::Singlehop(constraints)); + assert_eq!( + &matches.into_iter().exactly_one().unwrap().hostname, + "quic_ipv4" + ); + for (relay, reasons) in discards { + if &relay.hostname == "quic_ipv6" { + assert_eq!(reasons, vec![Reason::IpVersion]); + } else { + assert_eq!(reasons, vec![Reason::Obfuscation]); + } + } + + // Plain shadowsocks matches all relays + let constraints = EntryConstraints::default().obfuscation(ObfuscationSettings { + selected_obfuscation: SelectedObfuscation::Shadowsocks, ..Default::default() - }; - // test_selecting_over_shadowsocks - let shadowsocks = ObfuscationSettings { + }); + let RelayPartitions { + matches: _, + discards, + } = relay_selector.partition_relays(Predicate::Singlehop(constraints)); + assert!(discards.is_empty(), "Plain shadowsocks matches all relays"); + + // Shadowsocks with a port outside the configured port ranges (100..=200): + let out_of_range_port = 999; // outside 100..=200 + let constraints = EntryConstraints::default().obfuscation(ObfuscationSettings { selected_obfuscation: SelectedObfuscation::Shadowsocks, + shadowsocks: ShadowsocksSettings { + port: Constraint::Only(out_of_range_port), + }, ..Default::default() - }; - - for obfuscation in [quic, lwo, shadowsocks] { - let constraints = EntryConstraints::default().obfuscation(obfuscation); - let query = relay_selector.partition_relays(Predicate::Singlehop(constraints)); - - assert!( - unique_reasons(query) - .is_subset(&HashSet::from([Reason::Obfuscation, Reason::Inactive])) + }); + let RelayPartitions { matches, discards } = + relay_selector.partition_relays(Predicate::Singlehop(constraints)); + // Only the relay with an extra IPv6 address should match (its extra addr satisfies + // `ip_version=Any` → IpVersionMatch::Ok → AcceptObfuscationEndpoint). + assert_eq!( + &matches.into_iter().exactly_one().unwrap().hostname, + "shadowsocks_extra_ipv6", + "Only the relay with extra IPv6 addr should match when port is out of range" + ); + // All other relays have no extra addresses (IpVersionMatch::None) and the port is + // outside the WireGuard shadowsocks ranges, so they must be rejected with Reason::Port. + for (relay, reasons) in &discards { + assert_eq!( + reasons, + &vec![Reason::Port], + "relay '{}' should be rejected with Reason::Port", + relay.hostname ); } + + // Shadowsocks with ip_version=V4 and a port outside the configured port ranges: + let constraints = EntryConstraints::default() + .obfuscation(ObfuscationSettings { + selected_obfuscation: SelectedObfuscation::Shadowsocks, + shadowsocks: ShadowsocksSettings { + port: Constraint::Only(out_of_range_port), + }, + ..Default::default() + }) + .ip_version(IpVersion::V4); + let RelayPartitions { matches, discards } = + relay_selector.partition_relays(Predicate::Singlehop(constraints)); + assert!( + matches.is_empty(), + "No relay should match shadowsocks+V4+out-of-range port" + ); + for (relay, reasons) in &discards { + if relay.hostname == "shadowsocks_extra_ipv6" { + // Has extra addrs but only IPv6 → IpVersionMatch::Other → Reason::IpVersion. + // Switching to IPv6 would unblock this relay. + assert_eq!( + reasons, + &vec![Reason::IpVersion], + "relay '{}' should be rejected with Reason::IpVersion", + relay.hostname + ); + } else { + // Other relays have no extra ss addrs at all → IpVersionMatch::None → Reason::Port. + // The port is the only thing blocking; the WireGuard endpoint would work with V4. + assert_eq!( + reasons, + &vec![Reason::Port], + "relay '{}' should be rejected with Reason::Port", + relay.hostname + ); + } + } } /// Check that if IPv4 is not available, a relay with an IPv6 endpoint is returned. #[test] fn runtime_ipv4_unavailable() { + let mut relay_list_builder = RelayListBuilder::new(); + let has_ipv6 = relay_list_builder.add_relay("has_ipv6"); + has_ipv6.inner.ipv6_addr_in = Some(Ipv6Addr::LOCALHOST); + let has_ipv6_clone = has_ipv6.clone(); + let hasnt_ipv6_clone = relay_list_builder.add_relay("hasnt_ipv6").clone(); + + let relay_selector = RelaySelector::from(relay_list_builder); let constraints = EntryConstraints::default().ip_version(IpVersion::V6); // Query for all DAITA relays. - let query = relay_selector().partition_relays(Predicate::Singlehop(constraints)); - assert!(!query.matches.is_empty()); - for relay in &query.matches { - assert!(relay.ipv6_addr_in.is_some(), "{relay:#?}"); - } + let RelayPartitions { matches, discards } = + relay_selector.partition_relays(Predicate::Singlehop(constraints)); + assert_eq!(matches, vec![has_ipv6_clone]); + assert_eq!(discards, vec![(hasnt_ipv6_clone, vec![Reason::IpVersion])]); } /// Check that if IPv4 is not available and shadowsocks obfuscation is requested @@ -1695,14 +1807,33 @@ mod partition_relays { .ip_version(IpVersion::V6) .obfuscation(ObfuscationSettings { selected_obfuscation: SelectedObfuscation::Shadowsocks, + shadowsocks: ShadowsocksSettings { + port: Constraint::Only(1337), + }, ..Default::default() }); // Query for all DAITA relays. let query = relay_selector().partition_relays(Predicate::Singlehop(constraints)); assert!(!query.matches.is_empty()); for relay in &query.matches { - assert!(relay.ipv6_addr_in.is_some(), "{relay:#?}"); + assert!( + relay + .endpoint_data + .shadowsocks_extra_addr_in + .iter() + .any(|ip| ip.is_ipv6()), + "{relay:#?}" + ); } + + assert!(relay_selector().relay_list(|r| { + r.relays() + .any(|r| r.endpoint_data.shadowsocks_extra_addr_in.is_empty()) + })); + assert_eq!( + unique_reasons(query), + (HashSet::from_iter([Reason::Port, Reason::IpVersion, Reason::Inactive,])) + ); } /// Test that filtering on DAITA works. @@ -2020,7 +2151,7 @@ mod relay_list_builder { use talpid_types::net::wireguard::PublicKey; pub struct RelayListBuilder { - relay_list: RelayList, + pub inner: RelayList, } impl From for RelaySelector { @@ -2061,13 +2192,13 @@ mod relay_list_builder { shadowsocks_port_ranges: vec![100..=200, 1000..=2000], }, }; - Self { relay_list } + Self { inner: relay_list } } /// Add a relay into the previously added location, which defaults to "test-country". pub fn add_relay(&mut self, hostname: impl Display) -> &mut WireguardRelay { let country = self - .relay_list + .inner .countries .last_mut() .expect("Some active country"); @@ -2101,12 +2232,7 @@ mod relay_list_builder { } pub fn add_location(&mut self, country: &str, city: &str) { - let country = match self - .relay_list - .countries - .iter_mut() - .find(|c| c.code == country) - { + let country = match self.inner.countries.iter_mut().find(|c| c.code == country) { Some(country) => country, None => { let country = RelayListCountry { @@ -2114,8 +2240,8 @@ mod relay_list_builder { name: country.to_string(), cities: vec![], }; - self.relay_list.countries.push(country); - self.relay_list.countries.last_mut().unwrap() + self.inner.countries.push(country); + self.inner.countries.last_mut().unwrap() } }; let city = RelayListCity { @@ -2129,7 +2255,7 @@ mod relay_list_builder { } pub fn finish(self) -> RelayList { - self.relay_list + self.inner } } } diff --git a/mullvad-types/src/constraints/constraint.rs b/mullvad-types/src/constraints/constraint.rs index 19ffd79f9a47..870ea32254db 100644 --- a/mullvad-types/src/constraints/constraint.rs +++ b/mullvad-types/src/constraints/constraint.rs @@ -123,6 +123,11 @@ impl Constraint { pub fn is_only_and(self, f: impl FnOnce(T) -> bool) -> bool { self.option().is_some_and(f) } + + /// Returns true if the constraint is an `Any` or the value inside of it matches a predicate. + pub fn is_any_or(self, f: impl FnOnce(T) -> bool) -> bool { + self.option().is_none_or(f) + } } impl Constraint {