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.isoBut 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)
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}