@@ -18,9 +18,63 @@ pub fn validate_mesh_server_name(name: &str) -> ConfigResult<()> {
1818 Ok ( ( ) )
1919}
2020
21+ /// Validate a single worker URL: non-empty, an allowed scheme, and a
22+ /// parseable host. Shared by [`ConfigValidator::validate_urls`] (startup
23+ /// config) and the worker-management API so both reject schemeless or
24+ /// unparsable URLs identically. Rejecting at the API boundary prevents
25+ /// the orphaned `url_to_id` reservation from #1533: the AddWorker
26+ /// workflow rewrites schemeless input via `normalize_url`, so a
27+ /// reservation keyed on the raw URL would never match the registered
28+ /// worker.
29+ pub fn validate_worker_url ( url : & str ) -> ConfigResult < ( ) > {
30+ if url. is_empty ( ) {
31+ return Err ( ConfigError :: InvalidValue {
32+ field : "worker_url" . to_string ( ) ,
33+ value : url. to_string ( ) ,
34+ reason : "URL cannot be empty" . to_string ( ) ,
35+ } ) ;
36+ }
37+
38+ // Exact (lowercase) scheme allow-list. Case-insensitive matching is
39+ // tempting but wrong here: the AddWorker workflow's normalize_url
40+ // matches schemes case-sensitively, so a mixed-case scheme would be
41+ // rewritten downstream and diverge from the reservation key — the
42+ // same orphan failure as a schemeless URL.
43+ const ALLOWED_SCHEMES : & [ & str ] = & [ "http" , "https" , "grpc" , "grpcs" ] ;
44+ let scheme = url. split_once ( "://" ) . map_or ( "" , |( s, _) | s) ;
45+ if !ALLOWED_SCHEMES . contains ( & scheme) {
46+ return Err ( ConfigError :: InvalidValue {
47+ field : "worker_url" . to_string ( ) ,
48+ value : url. to_string ( ) ,
49+ reason :
50+ "URL must start with a lowercase http://, https://, grpc://, or grpcs:// scheme"
51+ . to_string ( ) ,
52+ } ) ;
53+ }
54+
55+ match :: url:: Url :: parse ( url) {
56+ Ok ( parsed) => {
57+ if parsed. host_str ( ) . is_none ( ) {
58+ return Err ( ConfigError :: InvalidValue {
59+ field : "worker_url" . to_string ( ) ,
60+ value : url. to_string ( ) ,
61+ reason : "URL must have a valid host" . to_string ( ) ,
62+ } ) ;
63+ }
64+ }
65+ Err ( e) => {
66+ return Err ( ConfigError :: InvalidValue {
67+ field : "worker_url" . to_string ( ) ,
68+ value : url. to_string ( ) ,
69+ reason : format ! ( "Invalid URL format: {e}" ) ,
70+ } ) ;
71+ }
72+ }
73+ Ok ( ( ) )
74+ }
75+
2176/// Configuration validator
2277pub ( crate ) struct ConfigValidator ;
23-
2478impl ConfigValidator {
2579 pub ( crate ) fn validate ( config : & RouterConfig ) -> ConfigResult < ( ) > {
2680 Self :: validate_mode ( & config. mode ) ?;
@@ -958,48 +1012,7 @@ impl ConfigValidator {
9581012
9591013 fn validate_urls ( urls : & [ String ] ) -> ConfigResult < ( ) > {
9601014 for url in urls {
961- if url. is_empty ( ) {
962- return Err ( ConfigError :: InvalidValue {
963- field : "worker_url" . to_string ( ) ,
964- value : url. clone ( ) ,
965- reason : "URL cannot be empty" . to_string ( ) ,
966- } ) ;
967- }
968-
969- // Case-insensitive scheme allow-list. Compare just the scheme
970- // segment so we don't allocate a lowercased copy of the full URL.
971- const ALLOWED_SCHEMES : & [ & str ] = & [ "http" , "https" , "grpc" , "grpcs" ] ;
972- let scheme = url. split_once ( "://" ) . map_or ( "" , |( s, _) | s) ;
973- if !ALLOWED_SCHEMES
974- . iter ( )
975- . any ( |allowed| scheme. eq_ignore_ascii_case ( allowed) )
976- {
977- return Err ( ConfigError :: InvalidValue {
978- field : "worker_url" . to_string ( ) ,
979- value : url. clone ( ) ,
980- reason : "URL must start with http://, https://, grpc://, or grpcs://"
981- . to_string ( ) ,
982- } ) ;
983- }
984-
985- match :: url:: Url :: parse ( url) {
986- Ok ( parsed) => {
987- if parsed. host_str ( ) . is_none ( ) {
988- return Err ( ConfigError :: InvalidValue {
989- field : "worker_url" . to_string ( ) ,
990- value : url. clone ( ) ,
991- reason : "URL must have a valid host" . to_string ( ) ,
992- } ) ;
993- }
994- }
995- Err ( e) => {
996- return Err ( ConfigError :: InvalidValue {
997- field : "worker_url" . to_string ( ) ,
998- value : url. clone ( ) ,
999- reason : format ! ( "Invalid URL format: {e}" ) ,
1000- } ) ;
1001- }
1002- }
1015+ validate_worker_url ( url) ?;
10031016 }
10041017 Ok ( ( ) )
10051018 }
@@ -1031,6 +1044,56 @@ mod tests {
10311044 assert ! ( validate_mesh_server_name( "node-a" ) . is_ok( ) ) ;
10321045 }
10331046
1047+ #[ test]
1048+ fn worker_url_accepts_allowed_schemes ( ) {
1049+ for url in [
1050+ "http://10.0.0.5:8000" ,
1051+ "https://worker.example.com" ,
1052+ "grpc://10.0.0.5:50051" ,
1053+ "grpcs://worker.example.com:443" ,
1054+ ] {
1055+ assert ! ( validate_worker_url( url) . is_ok( ) , "expected {url} to pass" ) ;
1056+ }
1057+ }
1058+
1059+ #[ test]
1060+ fn worker_url_rejects_non_lowercase_scheme ( ) {
1061+ // normalize_url in the AddWorker workflow matches schemes
1062+ // case-sensitively, so `HTTP://…` would be mangled into
1063+ // `http://HTTP://…` and orphan the reservation just like a
1064+ // schemeless URL would.
1065+ assert ! ( validate_worker_url( "HTTP://10.0.0.5:8000" ) . is_err( ) ) ;
1066+ assert ! ( validate_worker_url( "Grpc://10.0.0.5:50051" ) . is_err( ) ) ;
1067+ }
1068+
1069+ #[ test]
1070+ fn worker_url_rejects_empty ( ) {
1071+ assert ! ( matches!(
1072+ validate_worker_url( "" ) ,
1073+ Err ( ConfigError :: InvalidValue { ref field, .. } ) if field == "worker_url"
1074+ ) ) ;
1075+ }
1076+
1077+ #[ test]
1078+ fn worker_url_rejects_schemeless_host_port ( ) {
1079+ // The #1533 case: bare host:port input would be rewritten by the
1080+ // AddWorker workflow, orphaning the API-layer ID reservation.
1081+ assert ! ( matches!(
1082+ validate_worker_url( "10.0.0.5:8000" ) ,
1083+ Err ( ConfigError :: InvalidValue { ref field, .. } ) if field == "worker_url"
1084+ ) ) ;
1085+ }
1086+
1087+ #[ test]
1088+ fn worker_url_rejects_disallowed_scheme ( ) {
1089+ assert ! ( validate_worker_url( "ftp://example.com:21" ) . is_err( ) ) ;
1090+ }
1091+
1092+ #[ test]
1093+ fn worker_url_rejects_unparsable ( ) {
1094+ assert ! ( validate_worker_url( "http://" ) . is_err( ) ) ;
1095+ }
1096+
10341097 #[ test]
10351098 fn test_validate_regular_mode ( ) {
10361099 let config = RouterConfig :: new (
0 commit comments