• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KSP-CKAN / CKAN / 16536075581

26 Jul 2025 04:27AM UTC coverage: 56.347% (+8.5%) from 47.804%
16536075581

push

github

HebaruSan
Merge #4408 Add tests for CmdLine

4557 of 8422 branches covered (54.11%)

Branch coverage included in aggregate %.

148 of 273 new or added lines in 28 files covered. (54.21%)

18 existing lines in 5 files now uncovered.

9719 of 16914 relevant lines covered (57.46%)

1.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.15
/Core/Net/NetFileCache.cs
1
using System;
2
using System.IO;
3
using System.Text;
4
using System.Text.RegularExpressions;
5
using System.Collections.Generic;
6
using System.Collections.Concurrent;
7
using System.Linq;
8
using System.Diagnostics;
9
using System.Threading;
10
using System.Security.Cryptography;
11
#if NETFRAMEWORK
12
using System.Security.Permissions;
13
#endif
14

15
using log4net;
16
using ChinhDo.Transactions.FileManager;
17

18
using CKAN.IO;
19
using CKAN.Extensions;
20
using CKAN.Versioning;
21

22
namespace CKAN
23
{
24

25
    /// <summary>
26
    /// A local cache dedicated to storing and retrieving files based upon their
27
    /// URL.
28
    /// </summary>
29

30
    // We require fancy permissions to use the FileSystemWatcher
31
    // (No longer supported by .NET Core/Standard/5/6/7/etc.)
32
    #if NETFRAMEWORK
33
    [PermissionSet(SecurityAction.Demand, Name="FullTrust")]
34
    #endif
35
    public class NetFileCache : IDisposable
36
    {
37
        private readonly FileSystemWatcher watcher;
38
        // hash => full file path
39
        private Dictionary<string, string>? cachedFiles;
40
        private readonly DirectoryInfo cachePath;
41
        // Files go here while they're downloading
42
        private readonly DirectoryInfo inProgressPath;
43
        private readonly GameInstanceManager? manager;
44
        private static readonly Regex cacheFileRegex = new Regex("^[0-9A-F]{8}-", RegexOptions.Compiled);
2✔
45
        private static readonly ILog log = LogManager.GetLogger(typeof (NetFileCache));
2✔
46

47
        /// <summary>
48
        /// Initialize a cache given a GameInstanceManager
49
        /// </summary>
50
        /// <param name="mgr">GameInstanceManager object containing the Instances that might have old caches</param>
51
        /// <param name="path">Location of folder to use for caching</param>
52
        public NetFileCache(GameInstanceManager mgr, string path)
53
            : this(path)
2✔
54
        {
2✔
55
            manager = mgr;
2✔
56
        }
2✔
57

58
        /// <summary>
59
        /// Initialize a cache given a path
60
        /// </summary>
61
        /// <param name="path">Location of folder to use for caching</param>
62
        public NetFileCache(string path)
2✔
63
        {
2✔
64
            cachePath = new DirectoryInfo(path);
2✔
65
            // Basic validation, our cache has to exist.
66
            if (!cachePath.Exists)
2✔
67
            {
2✔
68
                throw new DirectoryNotFoundKraken(
2✔
69
                    path,
70
                    string.Format(Properties.Resources.NetFileCacheCannotFind,
71
                                  path));
72
            }
73
            inProgressPath = new DirectoryInfo(Path.Combine(path, "downloading"));
2✔
74

75
            // Establish a watch on our cache. This means we can cache the directory contents,
76
            // and discard that cache if we spot changes.
77
            watcher = new FileSystemWatcher(cachePath.FullName, "*.zip")
2✔
78
            {
79
                NotifyFilter = NotifyFilters.LastWrite
80
                             | NotifyFilters.LastAccess
81
                             | NotifyFilters.DirectoryName
82
                             | NotifyFilters.FileName
83
            };
84

85
            // If we spot any changes, we fire our event handler.
86
            // NOTE: FileSystemWatcher.Changed fires when you READ info about a file,
87
            //       do NOT listen for it!
88
            watcher.Created += OnCacheChanged;
2✔
89
            watcher.Deleted += OnCacheChanged;
2✔
90
            watcher.Renamed += OnCacheChanged;
2✔
91

92
            // Enable events!
93
            watcher.EnableRaisingEvents = true;
2✔
94
        }
2✔
95

96
        /// <summary>
97
        /// Releases all resource used by the <see cref="NetFileCache"/> object.
98
        /// </summary>
99
        /// <remarks>Call <see cref="Dispose"/> when you are finished using the <see cref="NetFileCache"/>. The
100
        /// <see cref="Dispose"/> method leaves the <see cref="NetFileCache"/> in an unusable state. After calling
101
        /// <see cref="Dispose"/>, you must release all references to the <see cref="NetFileCache"/> so the garbage
102
        /// collector can reclaim the memory that the <see cref="NetFileCache"/> was occupying.</remarks>
103
        public void Dispose()
104
        {
2✔
105
            // All we really need to do is clear our FileSystemWatcher.
106
            // We disable its event raising capabilities first for good measure.
107
            watcher.EnableRaisingEvents = false;
2✔
108
            watcher.Dispose();
2✔
109
            GC.SuppressFinalize(this);
2✔
110
        }
2✔
111

112
        private FileInfo GetInProgressFileName(string hash, string description)
113
        {
2✔
114
            inProgressPath.Create();
2✔
115
            return inProgressPath.EnumerateFiles()
2!
116
                                 .FirstOrDefault(path => path.Name.StartsWith(hash))
×
117
                                 // If not found, return the name to create
118
                                 ?? new FileInfo(Path.Combine(inProgressPath.FullName,
119
                                                              $"{hash}-{description}"));
120
        }
2✔
121

122
        public FileInfo GetInProgressFileName(Uri url, string description)
123
            => GetInProgressFileName(CreateURLHash(url),
×
124
                                     description);
125

126
        public FileInfo? GetInProgressFileName(List<Uri> urls, string description)
127
        {
2✔
128
            var filenames = urls.Select(url => GetInProgressFileName(CreateURLHash(url), description))
2✔
129
                                .Memoize();
130
            return filenames.FirstOrDefault(fi => fi.Exists)
2!
131
                ?? filenames.FirstOrDefault();
132
        }
2✔
133

134
        /// <summary>
135
        /// Called from our FileSystemWatcher. Use OnCacheChanged()
136
        /// without arguments to signal manually.
137
        /// </summary>
138
        private void OnCacheChanged(object source, FileSystemEventArgs e)
139
        {
2✔
140
            log.DebugFormat("File system watcher event {0} fired for {1}",
2✔
141
                            e.ChangeType.ToString(),
142
                            e.FullPath);
143
            OnCacheChanged();
2✔
144
            if (e.ChangeType == WatcherChangeTypes.Deleted)
2✔
145
            {
2✔
146
                log.DebugFormat("Purging hashes reactively: {0}", e.FullPath);
2✔
147
                PurgeHashes(null, e.FullPath);
2✔
148
            }
2✔
149
        }
2✔
150

151
        /// <summary>
152
        /// When our cache dirctory changes, we just clear the list of
153
        /// files we know about.
154
        /// </summary>
155
        public void OnCacheChanged()
156
        {
2✔
157
            log.Debug("Purging cache index");
2✔
158
            cachedFiles = null;
2✔
159
        }
2✔
160

161
        // returns true if a url is already in the cache
162
        public bool IsCached(Uri url) => GetCachedFilename(url) != null;
2✔
163

164
        // returns true if a url is already in the cache
165
        // returns the filename in the outFilename parameter
166
        public bool IsCached(Uri url, out string? outFilename)
167
        {
×
168
            outFilename = GetCachedFilename(url);
×
169
            return outFilename != null;
×
170
        }
×
171

172
        /// <summary>
173
        /// Returns true if a file matching the given URL is cached, but makes no
174
        /// attempts to check if it's even valid. This is very fast.
175
        ///
176
        /// Use IsCachedZip() for a slower but more reliable method.
177
        /// </summary>
178
        public bool IsMaybeCachedZip(Uri url, DateTime? remoteTimestamp = null)
179
            => GetCachedFilename(url, remoteTimestamp) != null;
2✔
180

181
        /// <summary>>
182
        /// Returns the filename of an already cached url or null otherwise
183
        /// </summary>
184
        /// <param name="url">The URL to check for in the cache</param>
185
        /// <param name="remoteTimestamp">Timestamp of the remote file, if known; cached files older than this will be considered invalid</param>
186
        public string? GetCachedFilename(Uri url, DateTime? remoteTimestamp = null)
187
        {
2✔
188
            log.DebugFormat("Checking cache for {0}", url);
2✔
189

190
            if (url == null)
2!
191
            {
×
192
                return null;
×
193
            }
194

195
            string hash = CreateURLHash(url);
2✔
196

197
            // Use our existing list of files, or retrieve and
198
            // store the list of files in our cache. Note that
199
            // we copy cachedFiles into our own variable as it
200
            // *may* get cleared by OnCacheChanged while we're
201
            // using it.
202

203
            var files = cachedFiles;
2✔
204

205
            if (files == null)
2✔
206
            {
2✔
207
                log.Debug("Rebuilding cache index");
2✔
208
                cachedFiles = files = allFiles()
2✔
209
                    .GroupBy(fi => fi.Name[..8])
2✔
210
                    .ToDictionary(grp => grp.Key,
2✔
211
                                  grp => grp.First().FullName);
2✔
212
            }
2✔
213

214
            // Now that we have a list of files one way or another,
215
            // check them to see if we can find the one we're looking
216
            // for.
217

218
            var found = scanDirectory(files, hash, remoteTimestamp);
2✔
219
            return string.IsNullOrEmpty(found) ? null : found;
2✔
220
        }
2✔
221

222
        private string? scanDirectory(Dictionary<string, string> files,
223
                                      string                     findHash,
224
                                      DateTime?                  remoteTimestamp = null)
225
        {
2✔
226
            if (files.TryGetValue(findHash, out string? file)
2✔
227
                && File.Exists(file))
228
            {
2✔
229
                log.DebugFormat("Found file {0}", file);
2✔
230
                // Check local vs remote timestamps; if local is older, then it's invalid.
231
                // null means we don't know the remote timestamp (so file is OK)
232
                if (remoteTimestamp == null
2✔
233
                    || remoteTimestamp < File.GetLastWriteTimeUtc(file))
234
                {
2✔
235
                    // File not too old, use it
236
                    log.Debug("Found good file, using it");
2✔
237
                    return file;
2✔
238
                }
239
                else
240
                {
2✔
241
                    // Local file too old, delete it
242
                    log.Debug("Found stale file, deleting it");
2✔
243
                    File.Delete(file);
2✔
244
                    PurgeHashes(null, file);
2✔
245
                }
2✔
246
            }
2✔
247
            else
248
            {
2✔
249
                log.DebugFormat("{0} not in cache", findHash);
2✔
250
            }
2✔
251
            return null;
2✔
252
        }
2✔
253

254
        /// <summary>
255
        /// Count the files and bytes in the cache
256
        /// </summary>
257
        /// <param name="numFiles">Output parameter set to number of files in cache</param>
258
        /// <param name="numBytes">Output parameter set to number of bytes in cache</param>
259
        /// <param name="bytesFree">Output parameter set to number of bytes free</param>
260
        public void GetSizeInfo(out int numFiles, out long numBytes, out long? bytesFree)
261
        {
2✔
262
            bytesFree = cachePath.GetDrive()?.AvailableFreeSpace;
2✔
263
            (numFiles, numBytes) = Enumerable.Repeat(cachePath, 1)
2✔
264
                                             .Concat(legacyDirs())
265
                                             .Select(GetDirSizeInfo)
266
                                             .Aggregate((numFiles: 0,
267
                                                         numBytes: 0L),
268
                                                        (total, next) => (numFiles: total.numFiles + next.numFiles,
2✔
269
                                                                          numBytes: total.numBytes + next.numBytes));
270
        }
2✔
271

272
        private static (int numFiles, long numBytes) GetDirSizeInfo(DirectoryInfo cacheDir)
273
            => cacheDir.EnumerateFiles("*", SearchOption.AllDirectories)
2✔
274
                       .Aggregate((numFiles: 0,
275
                                   numBytes: 0L),
276
                                  (tuple, fi) => (numFiles: tuple.numFiles + 1,
2✔
277
                                                  numBytes: tuple.numBytes + fi.Length));
278

279
        public void CheckFreeSpace(long bytesToStore)
280
        {
2✔
281
            CKANPathUtils.CheckFreeSpace(cachePath,
2✔
282
                                         bytesToStore,
283
                                         Properties.Resources.NotEnoughSpaceToCache);
284
        }
2✔
285

286
        private IEnumerable<DirectoryInfo> legacyDirs()
287
            => manager?.Instances.Values
2✔
288
                       .Where(ksp => ksp.Valid)
2✔
289
                       .Select(ksp => new DirectoryInfo(ksp.DownloadCacheDir()))
2✔
290
                       .Where(dir => dir.Exists)
2✔
291
                      ?? Enumerable.Empty<DirectoryInfo>();
292

293
        public void EnforceSizeLimit(long bytes, Registry registry)
294
        {
2✔
295
            GetSizeInfo(out int numFiles, out long curBytes, out _);
2✔
296
            if (curBytes > bytes)
2✔
297
            {
2✔
298
                // This object will let us determine whether a module is compatible with any of our instances
299
                var aggregateCriteria = manager?.Instances.Values
2✔
300
                    .Where(ksp => ksp.Valid)
×
301
                    .Select(ksp => ksp.VersionCriteria())
×
302
                    .Aggregate(
303
                        manager?.CurrentInstance?.VersionCriteria()
304
                            ?? new GameVersionCriteria(null),
305
                        (combinedCrit, nextCrit) => combinedCrit.Union(nextCrit))
×
306
                    ?? new GameVersionCriteria(null);
307

308
                // This object lets us find the modules associated with a cached file
309
                var hashMap = registry.GetDownloadUrlHashIndex();
2✔
310

311
                // Prune the module lists to only those that are compatible
312
                foreach (var kvp in hashMap)
4!
313
                {
×
314
                    kvp.Value.RemoveAll(mod => !mod.IsCompatible(aggregateCriteria));
×
315
                }
×
316

317
                // Now get all the files in all the caches, including in progress...
318
                List<FileInfo> files = allFiles(true);
2✔
319
                // ... and sort them by compatibility and timestamp...
320
                files.Sort((a, b) => compareFiles(hashMap, a, b));
1✔
321

322
                // ... and delete them till we're under the limit
323
                foreach (FileInfo fi in files)
5✔
324
                {
2✔
325
                    curBytes -= fi.Length;
2✔
326
                    fi.Delete();
2✔
327
                    File.Delete($"{fi.Name}.sha1");
2✔
328
                    File.Delete($"{fi.Name}.sha256");
2✔
329
                    if (curBytes <= bytes)
2!
330
                    {
2✔
331
                        // Limit met, all done!
332
                        break;
2✔
333
                    }
334
                }
×
335
                OnCacheChanged();
2✔
336
                sha1Cache.Clear();
2✔
337
                sha256Cache.Clear();
2✔
338
            }
2✔
339
        }
2✔
340

341
        private static int compareFiles(IReadOnlyDictionary<string, List<CkanModule>> hashMap, FileInfo a, FileInfo b)
342
        {
×
343
            // Compatible modules for file A
344
            hashMap.TryGetValue(a.Name[..8], out List<CkanModule>? modulesA);
×
345
            bool compatA = modulesA?.Any() ?? false;
×
346

347
            // Compatible modules for file B
348
            hashMap.TryGetValue(b.Name[..8], out List<CkanModule>? modulesB);
×
349
            bool compatB = modulesB?.Any() ?? false;
×
350

351
            if (modulesA == null && modulesB != null)
×
352
            {
×
353
                // A isn't indexed but B is, delete A first
354
                return -1;
×
355
            }
356
            else if (modulesA != null && modulesB == null)
×
357
            {
×
358
                // A is indexed but B isn't, delete B first
359
                return 1;
×
360
            }
361
            else if (!compatA && compatB)
×
362
            {
×
363
                // A isn't compatible but B is, delete A first
364
                return -1;
×
365
            }
366
            else if (compatA && !compatB)
×
367
            {
×
368
                // A is compatible but B isn't, delete B first
369
                return 1;
×
370
            }
371
            else
372
            {
×
373
                // Both are either compatible or incompatible
374
                // Go by file age, oldest first
375
                return (int)(a.CreationTime - b.CreationTime).TotalSeconds;
×
376
            }
377
        }
×
378

379
        private List<FileInfo> allFiles(bool includeInProgress = false)
380
        {
2✔
381
            var files = cachePath.EnumerateFiles("*",
2✔
382
                                                 includeInProgress ? SearchOption.AllDirectories
383
                                                                   : SearchOption.TopDirectoryOnly);
384
            foreach (var legacyDir in legacyDirs())
4!
385
            {
×
386
                files = files.Union(legacyDir.EnumerateFiles());
×
387
            }
×
388
            return files.Where(fi =>
2✔
389
                    // Require 8 digit hex prefix followed by dash; any else was not put there by CKAN
390
                    cacheFileRegex.IsMatch(fi.Name)
2✔
391
                    // Treat the hash files as companions of the main files, not their own entries
392
                    && !fi.Name.EndsWith(".sha1") && !fi.Name.EndsWith(".sha256")
393
                ).ToList();
394
        }
2✔
395

396
        public IEnumerable<(string hash, long size)> CachedHashesAndSizes()
397
            => allFiles(false).Select(fi => (hash: fi.Name[..8],
×
398
                                             size: fi.Length));
399

400
        /// <summary>
401
        /// Stores the results of a given URL in the cache.
402
        /// Description is adjusted to be filesystem-safe and then appended to the file hash when saving.
403
        /// If not present, the filename will be used.
404
        /// If `move` is true, then the file will be moved; otherwise, it will be copied.
405
        ///
406
        /// Returns a path to the newly cached file.
407
        ///
408
        /// This method is filesystem transaction aware.
409
        /// </summary>
410
        public string Store(Uri     url,
411
                            string  path,
412
                            string? description = null,
413
                            bool    move        = false)
414
        {
2✔
415
            log.DebugFormat("Storing {0}", url);
2✔
416

417
            TxFileManager tx_file = new TxFileManager();
2✔
418

419
            // Clear our cache entry first
420
            Remove(url);
2✔
421

422
            string hash = CreateURLHash(url);
2✔
423

424
            description ??= Path.GetFileName(path);
2✔
425

426
            Debug.Assert(
2✔
427
                Regex.IsMatch(description, "^[A-Za-z0-9_.-]*$"),
428
                $"description {description} isn't as filesystem safe as we thought... (#1266)");
429

430
            string fullName = string.Format("{0}-{1}", hash, Path.GetFileName(description));
2✔
431
            string targetPath = Path.Combine(cachePath.FullName, fullName);
2✔
432

433
            // Purge hashes associated with the new file
434
            PurgeHashes(tx_file, targetPath);
2✔
435

436
            log.InfoFormat("Storing {0} in {1}", path, targetPath);
2✔
437

438
            if (move)
2!
439
            {
×
440
                tx_file.Move(path, targetPath);
×
441
            }
×
442
            else
443
            {
2✔
444
                tx_file.Copy(path, targetPath, true);
2✔
445
            }
2✔
446

447
            // We've changed our cache, so signal that immediately.
448
            if (!cachedFiles?.ContainsKey(hash) ?? false)
2✔
449
            {
2✔
450
                cachedFiles?.Add(hash, targetPath);
2!
451
            }
2✔
452

453
            return targetPath;
2✔
454
        }
2✔
455

456
        /// <summary>
457
        /// Removes the given URL from the cache.
458
        /// Returns true if any work was done, false otherwise.
459
        /// This method is filesystem transaction aware.
460
        /// </summary>
461
        public bool Remove(Uri url)
462
        {
2✔
463
            if (GetCachedFilename(url) is string file
2✔
464
                && File.Exists(file))
465
            {
2✔
466
                TxFileManager tx_file = new TxFileManager();
2✔
467
                tx_file.Delete(file);
2✔
468
                // We've changed our cache, so signal that immediately.
469
                cachedFiles?.Remove(CreateURLHash(url));
2!
470
                PurgeHashes(tx_file, file);
2✔
471
                return true;
2✔
472
            }
473
            return false;
2✔
474
        }
2✔
475

476
        public bool Remove(IEnumerable<Uri> urls)
477
            => urls.Select(Remove)
×
478
                   // Force all elements to be evaluated
479
                   .ToArray()
480
                   .Any(found => found);
×
481

482
        private void PurgeHashes(TxFileManager? tx_file, string file)
483
        {
2✔
484
            try
485
            {
2✔
486
                sha1Cache.TryRemove(file, out _);
2✔
487
                sha256Cache.TryRemove(file, out _);
2✔
488

489
                tx_file ??= new TxFileManager();
2✔
490
                tx_file.Delete($"{file}.sha1");
2✔
491
                tx_file.Delete($"{file}.sha256");
2✔
492
            }
2✔
493
            catch
×
494
            {
×
495
            }
×
496
        }
2✔
497

498
        /// <summary>
499
        /// Clear all files in cache, including main directory and legacy directories
500
        /// </summary>
501
        public void RemoveAll()
502
        {
2✔
503
            foreach (var file in legacyDirs()
5✔
504
                                 .Prepend(inProgressPath)
505
                                 .Prepend(cachePath)
506
                                 .SelectManyWithCatch(dir => dir.EnumerateFiles()))
2✔
507
            {
2✔
508
                try
509
                {
2✔
510
                    file.Delete();
2✔
511
                }
2✔
NEW
512
                catch { }
×
513
            }
2✔
514
            sha1Cache.Clear();
2✔
515
            sha256Cache.Clear();
2✔
516
            OnCacheChanged();
2✔
517
        }
2✔
518

519
        /// <summary>
520
        /// Move files from another folder into this cache
521
        /// May throw an IOException if disk is full!
522
        /// </summary>
523
        /// <param name="fromDir">Path from which to move files</param>
524
        /// <param name="percentProgress">Callback to notify as we traverse the input, called with percentages from 0 to 100</param>
525
        public void MoveFrom(DirectoryInfo  fromDir,
526
                             IProgress<int> percentProgress)
527
        {
×
528
            if (fromDir.Exists && !cachePath.PathEquals(fromDir))
×
529
            {
×
530
                var files = fromDir.GetFiles("*", SearchOption.AllDirectories);
×
531
                var bytesProgress = new ProgressScalePercentsByFileSizes(
×
532
                                        percentProgress,
533
                                        files.Select(f => f.Length));
×
534
                bool hasAny = false;
×
535
                foreach (var fromFile in files)
×
536
                {
×
537
                    bytesProgress.Report(0);
×
538

539
                    var toFile = Path.Combine(cachePath.FullName,
×
540
                                              CKANPathUtils.ToRelative(fromFile.FullName,
541
                                                                       fromDir.FullName));
542
                    if (File.Exists(toFile))
×
543
                    {
×
544
                        if (fromFile.CreationTimeUtc == File.GetCreationTimeUtc(toFile))
×
545
                        {
×
546
                            // Same filename with same timestamp, almost certainly the same
547
                            // actual file on disk via different paths thanks to symlinks.
548
                            // Skip this whole folder!
549
                            break;
×
550
                        }
551
                        else
552
                        {
×
553
                            // Don't need multiple copies of the same file
554
                            fromFile.Delete();
×
555
                        }
×
556
                    }
×
557
                    else
558
                    {
×
559
                        try
560
                        {
×
561
                            if (Path.GetDirectoryName(toFile) is string parent)
×
562
                            {
×
563
                                Directory.CreateDirectory(parent);
×
564
                            }
×
565
                            fromFile.MoveTo(toFile);
×
566
                            hasAny = true;
×
567
                        }
×
568
                        catch (Exception exc)
×
569
                        {
×
570
                            // On Windows, FileInfo.MoveTo sometimes throws exceptions after it succeeds (!!).
571
                            // Just log it and ignore.
572
                            log.ErrorFormat("Couldn't move {0} to {1}: {2}",
×
573
                                            fromFile.FullName,
574
                                            toFile,
575
                                            exc.Message);
576
                        }
×
577
                    }
×
578
                    bytesProgress.Report(100);
×
579
                    bytesProgress.NextFile();
×
580
                }
×
581
                if (hasAny)
×
582
                {
×
583
                    OnCacheChanged();
×
584
                    sha1Cache.Clear();
×
585
                    sha256Cache.Clear();
×
586
                }
×
587
            }
×
588
        }
×
589

590
        /// <summary>
591
        /// Generate the hash used for caching
592
        /// </summary>
593
        /// <param name="url">URL to hash</param>
594
        /// <returns>
595
        /// Returns the 8-byte hash for a given url
596
        /// </returns>
597
        public static string CreateURLHash(Uri? url)
598
        {
2✔
599
            using (SHA1 sha1 = SHA1.Create())
2✔
600
            {
2✔
601
                byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(url?.ToString() ?? ""));
2✔
602

603
                return BitConverter.ToString(hash).Replace("-", "")[..8];
2✔
604
            }
605
        }
2✔
606

607
        /// <summary>
608
        /// Calculate the SHA1 hash of a file
609
        /// </summary>
610
        /// <param name="filePath">Path to file to examine</param>
611
        /// <param name="progress">Callback to notify as we traverse the input, called with percentages from 0 to 100</param>
612
        /// <param name="cancelToken">Cancellation token to cancel the operation</param>
613
        /// <returns>
614
        /// SHA1 hash, in all-caps hexadecimal format
615
        /// </returns>
616
        public string GetFileHashSha1(string             filePath,
617
                                      IProgress<int>?    progress,
618
                                      CancellationToken? cancelToken = default)
619
            => GetFileHash(filePath, "sha1", sha1Cache, SHA1.Create, progress, cancelToken);
2✔
620

621
        /// <summary>
622
        /// Calculate the SHA256 hash of a file
623
        /// </summary>
624
        /// <param name="filePath">Path to file to examine</param>
625
        /// <param name="progress">Callback to notify as we traverse the input, called with percentages from 0 to 100</param>
626
        /// <param name="cancelToken">Cancellation token to cancel the operation</param>
627
        /// <returns>
628
        /// SHA256 hash, in all-caps hexadecimal format
629
        /// </returns>
630
        public string GetFileHashSha256(string             filePath,
631
                                        IProgress<int>?    progress,
632
                                        CancellationToken? cancelToken = default)
633
            => GetFileHash(filePath, "sha256", sha256Cache, SHA256.Create, progress, cancelToken);
2✔
634

635
        /// <summary>
636
        /// Calculate the hash of a file
637
        /// </summary>
638
        /// <param name="filePath">Path to file to examine</param>
639
        /// <param name="hashSuffix">Suffix to use for the hash file</param>
640
        /// <param name="cache">Cache to use for storing the hash</param>
641
        /// <param name="getHashAlgo">Function to get the hash algorithm</param>
642
        /// <param name="progress">Callback to notify as we traverse the input, called with percentages from 0 to 100</param>
643
        /// <param name="cancelToken">Cancellation token to cancel the operation</param>
644
        /// <returns>
645
        /// Hash, in all-caps hexadecimal format
646
        /// </returns>
647
        private string GetFileHash(string                               filePath,
648
                                   string                               hashSuffix,
649
                                   ConcurrentDictionary<string, string> cache,
650
                                   Func<HashAlgorithm>                  getHashAlgo,
651
                                   IProgress<int>?                      progress,
652
                                   CancellationToken?                   cancelToken)
653
            => cache.GetOrAdd(filePath, p =>
2✔
654
               {
2✔
655
                   var hashFile = $"{p}.{hashSuffix}";
2✔
656
                   if (File.Exists(hashFile))
2!
657
                   {
×
658
                       return File.ReadAllText(hashFile);
×
659
                   }
660
                   else
661
                   {
2✔
662
                       using (var fs     = new FileStream(p, FileMode.Open, FileAccess.Read))
2✔
663
                       using (var bs     = new BufferedStream(fs))
2✔
664
                       using (var hasher = getHashAlgo())
2✔
665
                       {
2✔
666
                           var hash = BitConverter.ToString(hasher.ComputeHash(bs, progress, cancelToken))
2✔
667
                                                  .Replace("-", "");
668
                           if (Path.GetDirectoryName(hashFile) == cachePath.FullName)
2✔
669
                           {
2✔
670
                               hash.WriteThroughTo(hashFile);
2✔
671
                           }
2✔
672
                           return hash;
2✔
673
                       }
674
                   }
675
               });
2✔
676

677
        private readonly ConcurrentDictionary<string, string> sha1Cache   = new ConcurrentDictionary<string, string>();
2✔
678
        private readonly ConcurrentDictionary<string, string> sha256Cache = new ConcurrentDictionary<string, string>();
2✔
679
    }
680
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc