66// granted to it by virtue of its status as an intergovernmental organisation nor
77// does it submit to any jurisdiction.
88
9- use anyhow:: { Result , bail} ;
9+ use anyhow:: { Result , anyhow , bail} ;
1010use tracing:: debug;
1111
1212/// Polygon coordinate validator
@@ -22,45 +22,74 @@ impl PolygonHandler {
2222 field_name, value
2323 ) ;
2424
25- // Parse the coordinate string
26- let coordinates = Self :: parse_polygon_coordinates ( value) ?;
25+ let coordinates = Self :: parse_polygon_coordinates ( value) . map_err ( |e| {
26+ anyhow ! ( "field '{}' must be a valid polygon: {}" , field_name, e)
27+ } ) ?;
2728 debug ! (
2829 "Parsed {} coordinate pairs for field '{}'" ,
2930 coordinates. len( ) ,
3031 field_name
3132 ) ;
3233
33- // Validate the polygon
34- Self :: validate_polygon_geometry ( & coordinates) ?;
34+ Self :: validate_polygon_geometry ( & coordinates) . map_err ( |e| {
35+ anyhow ! ( "field '{}' must be a valid polygon: {}" , field_name, e)
36+ } ) ?;
3537 debug ! (
3638 "Polygon geometry validation passed for field '{}'" ,
3739 field_name
3840 ) ;
3941
40- // Return the original validated string
41- // (JSON conversion will happen elsewhere when building the payload)
4242 Ok ( value. to_string ( ) )
4343 }
4444
45- /// Parse a string of coordinates "(lat,lon,lat,lon,...)" into a vector of (lat, lon) tuples.
45+ /// Parse a polygon coordinate string into a vector of ` (lat, lon)` tuples.
4646 ///
47- /// This function ALWAYS returns (lat, lon)
48- /// DO NOT swap here. Only swap to (lon, lat) when passing to geo crate.
47+ /// Accepted forms (whitespace tolerated everywhere):
48+ /// * `"(lat1,lon1,...,lat1,lon1)"` — parenthesised, balanced
49+ /// * `"lat1,lon1,...,lat1,lon1"` — no parentheses
50+ ///
51+ /// Rejected forms (each with a specific error message):
52+ /// * Opening `(` without a matching closing `)` (or vice versa)
53+ /// * Embedded `(` or `)` anywhere except as the single outer pair
54+ /// * Empty string or `()`
55+ /// * Odd number of comma-separated values
56+ /// * Any value that does not parse as `f64`
57+ ///
58+ /// This function ALWAYS returns `(lat, lon)` pairs. DO NOT swap here; only
59+ /// swap to `(lon, lat)` when passing to the `geo` crate.
4960 pub fn parse_polygon_coordinates ( coord_string : & str ) -> Result < Vec < ( f64 , f64 ) > > {
50- let trimmed = coord_string
51- . trim ( )
52- . trim_start_matches ( '(' )
53- . trim_end_matches ( ')' )
54- . trim ( ) ;
55-
56- if trimmed. is_empty ( ) {
57- bail ! ( "Empty polygon coordinate string" ) ;
61+ let raw = coord_string. trim ( ) ;
62+ if raw. is_empty ( ) {
63+ bail ! ( "polygon coordinate string is empty" ) ;
5864 }
5965
60- let coord_parts: Vec < & str > = trimmed. split ( ',' ) . collect ( ) ;
66+ let inner = match ( raw. starts_with ( '(' ) , raw. ends_with ( ')' ) ) {
67+ ( true , true ) => & raw [ 1 ..raw. len ( ) - 1 ] ,
68+ ( false , false ) => raw,
69+ ( true , false ) => bail ! (
70+ "polygon coordinate string has opening '(' but is missing the closing ')'"
71+ ) ,
72+ ( false , true ) => bail ! (
73+ "polygon coordinate string has closing ')' but is missing the opening '('"
74+ ) ,
75+ } ;
76+
77+ if inner. contains ( '(' ) || inner. contains ( ')' ) {
78+ bail ! (
79+ "polygon coordinate string must have at most one outer pair of parentheses; \
80+ nested '(' or ')' are not allowed"
81+ ) ;
82+ }
83+
84+ let inner = inner. trim ( ) ;
85+ if inner. is_empty ( ) {
86+ bail ! ( "polygon coordinate string is empty between parentheses" ) ;
87+ }
88+
89+ let coord_parts: Vec < & str > = inner. split ( ',' ) . collect ( ) ;
6190
6291 if !coord_parts. len ( ) . is_multiple_of ( 2 ) {
63- bail ! ( "Polygon coordinates must be in pairs ( lat,lon)" ) ;
92+ bail ! ( "polygon coordinates must be in lat,lon pairs (got an odd number of values )" ) ;
6493 }
6594
6695 let mut coordinates = Vec :: new ( ) ;
@@ -72,12 +101,12 @@ impl PolygonHandler {
72101 let lat: f64 = lat_str
73102 . trim ( )
74103 . parse ( )
75- . map_err ( |_| anyhow:: anyhow !( "Invalid latitude value: {} " , lat_str) ) ?;
104+ . map_err ( |_| anyhow ! ( "could not parse latitude '{}' as a number " , lat_str. trim ( ) ) ) ?;
76105
77106 let lon: f64 = lon_str
78107 . trim ( )
79108 . parse ( )
80- . map_err ( |_| anyhow:: anyhow !( "Invalid longitude value: {} " , lon_str) ) ?;
109+ . map_err ( |_| anyhow ! ( "could not parse longitude '{}' as a number " , lon_str. trim ( ) ) ) ?;
81110
82111 coordinates. push ( ( lat, lon) ) ;
83112 }
@@ -87,16 +116,18 @@ impl PolygonHandler {
87116
88117 /// Validates polygon geometry requirements
89118 fn validate_polygon_geometry ( coordinates : & [ ( f64 , f64 ) ] ) -> Result < ( ) > {
90- if coordinates. len ( ) < 3 {
91- bail ! ( "Polygon must have at least 3 coordinate pairs" ) ;
119+ if coordinates. len ( ) < 4 {
120+ bail ! (
121+ "polygon must have at least 4 coordinate pairs (3 unique vertices plus a \
122+ closing repeat of the first vertex)"
123+ ) ;
92124 }
93125
94- // Check if polygon is closed (first and last coordinates are the same)
95126 let first = coordinates. first ( ) . unwrap ( ) ;
96127 let last = coordinates. last ( ) . unwrap ( ) ;
97128
98129 if first != last {
99- bail ! ( "Polygon must be closed (first and last coordinates must be identical)" ) ;
130+ bail ! ( "polygon must be closed (first and last coordinates must be identical)" ) ;
100131 }
101132
102133 Ok ( ( ) )
@@ -257,6 +288,58 @@ mod tests {
257288 assert ! ( result. is_err( ) ) ;
258289 }
259290
291+ #[ test]
292+ fn rejects_polygon_with_opening_paren_but_no_closing_paren ( ) {
293+ let coord_string = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0" ;
294+ let err = PolygonHandler :: parse_polygon_coordinates ( coord_string)
295+ . expect_err ( "unbalanced parens must be rejected" ) ;
296+ let msg = err. to_string ( ) ;
297+ assert ! (
298+ msg. contains( "opening" ) && msg. contains( "missing the closing" ) ,
299+ "error should pinpoint the missing closing paren; got: {msg}"
300+ ) ;
301+ }
302+
303+ #[ test]
304+ fn rejects_polygon_with_closing_paren_but_no_opening_paren ( ) {
305+ let coord_string = "50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)" ;
306+ let err = PolygonHandler :: parse_polygon_coordinates ( coord_string)
307+ . expect_err ( "unbalanced parens must be rejected" ) ;
308+ let msg = err. to_string ( ) ;
309+ assert ! (
310+ msg. contains( "closing" ) && msg. contains( "missing the opening" ) ,
311+ "error should pinpoint the missing opening paren; got: {msg}"
312+ ) ;
313+ }
314+
315+ #[ test]
316+ fn rejects_polygon_with_extra_nested_parens ( ) {
317+ let coord_string = "(50.0,10.0),52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)" ;
318+ let err = PolygonHandler :: parse_polygon_coordinates ( coord_string)
319+ . expect_err ( "nested parens must be rejected, not produce a confusing parse error" ) ;
320+ let msg = err. to_string ( ) ;
321+ assert ! (
322+ msg. contains( "nested" ) || msg. contains( "outer pair" ) ,
323+ "error should mention parentheses placement, not e.g. a number-parse failure; got: {msg}"
324+ ) ;
325+ }
326+
327+ #[ test]
328+ fn validate_and_canonicalize_wraps_errors_with_field_name_and_validation_marker ( ) {
329+ // The classifier in handlers::notification_processor matches "field '" and
330+ // "must be a valid" to route polygon errors to a 400 response. This test
331+ // pins both substrings so the public error-classification contract does
332+ // not silently drift.
333+ let bad = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0" ;
334+ let err = PolygonHandler :: validate_and_canonicalize ( bad, "polygon" )
335+ . expect_err ( "unbalanced polygon must error" ) ;
336+ let msg = err. to_string ( ) ;
337+ assert ! (
338+ msg. contains( "field 'polygon'" ) && msg. contains( "must be a valid" ) ,
339+ "error must carry the validation-classifier markers; got: {msg}"
340+ ) ;
341+ }
342+
260343 #[ test]
261344 fn test_validate_polygon_geometry_valid_triangle ( ) {
262345 let coordinates = vec ! [ ( 0.0 , 0.0 ) , ( 1.0 , 0.0 ) , ( 0.5 , 1.0 ) , ( 0.0 , 0.0 ) ] ;
@@ -287,6 +370,22 @@ mod tests {
287370 assert ! ( result. is_err( ) ) ;
288371 }
289372
373+ #[ test]
374+ fn rejects_three_pair_closed_line_segment_as_degenerate_polygon ( ) {
375+ // Three pairs is two unique vertices closed back on the first, i.e. a line
376+ // segment, not a polygon. The downstream geo conversion in
377+ // src/notification/spatial.rs requires 4+ pairs; without rejecting here
378+ // the request silently degraded to a 500 NOTIFICATION_PROCESSING_FAILED.
379+ let coordinates = vec ! [ ( 0.0 , 0.0 ) , ( 1.0 , 0.0 ) , ( 0.0 , 0.0 ) ] ;
380+ let err = PolygonHandler :: validate_polygon_geometry ( & coordinates)
381+ . expect_err ( "3-pair closed line segment must be rejected as a polygon" ) ;
382+ let msg = err. to_string ( ) ;
383+ assert ! (
384+ msg. contains( "at least 4 coordinate pairs" ) ,
385+ "error must specify the new minimum; got: {msg}"
386+ ) ;
387+ }
388+
290389 #[ test]
291390 fn test_validate_polygon_geometry_not_closed ( ) {
292391 let coordinates = vec ! [ ( 0.0 , 0.0 ) , ( 1.0 , 0.0 ) , ( 0.5 , 1.0 ) , ( 0.1 , 0.1 ) ] ; // Not closed
0 commit comments