Fast .NET CLI ISO Downloader with Integrity Validation

This post is a follow-up to my previous fast .NET CLI downloader with the additional feature of ISO integrity validation. Just point it at the ISO you want to download, and it will look in the usual spot for the SHA sum file.

I often download versions of Linux distributions to try out. These are very large and usually come with a checksum file that you have to manually check after downloading. The checksum verification is important because a corrupted ISO can cause all sorts of weird issues when you try to use it.

It happened to me once, back when I used to burn ISOs to DVDs. The install kept failing, and I kept blaming the DVD until I finally realized the ISO file was corrupted; I hadn’t run the checksum verification.

Using it is as simple as -

./downloader.cs https://releases.ubuntu.com/resolute/ubuntu-26.04-desktop-amd64.iso

But the command supports some additional options as well -

Usage: ./downloader.cs <url> [output-file] [chunks]
  url          - URL to download
  output-file  - Output filename (default: derived from URL)
  chunks       - Number of parallel streams (default: 8)

Downloading Ubuntu 26.04
Downloading Ubuntu 26.04
  1#!/usr/bin/dotnet run
  2
  3using System.Diagnostics;
  4using System.Net.Http.Headers;
  5using System.Security.Cryptography;
  6
  7const int DefaultChunks = 8;
  8const int MaxRetries = 5;
  9const int RetryDelayMs = 1000;
 10const int ProgressUpdateMs = 250;
 11
 12if (args.Length < 1 || args[0] is "-h" or "--help")
 13{
 14    PrintUsage();
 15    return args.Length < 1 ? 1 : 0;
 16}
 17
 18var url = args[0];
 19var outputFile = args.Length > 1 ? args[1] : Path.GetFileName(new Uri(url).LocalPath);
 20var chunks = args.Length > 2 ? int.Parse(args[2]) : DefaultChunks;
 21
 22if (string.IsNullOrWhiteSpace(outputFile) || outputFile == "/")
 23    outputFile = "download";
 24
 25Console.WriteLine($"URL:     {url}");
 26Console.WriteLine($"Output:  {outputFile}");
 27Console.WriteLine($"Streams: {chunks}");
 28Console.WriteLine();
 29
 30var isIso = string.Equals(Path.GetExtension(outputFile), ".iso", StringComparison.OrdinalIgnoreCase);
 31
 32using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
 33client.DefaultRequestHeaders.UserAgent.ParseAdd("DownloaderCLI/1.0");
 34
 35string? checksumFilePath = null;
 36if (isIso)
 37{
 38    checksumFilePath = await TryDownloadChecksumFileForIso(client, url, outputFile);
 39    Console.WriteLine();
 40}
 41
 42// Probe the server for content-length and range support
 43using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
 44using var headResp = await client.SendAsync(headReq);
 45headResp.EnsureSuccessStatusCode();
 46
 47var totalSize = headResp.Content.Headers.ContentLength ?? -1;
 48var acceptRanges = headResp.Headers.Contains("Accept-Ranges")
 49    && headResp.Headers.GetValues("Accept-Ranges").Any(v => v.Contains("bytes", StringComparison.OrdinalIgnoreCase));
 50
 51if (totalSize <= 0 || !acceptRanges)
 52{
 53    Console.WriteLine(totalSize <= 0
 54        ? "Server did not report content length - falling back to single-stream download."
 55        : "Server does not support range requests - falling back to single-stream download.");
 56    Console.WriteLine();
 57    await SingleStreamDownload(client, url, outputFile);
 58    if (isIso)
 59    {
 60        var valid = await ValidateIsoAsync(outputFile, checksumFilePath);
 61        return valid ? 0 : 2;
 62    }
 63
 64    return 0;
 65}
 66
 67Console.WriteLine($"Size:    {FormatBytes(totalSize)}");
 68Console.WriteLine($"Ranges:  supported");
 69Console.WriteLine();
 70
 71var sw = Stopwatch.StartNew();
 72var chunkInfos = BuildChunks(totalSize, chunks);
 73var progress = new long[chunkInfos.Count];
 74
 75// Progress reporter
 76using var cts = new CancellationTokenSource();
 77var progressTask = Task.Run(async () =>
 78{
 79    while (!cts.Token.IsCancellationRequested)
 80    {
 81        PrintProgress(progress, chunkInfos, totalSize, sw.Elapsed);
 82        try { await Task.Delay(ProgressUpdateMs, cts.Token); } catch (TaskCanceledException) { break; }
 83    }
 84});
 85
 86// Download all chunks in parallel
 87var tempFiles = new string[chunkInfos.Count];
 88var downloadTasks = new Task[chunkInfos.Count];
 89
 90for (int i = 0; i < chunkInfos.Count; i++)
 91{
 92    var idx = i;
 93    var (start, end) = chunkInfos[idx];
 94    tempFiles[idx] = $"{outputFile}.part{idx}";
 95    downloadTasks[idx] = DownloadChunk(client, url, start, end, tempFiles[idx], progress, idx);
 96}
 97
 98await Task.WhenAll(downloadTasks);
 99
