Skip to main content

ratify_protocol/
constraints.rs

1//! Constraint evaluation — mirrors Go's constraints.go exactly.
2//!
3//! Every semantic must produce the same verdict for the same inputs, or
4//! cross-language conformance fails.
5
6#[cfg(not(feature = "std"))]
7use alloc::{boxed::Box, format, string::String, string::ToString};
8
9use crate::types::{Constraint, ConstraintEvaluator, DelegationCert, VerifierContext};
10
11use alloc::collections::BTreeMap;
12use chrono::{DateTime, Utc};
13use chrono_tz::Tz;
14
15/// Run every Constraint on cert against the caller-supplied VerifierContext.
16/// Return `Ok(())` iff all pass; an error string otherwise.
17/// Fail-closed: unknown Type or missing required context field causes rejection.
18/// (SPEC §17.7) Unknown built-in types fall through to `ext_evaluators`
19/// before failing closed.
20pub fn evaluate_constraints<'a>(
21    cert: &DelegationCert,
22    ctx: &VerifierContext,
23    now_sec: i64,
24    ext_evaluators: Option<&BTreeMap<String, Box<dyn ConstraintEvaluator + 'a>>>,
25) -> Result<(), String> {
26    for (i, c) in cert.constraints.iter().enumerate() {
27        let mut err = evaluate_constraint(c, &cert.cert_id, ctx, now_sec);
28        if let Err(ref msg) = err {
29            if msg.starts_with("constraint_unknown:") {
30                if let Some(map) = ext_evaluators {
31                    if let Some(ev) = map.get(&c.kind) {
32                        err = ev.evaluate(c, &cert.cert_id, ctx, now_sec);
33                    }
34                }
35            }
36        }
37        if let Err(e) = err {
38            return Err(format!("constraint[{}] ({}): {}", i, c.kind, e));
39        }
40    }
41    Ok(())
42}
43
44fn evaluate_constraint(
45    c: &Constraint,
46    cert_id: &str,
47    ctx: &VerifierContext,
48    now_sec: i64,
49) -> Result<(), String> {
50    match c.kind.as_str() {
51        "geo_circle" => {
52            let (lat, lon) = match (ctx.current_lat, ctx.current_lon) {
53                (Some(a), Some(b)) => (a, b),
54                _ => return Err("constraint_unverifiable: no current location in context".into()),
55            };
56            let d = haversine_meters(lat, lon, c.lat, c.lon);
57            if d > c.radius_m {
58                return Err(format!(
59                    "outside allowed radius: {:.1}m > {:.1}m",
60                    d, c.radius_m
61                ));
62            }
63            Ok(())
64        }
65        "geo_polygon" => {
66            let (lat, lon) = match (ctx.current_lat, ctx.current_lon) {
67                (Some(a), Some(b)) => (a, b),
68                _ => return Err("constraint_unverifiable: no current location in context".into()),
69            };
70            if c.points.len() < 3 {
71                return Err("polygon has fewer than 3 points".into());
72            }
73            // v1 polygon is defined over equirectangular projection — correct
74            // for small regions, incorrect for anti-meridian-crossing shapes.
75            // Fail closed rather than silently return wrong answers. SPEC §5.7.2
76            // documents the v1 small-region limitation; geodesic semantics are v2.
77            if polygon_spans_antimeridian(&c.points) {
78                return Err("geo_polygon spans >180° longitude — v1 semantics are undefined for anti-meridian-crossing polygons (see SPEC §5.7.2)".into());
79            }
80            if !point_in_polygon(lat, lon, &c.points) {
81                return Err("outside allowed polygon".into());
82            }
83            Ok(())
84        }
85        "geo_bbox" => {
86            let (lat, lon) = match (ctx.current_lat, ctx.current_lon) {
87                (Some(a), Some(b)) => (a, b),
88                _ => return Err("constraint_unverifiable: no current location in context".into()),
89            };
90            if lat < c.min_lat || lat > c.max_lat {
91                // Format must match Go's %.6f (fixed 6 decimals) so cross-SDK
92                // error_reason strings are byte-identical. Don't change.
93                return Err(format!(
94                    "latitude {:.6} outside [{:.6}, {:.6}]",
95                    lat, c.min_lat, c.max_lat
96                ));
97            }
98            // Anti-meridian-aware longitude check (SPEC §5.7.2).
99            //   min_lon <= max_lon → ordinary bbox, lon must be in [min_lon, max_lon]
100            //   min_lon >  max_lon → bbox wraps the 180° meridian (e.g.,
101            //     min_lon=170, max_lon=-170 = "from 170°E through 180 to -170°W")
102            //     — inside iff lon >= min_lon OR lon <= max_lon.
103            if c.min_lon <= c.max_lon {
104                if lon < c.min_lon || lon > c.max_lon {
105                    return Err(format!(
106                        "longitude {:.6} outside [{:.6}, {:.6}]",
107                        lon, c.min_lon, c.max_lon
108                    ));
109                }
110            } else if lon < c.min_lon && lon > c.max_lon {
111                return Err(format!(
112                    "longitude {:.6} outside wrapped [{:.6}, {:.6}]",
113                    lon, c.min_lon, c.max_lon
114                ));
115            }
116            let has_alt = c.min_alt_m != 0.0 || c.max_alt_m != 0.0;
117            if has_alt {
118                let alt = ctx.current_alt_m.ok_or_else(|| {
119                    "constraint_unverifiable: no altitude in context but bbox has altitude bounds"
120                        .to_string()
121                })?;
122                if alt < c.min_alt_m || alt > c.max_alt_m {
123                    return Err(format!(
124                        "altitude {:.1}m outside [{:.1}m, {:.1}m]",
125                        alt, c.min_alt_m, c.max_alt_m
126                    ));
127                }
128            }
129            Ok(())
130        }
131        "time_window" => {
132            if c.tz.is_empty() || c.start.is_empty() || c.end.is_empty() {
133                return Err("malformed time_window: tz/start/end required".into());
134            }
135            let start =
136                parse_hhmm(&c.start).ok_or_else(|| format!("bad start time: {}", c.start))?;
137            let end = parse_hhmm(&c.end).ok_or_else(|| format!("bad end time: {}", c.end))?;
138            let zone: Tz =
139                c.tz.parse()
140                    .map_err(|_| format!("unknown timezone \"{}\"", c.tz))?;
141            let utc = DateTime::<Utc>::from_timestamp(now_sec, 0)
142                .ok_or_else(|| "time_window: invalid now_sec".to_string())?;
143            let local = utc.with_timezone(&zone);
144            let cur = (local.format("%H").to_string().parse::<i32>().unwrap_or(0)) * 60
145                + local.format("%M").to_string().parse::<i32>().unwrap_or(0);
146            if start <= end {
147                if cur < start || cur > end {
148                    return Err(format!(
149                        "current {} outside [{}, {}] {}",
150                        local.format("%H:%M"),
151                        c.start,
152                        c.end,
153                        c.tz
154                    ));
155                }
156            } else {
157                // Wrapping window (e.g. 22:00 to 06:00).
158                if cur < start && cur > end {
159                    return Err(format!(
160                        "current {} outside wrapped [{}, {}] {}",
161                        local.format("%H:%M"),
162                        c.start,
163                        c.end,
164                        c.tz
165                    ));
166                }
167            }
168            Ok(())
169        }
170        "max_speed_mps" => {
171            let speed = ctx.current_speed_mps.ok_or_else(|| {
172                "constraint_unverifiable: no current speed in context".to_string()
173            })?;
174            if speed > c.max_mps {
175                return Err(format!(
176                    "speed {:.2}mps exceeds max {:.2}mps",
177                    speed, c.max_mps
178                ));
179            }
180            Ok(())
181        }
182        "max_amount" => {
183            let amount = ctx.requested_amount.ok_or_else(|| {
184                "constraint_unverifiable: no requested amount in context".to_string()
185            })?;
186            let req_ccy = ctx.requested_currency.as_deref().ok_or_else(|| {
187                "constraint_unverifiable: no requested currency in context".to_string()
188            })?;
189            if req_ccy != c.currency {
190                return Err(format!(
191                    "currency mismatch: requested \"{}\", constraint \"{}\"",
192                    req_ccy, c.currency
193                ));
194            }
195            if amount > c.max_amount {
196                return Err(format!(
197                    "amount {:.2} {} exceeds max {:.2} {}",
198                    amount, req_ccy, c.max_amount, c.currency
199                ));
200            }
201            Ok(())
202        }
203        "max_rate" => {
204            let counter = ctx
205                .invocations_in_window
206                .as_ref()
207                .ok_or_else(|| "constraint_unverifiable: no rate counter in context".to_string())?;
208            if c.count <= 0 || c.window_s <= 0 {
209                return Err("malformed max_rate: count and window_s must be positive".into());
210            }
211            let got = counter(cert_id, c.window_s);
212            if got >= c.count {
213                return Err(format!(
214                    "rate limit exceeded: {} invocations in last {}s (max {})",
215                    got, c.window_s, c.count
216                ));
217            }
218            Ok(())
219        }
220        other => Err(format!(
221            // Sentinel prefix routes the verifier to identity_status=constraint_unknown.
222            "constraint_unknown: unknown constraint type \"{}\"",
223            other
224        )),
225    }
226}
227
228// ---- helpers ----
229
230fn haversine_meters(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
231    let earth_radius_m = 6371000.0_f64;
232    let rad = core::f64::consts::PI / 180.0;
233    let d_lat = (lat2 - lat1) * rad;
234    let d_lon = (lon2 - lon1) * rad;
235    let a = (d_lat / 2.0).sin().powi(2)
236        + (lat1 * rad).cos() * (lat2 * rad).cos() * (d_lon / 2.0).sin().powi(2);
237    2.0 * earth_radius_m * a.sqrt().min(1.0).asin()
238}
239
240/// Returns true if the polygon's longitudes span more than 180°. The only
241/// way to span more than half the globe in longitude is to cross the
242/// anti-meridian, and equirectangular ray-casting can't handle that — so
243/// we refuse these polygons up-front (fail-closed). Mirrors Go's
244/// polygonSpansAntimeridian.
245fn polygon_spans_antimeridian(points: &[[f64; 2]]) -> bool {
246    if points.len() < 3 {
247        return false;
248    }
249    let mut min_lon = points[0][1];
250    let mut max_lon = points[0][1];
251    for p in points {
252        if p[1] < min_lon {
253            min_lon = p[1];
254        }
255        if p[1] > max_lon {
256            max_lon = p[1];
257        }
258    }
259    (max_lon - min_lon) > 180.0
260}
261
262fn point_in_polygon(lat: f64, lon: f64, poly: &[[f64; 2]]) -> bool {
263    let mut inside = false;
264    let n = poly.len();
265    let mut j = n - 1;
266    for i in 0..n {
267        let (yi, xi) = (poly[i][0], poly[i][1]); // lat, lon
268        let (yj, xj) = (poly[j][0], poly[j][1]);
269        if ((yi > lat) != (yj > lat)) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi {
270            inside = !inside;
271        }
272        j = i;
273    }
274    inside
275}
276
277fn parse_hhmm(s: &str) -> Option<i32> {
278    let b = s.as_bytes();
279    if b.len() != 5 || b[2] != b':' {
280        return None;
281    }
282    let h: i32 = s[0..2].parse().ok()?;
283    let m: i32 = s[3..5].parse().ok()?;
284    if !(0..=23).contains(&h) || !(0..=59).contains(&m) {
285        return None;
286    }
287    Some(h * 60 + m)
288}