Skip to content

Commit b26c918

Browse files
committed
New tests
1 parent 7c3f4bc commit b26c918

File tree

3 files changed

+217
-5
lines changed

3 files changed

+217
-5
lines changed

Koware.Cli/Console/ResilientDownloader.cs

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

Koware.Cli/Program.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7615,6 +7615,11 @@ static void PrintFriendlyCommandHint(string command)
76157615
WriteColoredLine("Example: koware search \"fullmetal alchemist\"", ConsoleColor.Green);
76167616
WriteColoredLine("Tip: try 'koware explore' for the new interactive browser.", ConsoleColor.DarkGray);
76177617
break;
7618+
case "download":
7619+
WriteColoredLine("Command looks incomplete. Add a search query to download content.", ConsoleColor.Yellow);
7620+
WriteColoredLine("Usage: koware download <query> [--episode <n> | --episodes <n-m|all>] [--quality <label>] [--index <match>] [--dir <path>]", ConsoleColor.Cyan);
7621+
WriteColoredLine("Example: koware download \"bleach\" --episode 1 --dir ~/Downloads/Anime", ConsoleColor.Green);
7622+
break;
76187623
case "provider":
76197624
Console.WriteLine("Usage: koware provider [--enable <name> | --disable <name>]");
76207625
Console.WriteLine("Shows provider status or toggles a provider on/off.");
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Author: Ilgaz Mehmetoğlu
2+
// Tests for resilient manga image downloads.
3+
using System;
4+
using System.IO;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Koware.Cli.Console;
10+
using Xunit;
11+
12+
namespace Koware.Tests;
13+
14+
public class ResilientImageDownloaderTests
15+
{
16+
[Fact]
17+
public async Task DownloadImageWithRetryAsync_ValidImage_WritesFileAndCleansTemp()
18+
{
19+
var pngPayload = BuildPayloadWithPngHeader(256 * 1024);
20+
using var handler = new FixedResponseHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
21+
{
22+
Content = new ByteArrayContent(pngPayload)
23+
});
24+
using var httpClient = new HttpClient(handler);
25+
26+
var tempDir = Path.Combine(Path.GetTempPath(), "koware-tests", Guid.NewGuid().ToString("N"));
27+
Directory.CreateDirectory(tempDir);
28+
var outputPath = Path.Combine(tempDir, "page.png");
29+
30+
try
31+
{
32+
var ok = await httpClient.DownloadImageWithRetryAsync(
33+
new Uri("https://example.com/page.png"),
34+
outputPath,
35+
maxRetries: 1);
36+
37+
Assert.True(ok);
38+
Assert.True(File.Exists(outputPath));
39+
Assert.Equal(pngPayload.LongLength, new FileInfo(outputPath).Length);
40+
Assert.False(File.Exists(outputPath + ".part"));
41+
}
42+
finally
43+
{
44+
if (Directory.Exists(tempDir))
45+
{
46+
Directory.Delete(tempDir, recursive: true);
47+
}
48+
}
49+
}
50+
51+
[Fact]
52+
public async Task DownloadImageWithRetryAsync_InvalidPayload_FailsAndRemovesTemp()
53+
{
54+
var invalidPayload = System.Text.Encoding.UTF8.GetBytes("this is not image data");
55+
using var handler = new FixedResponseHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
56+
{
57+
Content = new ByteArrayContent(invalidPayload)
58+
});
59+
using var httpClient = new HttpClient(handler);
60+
61+
var tempDir = Path.Combine(Path.GetTempPath(), "koware-tests", Guid.NewGuid().ToString("N"));
62+
Directory.CreateDirectory(tempDir);
63+
var outputPath = Path.Combine(tempDir, "page.bin");
64+
65+
try
66+
{
67+
var ok = await httpClient.DownloadImageWithRetryAsync(
68+
new Uri("https://example.com/page.bin"),
69+
outputPath,
70+
maxRetries: 1);
71+
72+
Assert.False(ok);
73+
Assert.False(File.Exists(outputPath));
74+
Assert.False(File.Exists(outputPath + ".part"));
75+
}
76+
finally
77+
{
78+
if (Directory.Exists(tempDir))
79+
{
80+
Directory.Delete(tempDir, recursive: true);
81+
}
82+
}
83+
}
84+
85+
private static byte[] BuildPayloadWithPngHeader(int size)
86+
{
87+
if (size < 8)
88+
{
89+
throw new ArgumentOutOfRangeException(nameof(size), "Payload must be at least 8 bytes.");
90+
}
91+
92+
var bytes = new byte[size];
93+
bytes[0] = 0x89;
94+
bytes[1] = 0x50;
95+
bytes[2] = 0x4E;
96+
bytes[3] = 0x47;
97+
bytes[4] = 0x0D;
98+
bytes[5] = 0x0A;
99+
bytes[6] = 0x1A;
100+
bytes[7] = 0x0A;
101+
102+
for (var i = 8; i < size; i++)
103+
{
104+
bytes[i] = (byte)(i % 251);
105+
}
106+
107+
return bytes;
108+
}
109+
110+
private sealed class FixedResponseHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
111+
{
112+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
113+
{
114+
return Task.FromResult(responder(request));
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)