100cts.Cancel();
101await progressTask;
102PrintProgress(progress, chunkInfos, totalSize, sw.Elapsed);
103Console.WriteLine();
104Console.WriteLine();
105
106// Reassemble
107Console.Write("Reassembling... ");
108await Reassemble(tempFiles, outputFile);
109Console.WriteLine("done.");
110
111// Cleanup temp files
112foreach (var f in tempFiles)
113    if (File.Exists(f)) File.Delete(f);
114
115sw.Stop();
116var info = new FileInfo(outputFile);
117Console.WriteLine($"Completed in {sw.Elapsed.TotalSeconds:F1}s - {FormatBytes(info.Length)} @ {FormatBytes((long)(info.Length / sw.Elapsed.TotalSeconds))}/s");
118
119if (isIso)
120{
121    var valid = await ValidateIsoAsync(outputFile, checksumFilePath);
122    return valid ? 0 : 2;
123}
124
125return 0;
126
127// ---- helper methods ----
128
129static List<(long Start, long End)> BuildChunks(long totalSize, int count)
130{
131    var chunkSize = totalSize / count;
132    var result = new List<(long, long)>(count);
133    for (int i = 0; i < count; i++)
134    {
135        var start = i * chunkSize;
136        var end = (i == count - 1) ? totalSize - 1 : start + chunkSize - 1;
137        result.Add((start, end));
138    }
139    return result;
140}
141
142static async Task DownloadChunk(HttpClient client, string url, long start, long end,
143    string tempFile, long[] progress, int index)
144{
145    for (int attempt = 1; attempt <= MaxRetries; attempt++)
146    {
147        try
148        {
149            // Resume from where we left off if retrying
150            long existingBytes = 0;
151            if (File.Exists(tempFile))
152            {
153                existingBytes = new FileInfo(tempFile).Length;
154                if (existingBytes >= end - start + 1)
155                {
156                    progress[index] = end - start + 1;
157                    return; // already complete
158                }
159            }
160
161            using var req = new HttpRequestMessage(HttpMethod.Get, url);
162            req.Headers.Range = new RangeHeaderValue(start + existingBytes, end);
163
164            using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
165            resp.EnsureSuccessStatusCode();
166
167            await using var stream = await resp.Content.ReadAsStreamAsync();
168            await using var fs = new FileStream(tempFile, existingBytes > 0 ? FileMode.Append : FileMode.Create,
169                FileAccess.Write, FileShare.None, 81920, useAsync: true);
170
171            var buffer = new byte[81920];
172            long downloaded = existingBytes;
173            int bytesRead;
174
175            while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
176            {
177                await fs.WriteAsync(buffer.AsMemory(0, bytesRead));
178                downloaded += bytesRead;
179                Interlocked.Exchange(ref progress[index], downloaded);
180            }
181
182            return; // success
183        }
184        catch (Exception ex) when (attempt < MaxRetries)
185        {
186            Console.Error.WriteLine($"\n  [chunk {index}] attempt {attempt} failed: {ex.Message} - retrying...");
187            await Task.Delay(RetryDelayMs * attempt);
188        }
189    }
190}
191
192static async Task SingleStreamDownload(HttpClient client, string url, string outputFile)
193{
194    var sw = Stopwatch.StartNew();
195    using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
196    resp.EnsureSuccessStatusCode();
197
198    var total = resp.Content.Headers.ContentLength ?? -1;
199    await using var stream = await resp.Content.ReadAsStreamAsync();
200    await using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
201
202    var buffer = new byte[81920];
203    long downloaded = 0;
204    int bytesRead;
205    var lastUpdate = DateTimeOffset.UtcNow;
206
207    while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
208    {
209        await fs.WriteAsync(buffer.AsMemory(0, bytesRead));
210        downloaded += bytesRead;
211
212        if ((DateTimeOffset.UtcNow - lastUpdate).TotalMilliseconds >= ProgressUpdateMs)
213        {
214            lastUpdate = DateTimeOffset.UtcNow;
215            var pct = total > 0 ? (double)downloaded / total * 100 : 0;
216            var speed = downloaded / sw.Elapsed.TotalSeconds;
217            Console.Write($"\r  [{pct,5:F1}%] {FormatBytes(downloaded)}{(total > 0 ? $" / {FormatBytes(total)}" : "")}  {FormatBytes((long)speed)}/s   ");
218        }
219    }
220
221    sw.Stop();
222    Console.WriteLine($"\r  [100.0%] {FormatBytes(downloaded)}  {FormatBytes((long)(downloaded / sw.Elapsed.TotalSeconds))}/s - done.          ");
223}
224
225static async Task Reassemble(string[] parts, string outputFile)
226{
227    await using var outFs = new FileStream(outputFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
228    foreach (var part in parts)
229    {
230        await using var inFs = new FileStream(part, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
231        await inFs.CopyToAsync(outFs);
232    }
233}
234
235static async Task<string?> TryDownloadChecksumFileForIso(HttpClient client, string isoUrl, string outputFile)
236{
237    var outputDir = Path.GetDirectoryName(outputFile);
238    if (string.IsNullOrWhiteSpace(outputDir))
239        outputDir = ".";
240
241    Directory.CreateDirectory(outputDir);
242
243    var isoUri = new Uri(isoUrl);
244    var baseUri = new Uri(isoUri, ".");
245    string[] candidateNames = ["SHA256SUMS", "SHA256SUMS.txt", "sha256sum.txt", "SHA256SUM", "sha256sums"];
246
247    Console.WriteLine("ISO detected: looking for checksum file in source directory...");
248    foreach (var candidate in candidateNames)
249    {
250        try
251        {
252            var checksumUri = new Uri(baseUri, candidate);
253            using var resp = await client.GetAsync(checksumUri);
254            if (!resp.IsSuccessStatusCode)
255                continue;
256
257            var content = await resp.Content.ReadAsStringAsync();
258            if (string.IsNullOrWhiteSpace(content))
259                continue;
260
261            var localPath = Path.Combine(outputDir, candidate);
262            await File.WriteAllTextAsync(localPath, content);
263            Console.WriteLine($"Checksum file downloaded: {candidate}");
264            return localPath;
265        }
266        catch
267        {
268            // Try next known checksum filename.
269        }
270    }
271
272    Console.WriteLine("No checksum file found; ISO integrity check will use structure validation.");
273    return null;
274}
275
276static async Task<bool> ValidateIsoAsync(string isoPath, string? checksumFilePath)
277{
278    Console.WriteLine();
279    Console.WriteLine("Validating ISO image...");
280
281    if (!File.Exists(isoPath))
282    {
283        Console.Error.WriteLine("Validation failed: ISO file not found.");
284        return false;
285    }
286
287    var actualSha256 = await ComputeSha256Async(isoPath);
288
289    if (!string.IsNullOrWhiteSpace(checksumFilePath) && File.Exists(checksumFilePath))
290    {
291        var expectedSha256 = await GetExpectedSha256ForFileAsync(checksumFilePath, Path.GetFileName(isoPath));
292        if (!string.IsNullOrWhiteSpace(expectedSha256))
293        {
294            var valid = string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase);
295            Console.WriteLine($"Expected SHA256: {expectedSha256}");
296            Console.WriteLine($"Actual   SHA256: {actualSha256}");
297            Console.WriteLine(valid ? "ISO checksum validation passed." : "ISO checksum validation failed.");
298            return valid;
299        }
300
301        Console.WriteLine("Checksum file was downloaded but no matching hash entry was found for this ISO.");
302    }
303
304    var structureValid = await HasIso9660SignatureAsync(isoPath);
305    Console.WriteLine($"Computed SHA256: {actualSha256}");
306    Console.WriteLine(structureValid
307        ? "ISO structure validation passed (ISO9660 signature found)."
308        : "ISO structure validation failed (ISO9660 signature not found).");
309    return structureValid;
310}
311
312static async Task<string> ComputeSha256Async(string path)
313{
314    await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
315    var hash = await SHA256.HashDataAsync(fs);
316    return Convert.ToHexString(hash).ToLowerInvariant();
317}
318
319static async Task<string?> GetExpectedSha256ForFileAsync(string checksumFilePath, string fileName)
320{
321    var lines = await File.ReadAllLinesAsync(checksumFilePath);
322    foreach (var rawLine in lines)
323    {
324        var line = rawLine.Trim();
325        if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
326            continue;
327
328        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
329        if (parts.Length < 2)
330            continue;
331
332        var hash = parts[0];
333        if (hash.Length != 64 || !hash.All(Uri.IsHexDigit))
334            continue;
335
336        var listedName = parts[^1].TrimStart('*');
337        listedName = Path.GetFileName(listedName);
338        if (string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase))
339            return hash.ToLowerInvariant();
340    }
341
342    return null;
343}
344
345static async Task<bool> HasIso9660SignatureAsync(string path)
346{
347    const int signatureOffset = 0x8001;
348    const int signatureLength = 5;
349
350    await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
351    if (fs.Length < signatureOffset + signatureLength)
352        return false;
353
354    fs.Seek(signatureOffset, SeekOrigin.Begin);
355    var buffer = new byte[signatureLength];
356    var bytesRead = await fs.ReadAsync(buffer);
357    if (bytesRead < signatureLength)
358        return false;
359
360    return buffer[0] == (byte)'C'
361        && buffer[1] == (byte)'D'
362        && buffer[2] == (byte)'0'
363        && buffer[3] == (byte)'0'
364        && buffer[4] == (byte)'1';
365}
366
367static void PrintProgress(long[] progress, List<(long Start, long End)> chunks, long totalSize, TimeSpan elapsed)
368{
369    long totalDownloaded = progress.Sum();
370    var pct = (double)totalDownloaded / totalSize * 100;
371    var speed = elapsed.TotalSeconds > 0 ? totalDownloaded / elapsed.TotalSeconds : 0;
372    var eta = speed > 0 ? TimeSpan.FromSeconds((totalSize - totalDownloaded) / speed) : TimeSpan.Zero;
373
374    // Per-chunk mini bars
375    var bars = new List<string>();
376    for (int i = 0; i < chunks.Count; i++)
377    {
378        var chunkSize = chunks[i].End - chunks[i].Start + 1;
379        var chunkPct = (double)progress[i] / chunkSize;
380        const int miniBarWidth = 8;
381        var filled = chunkPct <= 0
382            ? 0
383            : Math.Clamp((int)Math.Ceiling(chunkPct * miniBarWidth), 1, miniBarWidth);
384        bars.Add(new string('█', filled) + new string('░', miniBarWidth - filled));
385    }
386
387    Console.Write($"\r  [{pct,5:F1}%] {FormatBytes(totalDownloaded)} / {FormatBytes(totalSize)}  " +
388                  $"{FormatBytes((long)speed)}/s  ETA {eta:mm\\:ss}  " +
389                  $"[{string.Join('|', bars)}]   ");
390}
391
392static string FormatBytes(long bytes)
393{
394    string[] units = ["B", "KB", "MB", "GB", "TB"];
395    double val = bytes;
396    int unit = 0;
397    while (val >= 1024 && unit < units.Length - 1) { val /= 1024; unit++; }
398    return $"{val:F1} {units[unit]}";
399}
400
401static void PrintUsage()
402{
403    Console.Error.WriteLine("Usage: ./downloader.cs <url> [output-file] [chunks]");
404    Console.Error.WriteLine("  url          - URL to download");
405    Console.Error.WriteLine("  output-file  - Output filename (default: derived from URL)");
406    Console.Error.WriteLine($"  chunks       - Number of parallel streams (default: {DefaultChunks})");
407    Console.Error.WriteLine();
408    Console.Error.WriteLine("Examples:");
409    Console.Error.WriteLine("  ./downloader.cs 'https://example.com/file.iso'");
410    Console.Error.WriteLine("  ./downloader.cs 'https://example.com/file.iso?x=1&y=2'");
411    Console.Error.WriteLine();
412    Console.Error.WriteLine("PowerShell note:");
413    Console.Error.WriteLine("  URLs containing '&' must be quoted, escaped as '`&', or passed after '--%'.");
414    Console.Error.WriteLine("  Example:");
415    Console.Error.WriteLine("    ./downloader.cs --% https://example.com/file.iso?x=1&y=2");
416}
comments powered by Disqus

Related