@@ -346,6 +346,8 @@ public static async Task<bool> DownloadImageWithRetryAsync(
346346 for ( var attempt = 1 ; attempt <= maxRetries ; attempt ++ )
347347 {
348348 cancellationToken . ThrowIfCancellationRequested ( ) ;
349+ var tempPath = outputPath + ".part" ;
350+ var succeeded = false ;
349351
350352 try
351353 {
@@ -367,15 +369,44 @@ public static async Task<bool> DownloadImageWithRetryAsync(
367369 using var response = await httpClient . SendAsync ( request , HttpCompletionOption . ResponseHeadersRead , attemptCts . Token ) ;
368370 response . EnsureSuccessStatusCode ( ) ;
369371
370- var bytes = await response . Content . ReadAsByteArrayAsync ( attemptCts . Token ) ;
372+ var outputDirectory = Path . GetDirectoryName ( outputPath ) ;
373+ if ( ! string . IsNullOrWhiteSpace ( outputDirectory ) )
374+ {
375+ Directory . CreateDirectory ( outputDirectory ) ;
376+ }
377+
378+ await using var responseStream = await response . Content . ReadAsStreamAsync ( attemptCts . Token ) ;
379+ await using var fileStream = new FileStream ( tempPath , FileMode . Create , FileAccess . Write , FileShare . None , 81920 , useAsync : true ) ;
380+
381+ // Capture the first bytes while streaming to validate image signature without buffering full payload.
382+ var header = new byte [ 12 ] ;
383+ var headerCount = 0 ;
384+ var buffer = new byte [ 81920 ] ;
385+ long totalBytes = 0 ;
386+ int read ;
387+
388+ while ( ( read = await responseStream . ReadAsync ( buffer . AsMemory ( 0 , buffer . Length ) , attemptCts . Token ) ) > 0 )
389+ {
390+ if ( headerCount < header . Length )
391+ {
392+ var copyCount = Math . Min ( read , header . Length - headerCount ) ;
393+ Buffer . BlockCopy ( buffer , 0 , header , headerCount , copyCount ) ;
394+ headerCount += copyCount ;
395+ }
396+
397+ await fileStream . WriteAsync ( buffer . AsMemory ( 0 , read ) , attemptCts . Token ) ;
398+ totalBytes += read ;
399+ }
400+
401+ await fileStream . FlushAsync ( attemptCts . Token ) ;
371402
372- // Validate image data (basic check for common image headers)
373- if ( bytes . Length < 8 )
403+ if ( totalBytes < 8 || ! LooksLikeImage ( header , headerCount ) )
374404 {
375- throw new InvalidDataException ( "Downloaded data too small to be a valid image" ) ;
405+ throw new InvalidDataException ( "Downloaded data is not a valid image" ) ;
376406 }
377407
378- await File . WriteAllBytesAsync ( outputPath , bytes , cancellationToken ) ;
408+ File . Move ( tempPath , outputPath , overwrite : true ) ;
409+ succeeded = true ;
379410 return true ;
380411 }
381412 catch ( OperationCanceledException ) when ( ! cancellationToken . IsCancellationRequested )
@@ -394,6 +425,24 @@ public static async Task<bool> DownloadImageWithRetryAsync(
394425 {
395426 logger ? . LogDebug ( ex , "Invalid image data on attempt {Attempt}/{Max}: {Url}" , attempt , maxRetries , imageUrl ) ;
396427 }
428+ finally
429+ {
430+ if ( ! succeeded && File . Exists ( tempPath ) )
431+ {
432+ try
433+ {
434+ File . Delete ( tempPath ) ;
435+ }
436+ catch ( IOException ex )
437+ {
438+ logger ? . LogDebug ( ex , "Failed to clean up partial image file: {Path}" , tempPath ) ;
439+ }
440+ catch ( UnauthorizedAccessException ex )
441+ {
442+ logger ? . LogDebug ( ex , "Failed to clean up partial image file (access denied): {Path}" , tempPath ) ;
443+ }
444+ }
445+ }
397446
398447 if ( attempt < maxRetries )
399448 {
@@ -404,4 +453,45 @@ public static async Task<bool> DownloadImageWithRetryAsync(
404453
405454 return false ;
406455 }
456+
457+ private static bool LooksLikeImage ( byte [ ] header , int length )
458+ {
459+ // JPEG
460+ if ( length >= 3 && header [ 0 ] == 0xFF && header [ 1 ] == 0xD8 && header [ 2 ] == 0xFF )
461+ {
462+ return true ;
463+ }
464+
465+ // PNG
466+ if ( length >= 8 &&
467+ header [ 0 ] == 0x89 && header [ 1 ] == 0x50 && header [ 2 ] == 0x4E && header [ 3 ] == 0x47 &&
468+ header [ 4 ] == 0x0D && header [ 5 ] == 0x0A && header [ 6 ] == 0x1A && header [ 7 ] == 0x0A )
469+ {
470+ return true ;
471+ }
472+
473+ // GIF (GIF87a/GIF89a)
474+ if ( length >= 6 &&
475+ header [ 0 ] == 0x47 && header [ 1 ] == 0x49 && header [ 2 ] == 0x46 &&
476+ header [ 3 ] == 0x38 && ( header [ 4 ] == 0x37 || header [ 4 ] == 0x39 ) && header [ 5 ] == 0x61 )
477+ {
478+ return true ;
479+ }
480+
481+ // BMP
482+ if ( length >= 2 && header [ 0 ] == 0x42 && header [ 1 ] == 0x4D )
483+ {
484+ return true ;
485+ }
486+
487+ // WEBP (RIFF....WEBP)
488+ if ( length >= 12 &&
489+ header [ 0 ] == 0x52 && header [ 1 ] == 0x49 && header [ 2 ] == 0x46 && header [ 3 ] == 0x46 &&
490+ header [ 8 ] == 0x57 && header [ 9 ] == 0x45 && header [ 10 ] == 0x42 && header [ 11 ] == 0x50 )
491+ {
492+ return true ;
493+ }
494+
495+ return false ;
496+ }
407497}
0 commit comments