net_cli/opts/
filter.rs

1// Copyright 2022 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use std::num::{NonZeroU16, NonZeroU64};
6use std::ops::RangeInclusive;
7use std::str::FromStr;
8
9use anyhow::{anyhow, Context as _};
10use argh::{ArgsInfo, FromArgs};
11use {
12    fidl_fuchsia_net as fnet, fidl_fuchsia_net_ext as fnet_ext,
13    fidl_fuchsia_net_filter as fnet_filter, fidl_fuchsia_net_filter_ext as fnet_filter_ext,
14    fidl_fuchsia_net_interfaces_ext as fnet_interfaces_ext,
15};
16
17#[derive(ArgsInfo, FromArgs, Clone, Debug, PartialEq)]
18#[argh(subcommand, name = "filter")]
19/// commands for configuring packet filtering
20pub struct Filter {
21    #[argh(subcommand)]
22    pub filter_cmd: FilterEnum,
23}
24
25#[derive(ArgsInfo, FromArgs, Clone, Debug, PartialEq)]
26#[argh(subcommand)]
27pub enum FilterEnum {
28    List(List),
29    Create(Create),
30    Remove(Remove),
31}
32
33/// A command to list filtering configuration.
34#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
35#[argh(subcommand, name = "list")]
36pub struct List {}
37
38/// A command to create new filtering resources
39#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
40#[argh(subcommand, name = "create")]
41pub struct Create {
42    /// the name of the controller to create (or connect to, if existing)
43    #[argh(option)]
44    pub controller: String,
45    /// whether the resource creation should be idempotent, i.e. succeed even
46    /// if the resource already exists
47    #[argh(switch)]
48    pub idempotent: Option<bool>,
49    /// the resource to create
50    #[argh(subcommand)]
51    pub resource: Resource,
52}
53
54#[derive(ArgsInfo, FromArgs, Clone, Debug, PartialEq)]
55#[argh(subcommand)]
56pub enum Resource {
57    Namespace(Namespace),
58    Routine(Routine),
59    Rule(Rule),
60}
61
62/// A command to specify a filtering namespace
63#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
64#[argh(subcommand, name = "namespace")]
65pub struct Namespace {
66    /// the name of the namespace
67    #[argh(option)]
68    pub name: String,
69    /// the IP domain of the namespace
70    #[argh(option, default = "Domain::All")]
71    pub domain: Domain,
72}
73
74#[derive(Clone, Debug, PartialEq)]
75pub enum Domain {
76    All,
77    Ipv4,
78    Ipv6,
79}
80
81impl std::str::FromStr for Domain {
82    type Err = anyhow::Error;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        match s.to_lowercase().as_str() {
86            "all" => Ok(Self::All),
87            "ipv4" => Ok(Self::Ipv4),
88            "ipv6" => Ok(Self::Ipv6),
89            s => Err(anyhow!("unknown IP domain {s}")),
90        }
91    }
92}
93
94/// A command to specify a filtering routine
95#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
96#[argh(subcommand, name = "routine")]
97pub struct Routine {
98    /// the namespace that contains the routine
99    #[argh(option)]
100    pub namespace: String,
101    /// the name of the routine
102    #[argh(option)]
103    pub name: String,
104    /// the type of the routine (IP or NAT)
105    #[argh(option, arg_name = "type")]
106    pub type_: RoutineType,
107    /// the hook on which the routine is installed (optional)
108    #[argh(option)]
109    pub hook: Option<Hook>,
110    /// the priority of the routine on its hook (optional)
111    #[argh(option)]
112    pub priority: Option<i32>,
113}
114
115#[derive(Clone, Debug, PartialEq)]
116pub enum RoutineType {
117    Ip,
118    Nat,
119}
120
121impl std::str::FromStr for RoutineType {
122    type Err = anyhow::Error;
123
124    fn from_str(s: &str) -> Result<Self, Self::Err> {
125        match s.to_lowercase().as_str() {
126            "ip" => Ok(Self::Ip),
127            "nat" => Ok(Self::Nat),
128            s => Err(anyhow!("unknown routine type {s}")),
129        }
130    }
131}
132
133#[derive(Clone, Debug, PartialEq)]
134pub enum Hook {
135    Ingress,
136    LocalIngress,
137    Forwarding,
138    LocalEgress,
139    Egress,
140}
141
142impl std::str::FromStr for Hook {
143    type Err = anyhow::Error;
144
145    fn from_str(s: &str) -> Result<Self, Self::Err> {
146        match s.to_lowercase().as_str() {
147            "ingress" => Ok(Self::Ingress),
148            "local_ingress" | "local-ingress" => Ok(Self::LocalIngress),
149            "forwarding" => Ok(Self::Forwarding),
150            "local_egress" | "local-egress" => Ok(Self::LocalEgress),
151            "egress" => Ok(Self::Egress),
152            s => Err(anyhow!("unknown hook {s}")),
153        }
154    }
155}
156
157// TODO(https://fxbug.dev/364951693): eventually we should specify a grammar
158// for fuchsia.net.filter rules, which we currently only have for
159// fuchsia.net.filter.deprecated. Then `net filter create rule` can take a raw
160// string that must be specified in this grammar.
161//
162/// A command to specify a filtering rule
163#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
164#[argh(subcommand, name = "rule")]
165pub struct Rule {
166    /// the namespace that contains the rule
167    #[argh(option)]
168    pub namespace: String,
169    /// the routine that contains the rule
170    #[argh(option)]
171    pub routine: String,
172    /// the index of the rule
173    #[argh(option)]
174    pub index: u32,
175    /// a matcher for the ingress interface of the packet (optional)
176    ///
177    /// Accepted formats are any of "id:<numeric>" | "name:<string>" | "class:<string>".
178    #[argh(option)]
179    pub in_interface: Option<InterfaceMatcher>,
180    /// a matcher for the egress interface of the packet (optional)
181    ///
182    /// Accepted formats are any of "id:<numeric>" | "name:<string>" | "class:<string>".
183    #[argh(option)]
184    pub out_interface: Option<InterfaceMatcher>,
185    /// a matcher for the source address of the packet (optional)
186    ///
187    /// Accepted formats are any of "subnet:<subnet>" | "range:<min-addr>..=<max-addr>".
188    /// Can be prefixed with a `!` for inverse match.
189    #[argh(option)]
190    pub src_addr: Option<AddressMatcher>,
191    /// a matcher for the destination address of the packet (optional)
192    ///
193    /// Accepted formats are any of "subnet:<subnet>" | "range:<min-addr>..=<max-addr>".
194    /// Can be prefixed with a `!` for inverse match.
195    #[argh(option)]
196    pub dst_addr: Option<AddressMatcher>,
197    /// a matcher for the transport protocol of the packet (optional)
198    ///
199    /// Accepted protocols are "tcp" | "udp" | "icmp" | "icmpv6".
200    #[argh(option)]
201    pub transport_protocol: Option<TransportProtocolMatcher>,
202    /// a matcher for the source port of the packet (optional)
203    ///
204    /// Must be accompanied by a transport protocol matcher for either TCP or UDP.
205    ///
206    /// Accepted format is "<min>..=<max>". Can be prefixed with a `!` for inverse match.
207    #[argh(option)]
208    pub src_port: Option<PortMatcher>,
209    /// a matcher for the destination port of the packet (optional)
210    ///
211    /// Must be accompanied by a transport protocol matcher for either TCP or UDP.
212    ///
213    /// Accepted format is "<min>..=<max>". Can be prefixed with a `!` for inverse match.
214    #[argh(option)]
215    pub dst_port: Option<PortMatcher>,
216    /// the action to take if the rule matches a given packet
217    #[argh(subcommand)]
218    pub action: Action,
219}
220
221/// An interface matcher
222#[derive(Clone, Debug, PartialEq)]
223pub enum InterfaceMatcher {
224    Id(NonZeroU64),
225    Name(String),
226    PortClass(fnet_interfaces_ext::PortClass),
227}
228
229impl FromStr for InterfaceMatcher {
230    type Err = anyhow::Error;
231
232    fn from_str(s: &str) -> Result<Self, Self::Err> {
233        let (property, value) = s.split_once(":").ok_or_else(|| {
234            anyhow!("expected $property:$value where property is one of id, name, class")
235        })?;
236        match property {
237            "id" => {
238                let id = value.parse::<NonZeroU64>()?;
239                Ok(Self::Id(id))
240            }
241            "name" => Ok(Self::Name(value.to_owned())),
242            "class" => {
243                let class = match value.to_lowercase().as_str() {
244                    "loopback" => fnet_interfaces_ext::PortClass::Loopback,
245                    "virtual" => fnet_interfaces_ext::PortClass::Virtual,
246                    "ethernet" => fnet_interfaces_ext::PortClass::Ethernet,
247                    "wlan_client" | "wlan-client" | "wlanclient" => {
248                        fnet_interfaces_ext::PortClass::WlanClient
249                    }
250                    "wlan_ap" | "wlan-ap" | "wlanap" => fnet_interfaces_ext::PortClass::WlanAp,
251                    "ppp" => fnet_interfaces_ext::PortClass::Ppp,
252                    "bridge" => fnet_interfaces_ext::PortClass::Bridge,
253                    "lowpan" => fnet_interfaces_ext::PortClass::Lowpan,
254                    other => return Err(anyhow!("unrecognized port class {other}")),
255                };
256                Ok(Self::PortClass(class))
257            }
258            other => Err(anyhow!("unrecognized interface property {other}")),
259        }
260    }
261}
262
263impl From<InterfaceMatcher> for fnet_filter_ext::InterfaceMatcher {
264    fn from(matcher: InterfaceMatcher) -> Self {
265        match matcher {
266            InterfaceMatcher::Id(id) => Self::Id(id),
267            InterfaceMatcher::Name(name) => Self::Name(name),
268            InterfaceMatcher::PortClass(class) => Self::PortClass(class),
269        }
270    }
271}
272
273/// An invertible address matcher
274#[derive(Clone, Debug, PartialEq)]
275pub struct AddressMatcher {
276    pub matcher: AddressMatcherType,
277    pub invert: bool,
278}
279
280impl FromStr for AddressMatcher {
281    type Err = anyhow::Error;
282
283    fn from_str(s: &str) -> Result<Self, Self::Err> {
284        let (invert, s) = s.strip_prefix("!").map(|s| (true, s)).unwrap_or((false, s));
285        let matcher = s.parse::<AddressMatcherType>()?;
286        Ok(Self { matcher, invert })
287    }
288}
289
290impl From<AddressMatcher> for fnet_filter_ext::AddressMatcher {
291    fn from(matcher: AddressMatcher) -> Self {
292        let AddressMatcher { matcher, invert } = matcher;
293        Self { matcher: matcher.into(), invert }
294    }
295}
296
297/// An address matcher
298#[derive(Clone, Debug, PartialEq)]
299pub enum AddressMatcherType {
300    Subnet(fnet_filter_ext::Subnet),
301    Range(fnet_filter_ext::AddressRange),
302}
303
304impl FromStr for AddressMatcherType {
305    type Err = anyhow::Error;
306
307    fn from_str(s: &str) -> Result<Self, Self::Err> {
308        let (property, value) = s.split_once(":").ok_or_else(|| {
309            anyhow!("expected $property:$value where property is one of subnet, range")
310        })?;
311        match property {
312            "subnet" => {
313                let subnet: fnet::Subnet = value.parse::<fnet_ext::Subnet>()?.into();
314                let subnet = subnet.try_into()?;
315                Ok(Self::Subnet(subnet))
316            }
317            "range" => {
318                let (start, end) = value.split_once("..=").ok_or_else(|| {
319                    anyhow!(
320                    "expected inclusive address range to be specified in the form $start..=$end"
321                )
322                })?;
323                let start: fnet::IpAddress = start.parse::<fnet_ext::IpAddress>()?.into();
324                let end: fnet::IpAddress = end.parse::<fnet_ext::IpAddress>()?.into();
325                let range = fnet_filter::AddressRange { start, end }.try_into()?;
326                Ok(Self::Range(range))
327            }
328            other => Err(anyhow!("unrecognized address property {other}")),
329        }
330    }
331}
332
333impl From<AddressMatcherType> for fnet_filter_ext::AddressMatcherType {
334    fn from(matcher: AddressMatcherType) -> Self {
335        match matcher {
336            AddressMatcherType::Subnet(subnet) => Self::Subnet(subnet),
337            AddressMatcherType::Range(range) => Self::Range(range),
338        }
339    }
340}
341
342/// A transport protocol matcher
343#[derive(Clone, Debug, PartialEq)]
344pub enum TransportProtocolMatcher {
345    Tcp,
346    Udp,
347    Icmp,
348    Icmpv6,
349}
350
351impl FromStr for TransportProtocolMatcher {
352    type Err = anyhow::Error;
353
354    fn from_str(s: &str) -> Result<Self, Self::Err> {
355        let matcher = match s.to_lowercase().as_str() {
356            "tcp" => Self::Tcp,
357            "udp" => Self::Udp,
358            "icmp" | "icmpv4" => Self::Icmp,
359            "icmpv6" => Self::Icmpv6,
360            other => return Err(anyhow!("unrecognized transport protocol {other}")),
361        };
362        Ok(matcher)
363    }
364}
365
366/// An invertible address matcher
367#[derive(Clone, Debug, PartialEq)]
368pub struct PortMatcher(fnet_filter_ext::PortMatcher);
369
370impl FromStr for PortMatcher {
371    type Err = anyhow::Error;
372
373    fn from_str(s: &str) -> Result<Self, Self::Err> {
374        let (invert, s) = s.strip_prefix("!").map(|s| (true, s)).unwrap_or((false, s));
375        let (start, end) = s.split_once("..=").ok_or_else(|| {
376            anyhow!("expected inclusive port range to be specified in the form $start..=$end")
377        })?;
378        let start = start.parse::<u16>()?;
379        let end = end.parse::<u16>()?;
380        let matcher = fnet_filter_ext::PortMatcher::new(start, end, invert)?;
381        Ok(Self(matcher))
382    }
383}
384
385impl From<PortMatcher> for fnet_filter_ext::PortMatcher {
386    fn from(matcher: PortMatcher) -> Self {
387        matcher.0
388    }
389}
390
391/// A filtering action
392#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
393#[argh(subcommand)]
394pub enum Action {
395    Accept(Accept),
396    Drop(Drop),
397    Jump(Jump),
398    Return(Return),
399    TransparentProxy(TransparentProxy),
400    Redirect(Redirect),
401    Masquerade(Masquerade),
402}
403
404/// The `fuchsia.net.filter/Action.Accept` action.
405#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
406#[argh(subcommand, name = "accept")]
407pub struct Accept {}
408
409/// The `fuchsia.net.filter/Action.Drop` action.
410#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
411#[argh(subcommand, name = "drop")]
412pub struct Drop {}
413
414/// The `fuchsia.net.filter/Action.Jump` action.
415#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
416#[argh(subcommand, name = "jump")]
417pub struct Jump {
418    /// the routine to jump to
419    #[argh(positional)]
420    pub target: String,
421}
422
423/// The `fuchsia.net.filter/Action.Return` action.
424#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
425#[argh(subcommand, name = "return")]
426pub struct Return {}
427
428/// The `fuchsia.net.filter/Action.TransparentProxy` action.
429///
430/// Both --addr and --port are optional, but at least one must be specified.
431#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
432#[argh(subcommand, name = "tproxy")]
433pub struct TransparentProxy {
434    /// the bound address of the local socket to redirect the packet to (optional)
435    #[argh(option)]
436    pub addr: Option<String>,
437    /// the bound port of the local socket to redirect the packet to (optional, must be nonzero)
438    #[argh(option)]
439    pub port: Option<NonZeroU16>,
440}
441
442/// An inclusive range of nonzero ports
443#[derive(Clone, Debug, PartialEq)]
444pub struct PortRange(pub RangeInclusive<NonZeroU16>);
445
446impl FromStr for PortRange {
447    type Err = anyhow::Error;
448
449    fn from_str(s: &str) -> Result<Self, Self::Err> {
450        let (start, end) = s.split_once("..=").ok_or_else(|| {
451            anyhow!("expected inclusive port range to be specified in the form $start..=$end")
452        })?;
453        let start = NonZeroU16::new(start.parse::<u16>()?).context("port must be nonzero")?;
454        let end = NonZeroU16::new(end.parse::<u16>()?).context("port must be nonzero")?;
455        Ok(Self(start..=end))
456    }
457}
458
459/// The `fuchsia.net.filter/Action.Redirect` action.
460///
461/// The destination port range to which to redirect the packet is optional, but
462/// --min-dst-port and --max-dst-port must either both be specified, or
463/// neither.
464#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
465#[argh(subcommand, name = "redirect")]
466pub struct Redirect {
467    /// the destination port range used to rewrite the packet (optional)
468    #[argh(option)]
469    pub dst_port: Option<PortRange>,
470}
471
472/// The `fuchsia.net.filter/Action.Masquerade` action.
473///
474/// The source port range to use to rewrite the packet is optional, but
475/// --min-src-port and --max-src-port must either both be specified, or
476/// neither.
477#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
478#[argh(subcommand, name = "masquerade")]
479pub struct Masquerade {
480    /// the source port range used to rewrite the packet (optional)
481    #[argh(option)]
482    pub src_port: Option<PortRange>,
483}
484
485/// A command to remove existing filtering resources
486#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
487#[argh(subcommand, name = "remove")]
488pub struct Remove {
489    /// the name of the controller that owns the resource
490    #[argh(option)]
491    pub controller: String,
492    /// the resource to be removed
493    #[argh(subcommand)]
494    pub resource: ResourceId,
495    /// whether the resource removal should be idempotent, i.e. succeed even if
496    /// the resource does not exist
497    #[argh(switch)]
498    pub idempotent: Option<bool>,
499}
500
501#[derive(ArgsInfo, FromArgs, Clone, Debug, PartialEq)]
502#[argh(subcommand)]
503pub enum ResourceId {
504    Namespace(NamespaceId),
505    Routine(RoutineId),
506    Rule(RuleId),
507}
508
509/// A command to identify a filtering namespace
510#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
511#[argh(subcommand, name = "namespace")]
512pub struct NamespaceId {
513    /// the name of the namespace
514    #[argh(option)]
515    pub name: String,
516}
517
518/// A command to identify a filtering routine
519#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
520#[argh(subcommand, name = "routine")]
521pub struct RoutineId {
522    /// the namespace that contains the routine
523    #[argh(option)]
524    pub namespace: String,
525    /// the name of the routine
526    #[argh(option)]
527    pub name: String,
528}
529
530/// A command to identify a filtering rule
531#[derive(Clone, Debug, ArgsInfo, FromArgs, PartialEq)]
532#[argh(subcommand, name = "rule")]
533pub struct RuleId {
534    /// the namespace that contains the rule
535    #[argh(option)]
536    pub namespace: String,
537    /// the routine that contains the rule
538    #[argh(option)]
539    pub routine: String,
540    /// the index of the rule
541    #[argh(option)]
542    pub index: u32,
543}
544
545#[cfg(test)]
546mod tests {
547    use net_declare::{fidl_ip, fidl_subnet};
548    use test_case::test_case;
549
550    use super::*;
551
552    const THREE: NonZeroU64 = NonZeroU64::new(3).unwrap();
553
554    #[test_case("id:0" => Err(()); "id must be nonzero")]
555    #[test_case("id:-1" => Err(()); "id must be nonnegative")]
556    #[test_case("id:a" => Err(()); "id must be numeric")]
557    #[test_case("wlan" => Err(()); "must have both property and value")]
558    #[test_case("class:not-a-class" => Err(()); "class must be valid")]
559    #[test_case("unknown-property:value" => Err(()); "property must be id, name, or class")]
560    #[test_case("id:3" => Ok(InterfaceMatcher::Id(THREE)); "valid ID matcher")]
561    #[test_case(
562        "name:wlan" =>
563        Ok(InterfaceMatcher::Name(String::from("wlan")));
564        "valid name matcher"
565    )]
566    #[test_case(
567        "class:ethernet" =>
568        Ok(InterfaceMatcher::PortClass(fnet_interfaces_ext::PortClass::Ethernet));
569        "valid ethernet matcher"
570    )]
571    #[test_case(
572        "class:wlan-client" =>
573        Ok(InterfaceMatcher::PortClass(fnet_interfaces_ext::PortClass::WlanClient));
574        "valid wlan-client matcher"
575    )]
576    fn interface_matcher(s: &str) -> Result<InterfaceMatcher, ()> {
577        s.parse::<InterfaceMatcher>().map_err(|_| ())
578    }
579
580    #[test_case("192.168.0.1" => Err(()); "must have both property and value")]
581    #[test_case("unknown-property:value" => Err(()); "property must be range or subnet")]
582    #[test_case("subnet:192.0.2.1/" => Err(()); "not a subnet")]
583    #[test_case("subnet:192.0.2.1/24" => Err(()); "host bits are set")]
584    #[test_case("range:192.0.2.1" => Err(()); "not a range")]
585    #[test_case("range:192.0.2.1..192.0.2.255" => Err(()); "range must be specified with ..=")]
586    #[test_case("range:192.0.2.255..=192.0.2.1" => Err(()); "invalid range")]
587    #[test_case("range:A..=B" => Err(()); "not an IP address")]
588    #[test_case("!!subnet:192.0.2.0/24" => Err(()); "double invert")]
589    #[test_case(
590        "subnet:192.0.2.0/24" =>
591        Ok(AddressMatcher {
592            matcher: AddressMatcherType::Subnet(fidl_subnet!("192.0.2.0/24").try_into().unwrap()),
593            invert: false,
594        });
595        "valid IPv4 subnet matcher"
596    )]
597    #[test_case(
598        "subnet:2001:db8::/32" =>
599        Ok(AddressMatcher {
600            matcher: AddressMatcherType::Subnet(fidl_subnet!("2001:db8::/32").try_into().unwrap()),
601            invert: false,
602        });
603        "valid IPv6 subnet matcher"
604    )]
605    #[test_case(
606        "!subnet:192.0.2.0/24" =>
607        Ok(AddressMatcher {
608            matcher: AddressMatcherType::Subnet(fidl_subnet!("192.0.2.0/24").try_into().unwrap()),
609            invert: true,
610        });
611        "valid inverse IPv4 subnet matcher"
612    )]
613    #[test_case(
614        "range:192.0.2.1..=192.0.2.255" =>
615        Ok(AddressMatcher {
616            matcher: AddressMatcherType::Range(
617                fnet_filter::AddressRange {
618                    start: fidl_ip!("192.0.2.1"),
619                    end: fidl_ip!("192.0.2.255"),
620                }.try_into().unwrap()
621            ),
622            invert: false,
623        });
624        "valid IPv4 range matcher"
625    )]
626    #[test_case(
627        "range:2001:db8::1..=2001:db8::2" =>
628        Ok(AddressMatcher {
629            matcher: AddressMatcherType::Range(
630                fnet_filter::AddressRange {
631                    start: fidl_ip!("2001:db8::1"),
632                    end: fidl_ip!("2001:db8::2"),
633                }.try_into().unwrap()
634            ),
635            invert: false,
636        });
637        "valid IPv6 range matcher"
638    )]
639    #[test_case(
640        "!range:192.0.2.1..=192.0.2.255" =>
641        Ok(AddressMatcher {
642            matcher: AddressMatcherType::Range(
643                fnet_filter::AddressRange {
644                    start: fidl_ip!("192.0.2.1"),
645                    end: fidl_ip!("192.0.2.255"),
646                }.try_into().unwrap()
647            ),
648            invert: true,
649        });
650        "valid inverse IPv6 range matcher"
651    )]
652    fn address_matcher(s: &str) -> Result<AddressMatcher, ()> {
653        s.parse::<AddressMatcher>().map_err(|_| ())
654    }
655
656    #[test_case("22" => Err(()); "must be specified as range")]
657    #[test_case("22..22" => Err(()); "must be written as ..=")]
658    #[test_case("65536..=65536" => Err(()); "invalid u16")]
659    #[test_case("33333..=22222" => Err(()); "invalid range")]
660    #[test_case("!!33333..=22222" => Err(()); "double invert")]
661    #[test_case(
662        "22..=22" =>
663        Ok(PortMatcher(fnet_filter_ext::PortMatcher::new(22, 22, /* invert */ false).unwrap()));
664        "valid single port matcher"
665    )]
666    #[test_case(
667        "0..=65535" =>
668        Ok(PortMatcher(fnet_filter_ext::PortMatcher::new(0, 65535, /* invert */ false).unwrap()));
669        "valid port range matcher"
670    )]
671    #[test_case(
672        "!443..=443" =>
673        Ok(PortMatcher(fnet_filter_ext::PortMatcher::new(443, 443, /* invert */ true).unwrap()));
674        "valid inverse port matcher"
675    )]
676    fn port_matcher(s: &str) -> Result<PortMatcher, ()> {
677        s.parse::<PortMatcher>().map_err(|_| ())
678    }
679
680    #[test_case("22" => Err(()); "must be specified as range")]
681    #[test_case("22..22" => Err(()); "must be written as ..=")]
682    #[test_case("0..=1" => Err(()); "must be nonzero")]
683    #[test_case("65536..=65536" => Err(()); "invalid u16")]
684    #[test_case(
685        "22..=22" => Ok(PortRange(NonZeroU16::new(22).unwrap()..=NonZeroU16::new(22).unwrap()));
686        "valid single port matcher"
687    )]
688    #[test_case(
689        "1..=65535" => Ok(PortRange(NonZeroU16::new(1).unwrap()..=NonZeroU16::MAX));
690        "valid port range matcher"
691    )]
692    fn port_range(s: &str) -> Result<PortRange, ()> {
693        s.parse::<PortRange>().map_err(|_| ())
694    }
695}