1111//!
1212//! Any atom this hook cannot represent (a non-observable field, a second atom
1313//! on the same field, an unparseable/typed-wrong value, IPv6, negated CIDR, or
14- //! any non-`Eq`/`Membership`/`Cidr` atom) makes the **whole clause**
15- //! non-lowerable, and the clause is dropped from the plan. Dropping an allow
16- //! clause can only remove allows, never add them — fail-closed.
14+ //! any non-`Eq`/`Membership`/`Cidr`/representable-`Cmp` atom) makes the
15+ //! **whole clause** non-lowerable, and the clause is dropped from the plan.
16+ //! Dropping an allow clause can only remove allows, never add them —
17+ //! fail-closed.
18+ //!
19+ //! ## Port ranges
20+ //!
21+ //! As a special case, the `dest_port` field accepts **multiple** `Cmp` atoms
22+ //! in one clause (e.g. `input.dest_port >= 1024` AND `input.dest_port <= 2048`).
23+ //! These are intersected into a single inclusive [`ScalarMatch::Range`]. The
24+ //! ordered comparisons `Ge`/`Gt`/`Le`/`Lt` map to half-open bounds clamped to
25+ //! `[0, u16::MAX]`; if the intersection is empty the clause matches nothing and
26+ //! is dropped (sound). Any `Cmp` that cannot be represented as a range bound
27+ //! (e.g. `Ne`, or a `negation_complement` where a missing field would match),
28+ //! or any mix of a `Cmp` with an `Eq`/`Membership` on the same `dest_port`
29+ //! field, makes the whole clause non-lowerable (fail-closed).
1730
1831use std:: net:: Ipv4Addr ;
1932
20- use regorus_lift:: ir:: { Atom , Clause , EnforcerConfig , IpFamily , LiftScalar } ;
33+ use regorus_lift:: ir:: { Atom , Clause , CmpOp , EnforcerConfig , IpFamily , LiftScalar } ;
2134
2235use crate :: abi:: { FieldId , Proto , Verdict , MAX_CLAUSES } ;
2336
24- /// Per-field scalar match: wildcard or an exact value.
37+ /// Per-field scalar match: wildcard, an exact value, or an inclusive range.
38+ ///
39+ /// `Range { min, max }` matches a present value `v` iff `min <= v <= max`
40+ /// (inclusive on both ends). A `Range` therefore requires the field to be
41+ /// **present** — a missing/None value never matches a `Range`, mirroring the
42+ /// fail-closed semantics of a Rego comparison over a missing field
43+ /// (`missing_matches = false`). Only `dest_port` ever lowers to a `Range`
44+ /// (from `>=`/`>`/`<=`/`<` comparisons); `proto` only ever uses `Any`/`Exact`.
2545#[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
2646pub enum ScalarMatch < T > {
2747 Any ,
2848 Exact ( T ) ,
49+ Range { min : T , max : T } ,
2950}
3051
3152/// `dest_ip` match: wildcard, exact host-order IPv4, or an IPv4 CIDR network.
@@ -102,38 +123,24 @@ fn map_verdict(v: regorus_lift::Verdict) -> Verdict {
102123/// Lower a single conjunction clause to one or more [`ClauseEntry`] rows, or
103124/// `None` if any atom is not representable by this hook (reject the clause).
104125fn lower_clause ( clause : & Clause ) -> Option < Vec < ClauseEntry > > {
105- // Alternatives per field. `None` = no constraining atom yet (wildcard).
106- let mut ip_alts: Option < Vec < IpMatch > > = None ;
107- let mut port_alts: Option < Vec < ScalarMatch < u16 > > > = None ;
108- let mut proto_alts: Option < Vec < ScalarMatch < u8 > > > = None ;
126+ // Gather atoms per field. The `dest_port` field may carry more than one
127+ // atom (intersected `Cmp` range bounds); the others accept at most one.
128+ let mut ip_atoms: Vec < & Atom > = Vec :: new ( ) ;
129+ let mut port_atoms: Vec < & Atom > = Vec :: new ( ) ;
130+ let mut proto_atoms: Vec < & Atom > = Vec :: new ( ) ;
109131
110132 for atom in & clause. atoms {
111133 let field = FieldId :: from_input_path ( atom. input_path ( ) ) ?; // non-observable -> reject
112134 match field {
113- FieldId :: DestIp => {
114- if ip_alts. is_some ( ) {
115- return None ; // two atoms on the same field -> reject (fail-closed)
116- }
117- ip_alts = Some ( lower_ip_atom ( atom) ?) ;
118- }
119- FieldId :: DestPort => {
120- if port_alts. is_some ( ) {
121- return None ;
122- }
123- port_alts = Some ( lower_port_atom ( atom) ?) ;
124- }
125- FieldId :: Proto => {
126- if proto_alts. is_some ( ) {
127- return None ;
128- }
129- proto_alts = Some ( lower_proto_atom ( atom) ?) ;
130- }
135+ FieldId :: DestIp => ip_atoms. push ( atom) ,
136+ FieldId :: DestPort => port_atoms. push ( atom) ,
137+ FieldId :: Proto => proto_atoms. push ( atom) ,
131138 }
132139 }
133140
134- let ip_alts = ip_alts . unwrap_or_else ( || vec ! [ IpMatch :: Any ] ) ;
135- let port_alts = port_alts . unwrap_or_else ( || vec ! [ ScalarMatch :: Any ] ) ;
136- let proto_alts = proto_alts . unwrap_or_else ( || vec ! [ ScalarMatch :: Any ] ) ;
141+ let ip_alts = lower_ip_field ( & ip_atoms ) ? ;
142+ let port_alts = lower_port_atoms ( & port_atoms ) ? ;
143+ let proto_alts = lower_single_field ( & proto_atoms , lower_proto_atom ) ? ;
137144
138145 // Bounded cross-product across the per-field alternatives.
139146 let product = ip_alts. len ( ) * port_alts. len ( ) * proto_alts. len ( ) ;
@@ -151,6 +158,29 @@ fn lower_clause(clause: &Clause) -> Option<Vec<ClauseEntry>> {
151158 Some ( entries)
152159}
153160
161+ /// Lower a field that accepts at most one constraining atom. Zero atoms is a
162+ /// wildcard; two or more atoms on the same field reject the clause (fail-closed).
163+ fn lower_single_field < T : Copy > (
164+ atoms : & [ & Atom ] ,
165+ lower : impl Fn ( & Atom ) -> Option < Vec < ScalarMatch < T > > > ,
166+ ) -> Option < Vec < ScalarMatch < T > > > {
167+ match atoms {
168+ [ ] => Some ( vec ! [ ScalarMatch :: Any ] ) ,
169+ [ atom] => lower ( atom) ,
170+ _ => None , // two atoms on the same field -> reject (fail-closed)
171+ }
172+ }
173+
174+ /// Specialised variant of [`lower_single_field`] for the `dest_ip` field, whose
175+ /// matches use [`IpMatch`] rather than [`ScalarMatch`].
176+ fn lower_ip_field ( atoms : & [ & Atom ] ) -> Option < Vec < IpMatch > > {
177+ match atoms {
178+ [ ] => Some ( vec ! [ IpMatch :: Any ] ) ,
179+ [ atom] => lower_ip_atom ( atom) ,
180+ _ => None ,
181+ }
182+ }
183+
154184fn lower_ip_atom ( atom : & Atom ) -> Option < Vec < IpMatch > > {
155185 match atom {
156186 Atom :: Eq ( eq) => Some ( vec ! [ IpMatch :: Exact ( parse_ipv4_scalar( & eq. scalar) ?) ] ) ,
@@ -191,6 +221,60 @@ fn lower_ip_atom(atom: &Atom) -> Option<Vec<IpMatch>> {
191221 }
192222}
193223
224+ /// Lower the `dest_port` atoms of a clause.
225+ ///
226+ /// - Zero atoms -> wildcard (`Any`).
227+ /// - A single `Eq` or `Membership` atom -> exact value(s), as before.
228+ /// - One or more `Cmp` atoms -> intersect into a single inclusive
229+ /// [`ScalarMatch::Range`]. An empty intersection drops the clause (sound).
230+ /// - Any other combination (a second `Eq`/`Membership`, or a `Cmp` mixed with
231+ /// an `Eq`/`Membership`, or an unrepresentable `Cmp`) -> reject (fail-closed).
232+ fn lower_port_atoms ( atoms : & [ & Atom ] ) -> Option < Vec < ScalarMatch < u16 > > > {
233+ if atoms. is_empty ( ) {
234+ return Some ( vec ! [ ScalarMatch :: Any ] ) ;
235+ }
236+
237+ // If every atom is a comparison, fold them into one intersected range.
238+ if atoms. iter ( ) . all ( |a| matches ! ( a, Atom :: Cmp ( _) ) ) {
239+ // Work in i64 so half-open adjustments and out-of-`u16` bounds can be
240+ // represented before clamping to the observable `[0, u16::MAX]` window.
241+ let mut lo: i64 = 0 ;
242+ let mut hi: i64 = u16:: MAX as i64 ;
243+ for atom in atoms {
244+ let Atom :: Cmp ( cmp) = atom else { return None } ;
245+ // A comparison whose missing field would still match cannot be
246+ // soundly represented by a presence-requiring range; reject.
247+ if cmp. missing_matches {
248+ return None ;
249+ }
250+ let n = scalar_to_i64 ( & cmp. scalar ) ?;
251+ match cmp. op {
252+ CmpOp :: Ge => lo = lo. max ( n) ,
253+ CmpOp :: Gt => lo = lo. max ( n. saturating_add ( 1 ) ) ,
254+ CmpOp :: Le => hi = hi. min ( n) ,
255+ CmpOp :: Lt => hi = hi. min ( n. saturating_sub ( 1 ) ) ,
256+ CmpOp :: Ne => return None , // not a single contiguous range
257+ }
258+ }
259+ // Clamp to the observable u16 window, then test for emptiness.
260+ let min = lo. max ( 0 ) ;
261+ let max = hi. min ( u16:: MAX as i64 ) ;
262+ if min > max {
263+ return None ; // empty range -> clause matches nothing -> drop
264+ }
265+ return Some ( vec ! [ ScalarMatch :: Range {
266+ min: min as u16 ,
267+ max: max as u16 ,
268+ } ] ) ;
269+ }
270+
271+ // Otherwise only a single Eq/Membership atom is representable.
272+ match atoms {
273+ [ atom] => lower_port_atom ( atom) ,
274+ _ => None ,
275+ }
276+ }
277+
194278fn lower_port_atom ( atom : & Atom ) -> Option < Vec < ScalarMatch < u16 > > > {
195279 match atom {
196280 Atom :: Eq ( eq) => Some ( vec ! [ ScalarMatch :: Exact ( scalar_to_u16( & eq. scalar) ?) ] ) ,
@@ -238,6 +322,17 @@ fn scalar_to_u16(scalar: &LiftScalar) -> Option<u16> {
238322 }
239323}
240324
325+ /// Convert an integer scalar to `i64` for range-bound arithmetic. A `Uint`
326+ /// larger than `i64::MAX` saturates to `i64::MAX` (a port bound far above the
327+ /// observable `u16` window, which clamps to an empty/degenerate range — sound).
328+ fn scalar_to_i64 ( scalar : & LiftScalar ) -> Option < i64 > {
329+ match scalar {
330+ LiftScalar :: Int ( i) => Some ( * i) ,
331+ LiftScalar :: Uint ( u) => Some ( i64:: try_from ( * u) . unwrap_or ( i64:: MAX ) ) ,
332+ _ => None ,
333+ }
334+ }
335+
241336fn scalar_to_proto ( scalar : & LiftScalar ) -> Option < u8 > {
242337 match scalar {
243338 LiftScalar :: Str ( s) => Proto :: from_name ( s) . map ( Proto :: as_u8) ,
0 commit comments