@@ -189,12 +189,16 @@ impl Client {
189189 let resp = self . send ( & mut req) . await ?;
190190 let body = resp. into_body ( ) ;
191191 serde_json:: from_slice ( & body) . map_err ( |e| {
192+ const MAX_PREVIEW : usize = 512 ;
193+ let truncated = body. len ( ) > MAX_PREVIEW ;
194+ let preview = String :: from_utf8_lossy ( & body[ ..body. len ( ) . min ( MAX_PREVIEW ) ] ) ;
192195 Error :: with_error (
193196 ErrorKind :: DataConversion ,
194197 e,
195198 format ! (
196- "Failed to deserialize response:\n {}" ,
197- String :: from_utf8_lossy( & body)
199+ "Failed to deserialize response:\n {}{}" ,
200+ preview,
201+ if truncated { "…" } else { "" }
198202 ) ,
199203 )
200204 } )
@@ -214,11 +218,10 @@ impl Client {
214218 /// Discover Azure DevOps service URLs via the ResourceAreas API.
215219 /// Returns a map of service name -> location URL.
216220 pub async fn discover_services ( & self , organization : & str ) -> Result < HashMap < String , String > > {
217- let url = Url :: parse ( & format ! (
218- "https://dev.azure.com/{}/_apis/ResourceAreas" ,
219- organization
220- ) )
221- . with_context ( ErrorKind :: DataConversion , "invalid organization URL" ) ?;
221+ let mut url = Url :: parse ( "https://dev.azure.com" ) . expect ( "hardcoded base URL is valid" ) ;
222+ url. path_segments_mut ( )
223+ . expect ( "https URL is always a base" )
224+ . extend ( & [ organization, "_apis" , "ResourceAreas" ] ) ;
222225
223226 let areas: ResourceAreasResponse = self . get_json ( url) . await ?;
224227 let map: HashMap < String , String > = areas
@@ -232,9 +235,9 @@ impl Client {
232235 /// Find the packages service URL from discovered services.
233236 pub fn find_packages_url ( services : & HashMap < String , String > , organization : & str ) -> String {
234237 services
235- . values ( )
236- . find ( |url| url. contains ( "pkgs." ) )
238+ . get ( "packaging" )
237239 . cloned ( )
240+ . or_else ( || services. values ( ) . find ( |url| url. contains ( "pkgs." ) ) . cloned ( ) )
238241 . unwrap_or_else ( || format ! ( "https://pkgs.dev.azure.com/{}" , organization) )
239242 }
240243
@@ -259,15 +262,25 @@ impl Client {
259262 name : & str ,
260263 version : & str ,
261264 ) -> Result < PackageMetadata > {
262- let mut url = Url :: parse ( & format ! (
263- "{}/{}/_packaging/{}/upack/packages/{}/versions/{}" ,
264- packages_url. trim_end_matches( '/' ) ,
265- project,
266- feed,
267- name,
268- version,
269- ) )
270- . with_context ( ErrorKind :: DataConversion , "invalid package metadata URL" ) ?;
265+ let mut url = Url :: parse ( packages_url. trim_end_matches ( '/' ) )
266+ . with_context ( ErrorKind :: DataConversion , "invalid packages URL" ) ?;
267+ url. path_segments_mut ( )
268+ . map_err ( |( ) | {
269+ Error :: with_message (
270+ ErrorKind :: DataConversion ,
271+ "packages URL is not a valid base URL" ,
272+ )
273+ } ) ?
274+ . extend ( & [
275+ project,
276+ "_packaging" ,
277+ feed,
278+ "upack" ,
279+ "packages" ,
280+ name,
281+ "versions" ,
282+ version,
283+ ] ) ;
271284
272285 url. query_pairs_mut ( ) . append_pair ( "intent" , "Download" ) ;
273286 self . get_json ( url) . await
@@ -281,11 +294,16 @@ impl Client {
281294 blob_service_url : & str ,
282295 blob_ids : & [ String ] ,
283296 ) -> Result < HashMap < String , String > > {
284- let mut url = Url :: parse ( & format ! (
285- "{}/_apis/dedup/urls" ,
286- blob_service_url. trim_end_matches( '/' )
287- ) )
288- . with_context ( ErrorKind :: DataConversion , "invalid dedup URL" ) ?;
297+ let mut url = Url :: parse ( blob_service_url. trim_end_matches ( '/' ) )
298+ . with_context ( ErrorKind :: DataConversion , "invalid dedup URL" ) ?;
299+ url. path_segments_mut ( )
300+ . map_err ( |( ) | {
301+ Error :: with_message (
302+ ErrorKind :: DataConversion ,
303+ "dedup service URL is not a valid base URL" ,
304+ )
305+ } ) ?
306+ . extend ( & [ "_apis" , "dedup" , "urls" ] ) ;
289307
290308 url. query_pairs_mut ( ) . append_pair ( "allowEdge" , "true" ) ;
291309
@@ -306,13 +324,18 @@ impl Client {
306324
307325 let resp = self . send ( & mut req) . await ?;
308326 let body = resp. into_body ( ) ;
309- serde_json:: from_slice ( & body) . map_err ( |e| {
327+ let map : HashMap < String , String > = serde_json:: from_slice ( & body) . map_err ( |e| {
310328 Error :: with_error (
311329 ErrorKind :: DataConversion ,
312330 e,
313331 "Failed to parse blob URL response" ,
314332 )
315- } )
333+ } ) ?;
334+ // Normalize keys to uppercase so they always match locally-generated blob IDs.
335+ Ok ( map
336+ . into_iter ( )
337+ . map ( |( k, v) | ( k. to_uppercase ( ) , v) )
338+ . collect ( ) )
316339 }
317340
318341 /// Download a blob from a SAS URL (no auth required).
@@ -385,15 +408,6 @@ impl Client {
385408 chunk_ids. push ( format ! ( "{}01" , hex_hash) ) ;
386409 }
387410
388- if chunk_ids. is_empty ( ) {
389- return Err ( Error :: with_message (
390- ErrorKind :: DataConversion ,
391- format ! (
392- "No chunk references found in dedup node blob ({} bytes)" ,
393- data. len( )
394- ) ,
395- ) ) ;
396- }
397411 Ok ( chunk_ids)
398412 }
399413
@@ -444,12 +458,15 @@ impl Client {
444458 )
445459 } ) ?;
446460
447- // Step 5: Download each file
461+ // Step 5: Batch-resolve all file root blob URLs, then download each file
462+ let all_file_blob_ids: Vec < String > =
463+ manifest. items . iter ( ) . map ( |i| i. blob . id . clone ( ) ) . collect ( ) ;
464+ let all_root_urls = self
465+ . resolve_blob_urls ( & blob_service_url, & all_file_blob_ids)
466+ . await ?;
467+
448468 for item in & manifest. items {
449- let file_root_urls = self
450- . resolve_blob_urls ( & blob_service_url, std:: slice:: from_ref ( & item. blob . id ) )
451- . await ?;
452- let file_root_url = file_root_urls
469+ let file_root_url = all_root_urls
453470 . get ( & item. blob . id )
454471 . ok_or_else ( || Error :: with_message ( ErrorKind :: Other , "File root URL not found" ) ) ?;
455472 let file_root_data = self . download_blob ( file_root_url) . await ?;
@@ -462,7 +479,9 @@ impl Client {
462479 . resolve_blob_urls ( & blob_service_url, & chunk_ids)
463480 . await ?;
464481
465- let mut file_data = Vec :: with_capacity ( item. blob . size as usize ) ;
482+ let mut file_data = usize:: try_from ( item. blob . size )
483+ . map ( Vec :: with_capacity)
484+ . unwrap_or_default ( ) ;
466485 for chunk_id in & chunk_ids {
467486 let chunk_url = chunk_urls. get ( chunk_id) . ok_or_else ( || {
468487 Error :: with_message (
@@ -480,6 +499,12 @@ impl Client {
480499 } ;
481500
482501 let relative_path = item. path . trim_start_matches ( '/' ) ;
502+ if relative_path. split ( '/' ) . any ( |c| c == ".." ) {
503+ return Err ( Error :: with_message (
504+ ErrorKind :: DataConversion ,
505+ format ! ( "Invalid path in manifest: {}" , item. path) ,
506+ ) ) ;
507+ }
483508 let file_path = output_path. join ( relative_path) ;
484509 if let Some ( parent) = file_path. parent ( ) {
485510 std:: fs:: create_dir_all ( parent) . map_err ( |e| {
0 commit comments