@@ -69,6 +69,68 @@ fn find_entry(map: &serde_json::Map<String, Value>, key: &str) -> Option<(String
6969 }
7070}
7171
72+ /// Returns `true` when `path` equals `context_path` or starts with `context_path/`.
73+ ///
74+ /// This prevents a broker context path such as `/pact` from falsely matching an
75+ /// unrelated path like `/pactfoo/bar` via a naive `starts_with` check.
76+ fn has_context_prefix ( path : & str , context_path : & str ) -> bool {
77+ path == context_path || path. starts_with ( & format ! ( "{}/" , context_path) )
78+ }
79+
80+ /// Extracts the path component from `url_string` (stripping scheme/host if it is an
81+ /// absolute URL) and prepends `broker_url`'s context path when it is missing.
82+ ///
83+ /// Accepts an already-parsed `broker_url` so callers that have parsed it for
84+ /// another purpose need not parse it a second time.
85+ fn normalize_path_inner ( url_string : & str , broker_url : & Url ) -> String {
86+ let mut path = if let Ok ( parsed_url) = url_string. parse :: < Url > ( ) {
87+ // Absolute URL: extract only the path, so we keep the broker's host.
88+ parsed_url. path ( ) . to_string ( )
89+ } else {
90+ // Already a path (relative or absolute).
91+ url_string. to_string ( )
92+ } ;
93+
94+ let context_path = broker_url. path ( ) ;
95+ if !context_path. is_empty ( ) && context_path != "/" && path. starts_with ( '/' ) {
96+ if !has_context_prefix ( & path, context_path) {
97+ debug ! ( "Prepending context path '{}' to path '{}'" , context_path, path) ;
98+ path = format ! ( "{}{}" , context_path, path) ;
99+ }
100+ }
101+ path
102+ }
103+
104+ /// Resolves a normalised `path` against an already-parsed `broker_url`,
105+ /// respecting any context path embedded in the broker URL.
106+ ///
107+ /// Accepts ownership of `broker_url` so callers that have already parsed it
108+ /// can pass it through without a second `parse::<Url>()` call.
109+ fn resolve_path_inner ( path : & str , broker_url : Url ) -> Result < Url , PactBrokerError > {
110+ let context_path = broker_url. path ( ) . to_string ( ) ;
111+ let url = if path. is_empty ( ) {
112+ broker_url
113+ } else if !context_path. is_empty ( ) && context_path != "/" {
114+ if has_context_prefix ( path, & context_path) {
115+ let mut base_url = broker_url;
116+ base_url. set_path ( "/" ) ;
117+ base_url. join ( path) ?
118+ } else if path. starts_with ( '/' ) {
119+ let mut base_url = broker_url;
120+ base_url. set_path ( path) ;
121+ base_url
122+ } else {
123+ let mut base_url = broker_url;
124+ let cp = format ! ( "{}/" , context_path) ;
125+ base_url. set_path ( & cp) ;
126+ base_url. join ( path) ?
127+ }
128+ } else {
129+ broker_url. join ( path) ?
130+ } ;
131+ Ok ( url)
132+ }
133+
72134/// Errors that can occur with a Pact Broker
73135#[ derive( Debug , Clone , thiserror:: Error ) ]
74136pub enum PactBrokerError {
@@ -340,16 +402,24 @@ impl HALClient {
340402 ) )
341403 } ?;
342404
343- let base_url = self . url . parse :: < Url > ( ) ?;
344- let joined_url = base_url. join ( & link_url) ?;
345- self . fetch ( joined_url. path ( ) . into ( ) ) . await
405+ // Parse broker URL once and use both inner functions to avoid double-parsing.
406+ let broker_url = self . url . parse :: < Url > ( ) ?;
407+ let path = normalize_path_inner ( & link_url, & broker_url) ;
408+ let url = resolve_path_inner ( & path, broker_url) ?;
409+ self . fetch_with_url ( & path, url) . await
346410 }
347411
348412 async fn fetch ( & self , path : & str ) -> Result < Value , PactBrokerError > {
349413 info ! ( "Fetching path '{}' from pact broker" , path) ;
350414 trace ! ( %path, broker_url = %self . url, ">> fetch" ) ;
351-
352415 let url = self . resolve_path ( path) ?;
416+ self . fetch_with_url ( path, url) . await
417+ }
418+
419+ /// Performs the actual GET request using an already-resolved `url`.
420+ async fn fetch_with_url ( & self , path : & str , url : Url ) -> Result < Value , PactBrokerError > {
421+ info ! ( "Fetching path '{}' from pact broker" , path) ;
422+ trace ! ( %path, broker_url = %self . url, ">> fetch_with_url" ) ;
353423 debug ! ( "Final broker URL: {}" , url) ;
354424
355425 let request_builder = match self . auth {
@@ -364,41 +434,18 @@ impl HALClient {
364434 let response = with_retries ( self . retries , request_builder) . await
365435 . map_err ( |err| {
366436 PactBrokerError :: IoError ( format ! ( "Failed to access pact broker path '{}' - {}. URL: '{}'" ,
367- & path,
437+ path,
368438 err,
369439 & self . url,
370440 ) )
371441 } ) ?;
372442
373- self . parse_broker_response ( path. to_string ( ) , response)
374- . await
443+ self . parse_broker_response ( path. to_string ( ) , response) . await
375444 }
376445
377446 fn resolve_path ( & self , path : & str ) -> Result < Url , PactBrokerError > {
378447 let broker_url = self . url . parse :: < Url > ( ) ?;
379- let context_path = broker_url. path ( ) ;
380- let url = if path. is_empty ( ) {
381- broker_url
382- } else if !context_path. is_empty ( ) && context_path != "/" {
383- if path. starts_with ( context_path) {
384- let mut base_url = broker_url. clone ( ) ;
385- base_url. set_path ( "/" ) ;
386- base_url. join ( path) ?
387- } else if path. starts_with ( "/" ) {
388- let mut base_url = broker_url. clone ( ) ;
389- base_url. set_path ( path) ;
390- base_url
391- } else {
392- let mut base_url = broker_url. clone ( ) ;
393- let mut cp = context_path. to_string ( ) ;
394- cp. push ( '/' ) ;
395- base_url. set_path ( cp. as_str ( ) ) ;
396- base_url. join ( path) ?
397- }
398- } else {
399- broker_url. join ( path) ?
400- } ;
401- Ok ( url)
448+ resolve_path_inner ( path, broker_url)
402449 }
403450
404451 async fn parse_broker_response (
@@ -528,29 +575,10 @@ impl HALClient {
528575 async fn send_document ( & self , url : & str , body : & str , method : Method ) -> Result < Value , PactBrokerError > {
529576 debug ! ( "Sending JSON to {} using {}: {}" , url, method, body) ;
530577
531- // Extract path from URL if it's absolute (like fetch_url does), then resolve with context path
532- let mut path = if let Ok ( link_as_url) = url. parse :: < Url > ( ) {
533- // URL is absolute, extract path to use broker's original host and context path
534- link_as_url. path ( ) . to_string ( )
535- } else {
536- // URL is already a path (relative)
537- url. to_string ( )
538- } ;
539-
540- // If we have a context path and the path doesn't already include it, prepend it
578+ // Parse broker URL once and use inner functions for both steps.
541579 let broker_url = self . url . parse :: < Url > ( ) ?;
542- let context_path = broker_url. path ( ) ;
543- if !context_path. is_empty ( ) && context_path != "/" && path. starts_with ( "/" ) {
544- let context_with_slash = format ! ( "{}/" , context_path) ;
545- let path_matches_context = path == context_path || path. starts_with ( & context_with_slash) ;
546- if !path_matches_context {
547- // Path doesn't include context path, prepend it
548- let full_path = format ! ( "{}{}" , context_path, path) ;
549- path = full_path;
550- }
551- }
552-
553- let url = self . resolve_path ( path. as_str ( ) ) ?;
580+ let path = normalize_path_inner ( url, & broker_url) ;
581+ let url = resolve_path_inner ( & path, broker_url) ?;
554582
555583 let request_builder = match self . auth {
556584 Some ( ref auth) => match auth {
@@ -2695,17 +2723,59 @@ mod tests {
26952723
26962724
26972725 #[ test]
2698- fn resolve_path_handles_absolute_links_correctly ( ) {
2726+ fn resolve_path_with_context_prefixed_path ( ) {
2727+ // Path already contains the context prefix — resolve_path must not double-prepend it.
26992728 let client = HALClientBuilder :: builder ( )
27002729 . with_url ( "http://127.0.0.1:8080/pact" , None )
27012730 . build ( ) ;
2702-
2731+
27032732 let path = "/pact/pacts/provider/Example%20API/for-verification" ;
27042733 let resolved = client. resolve_path ( path) . expect ( "Should resolve path" ) ;
2705-
2734+
27062735 expect ! ( resolved. path( ) ) . to ( be_equal_to ( "/pact/pacts/provider/Example%20API/for-verification" ) ) ;
27072736 }
27082737
2738+ #[ test]
2739+ fn resolve_path_does_not_match_context_prefix_without_boundary ( ) {
2740+ // "/pactfoo/bar" starts with "/pact" lexically but must NOT be treated as
2741+ // already having context path "/pact".
2742+ let client = HALClientBuilder :: builder ( )
2743+ . with_url ( "http://127.0.0.1:8080/pact" , None )
2744+ . build ( ) ;
2745+
2746+ let path = "/pactfoo/bar" ;
2747+ let resolved = client. resolve_path ( path) . expect ( "Should resolve path" ) ;
2748+
2749+ // The path does not start with /pact/, so it is treated as an absolute path
2750+ // without a context prefix and set directly.
2751+ expect ! ( resolved. path( ) ) . to ( be_equal_to ( "/pactfoo/bar" ) ) ;
2752+ }
2753+
2754+ #[ test]
2755+ fn normalize_path_from_url_prepends_context_for_absolute_url_missing_context ( ) {
2756+ // Broker lives at http://host/pact but the link contains an absolute URL
2757+ // from a different host that omits the context path. normalize_path_inner
2758+ // must strip the foreign host and prepend the broker's context path.
2759+ let broker_url = "http://127.0.0.1:8080/pact" . parse :: < Url > ( ) . unwrap ( ) ;
2760+ let absolute_link = "http://other-broker.example.com/pacts/provider/Example%20API/for-verification" ;
2761+
2762+ let normalized = normalize_path_inner ( absolute_link, & broker_url) ;
2763+
2764+ expect ! ( normalized) . to ( be_equal_to ( "/pact/pacts/provider/Example%20API/for-verification" ) ) ;
2765+ }
2766+
2767+ #[ test]
2768+ fn normalize_path_from_url_does_not_double_prepend_context_path ( ) {
2769+ // When the absolute URL already carries the correct context path the
2770+ // function must leave it unchanged.
2771+ let broker_url = "http://127.0.0.1:8080/pact" . parse :: < Url > ( ) . unwrap ( ) ;
2772+ let absolute_link = "http://other-broker.example.com/pact/pacts/provider/Example%20API/latest" ;
2773+
2774+ let normalized = normalize_path_inner ( absolute_link, & broker_url) ;
2775+
2776+ expect ! ( normalized) . to ( be_equal_to ( "/pact/pacts/provider/Example%20API/latest" ) ) ;
2777+ }
2778+
27092779 #[ test_log:: test( tokio:: test) ]
27102780 async fn subpath_broker_templates_are_substituted ( ) {
27112781 let pact_broker = PactBuilderAsync :: new ( "RustPactVerifier" , "TemplateTest" )
0 commit comments