1#[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
15pub 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 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 return Err(format!(
94 "latitude {:.6} outside [{:.6}, {:.6}]",
95 lat, c.min_lat, c.max_lat
96 ));
97 }
98 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 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 "constraint_unknown: unknown constraint type \"{}\"",
223 other
224 )),
225 }
226}
227
228fn 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
240fn 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]); 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}