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

KSP-CKAN / CKAN / 20625811636

31 Dec 2025 07:29PM UTC coverage: 85.324% (-0.02%) from 85.342%
20625811636

push

github

HebaruSan
Merge #4483 Translation updates

2006 of 2171 branches covered (92.4%)

Branch coverage included in aggregate %.

11947 of 14182 relevant lines covered (84.24%)

1.76 hits per line

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

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

14
using log4net;
15
using ChinhDo.Transactions;
16

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

21
namespace CKAN
22
{
23

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

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

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

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

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

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

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

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

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

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

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

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

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

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

163
        /// <summary>
164
        /// Returns true if a file matching the given URL is cached, but makes no
165
        /// attempts to check if it's even valid. This is very fast.
166
        ///
167
        /// Use IsCachedZip() for a slower but more reliable method.
168
        /// </summary>
169
        public bool IsMaybeCachedZip(Uri url, DateTime? remoteTimestamp = null)
170
            => GetCachedFilename(url, remoteTimestamp) != null;
2✔
171

172
        /// <summary>>
173
        /// Returns the filename of an already cached url or null otherwise
174
        /// </summary>
175
        /// <param name="url">The URL to check for in the cache</param>
176
        /// <param name="remoteTimestamp">Timestamp of the remote file, if known; cached files older than this will be considered invalid</param>
177
        public string? GetCachedFilename(Uri url, DateTime? remoteTimestamp = null)
178
        {
2✔
179
            log.DebugFormat("Checking cache for {0}", url);
2✔
180

181
            if (url == null)
2✔
182
            {
×
183
                return null;
×
184
            }
185

186
            string hash = CreateURLHash(url);
2✔
187

188
            // Use our existing list of files, or retrieve and
189
            // store the list of files in our cache. Note that
190
            // we copy cachedFiles into our own variable as it
191
            // *may* get cleared by OnCacheChanged while we're
192
            // using it.
193

194
            var files = cachedFiles;
2✔
195

196
            if (files == null)
2✔
197
            {
2✔
198
                log.Debug("Rebuilding cache index");
2✔
199
                cachedFiles = files = allFiles()
2✔
200
                    .GroupBy(fi => fi.Name[..8])
2✔
201
                    .ToDictionary(grp => grp.Key,
2✔
202
                                  grp => grp.First().FullName);
2✔
203
            }
2✔
204

205
            // Now that we have a list of files one way or another,
206
            // check them to see if we can find the one we're looking
207
            // for.
208

209
            var found = scanDirectory(files, hash, remoteTimestamp);
2✔
210
            return string.IsNullOrEmpty(found) ? null : found;
2✔
211
        }
2✔
212

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

245
        /// <summary>
246
        /// Count the files and bytes in the cache
247
        /// </summary>
248
        /// <param name="numFiles">Output parameter set to number of files in cache</param>
249
        /// <param name="numBytes">Output parameter set to number of bytes in cache</param>
250
        /// <param name="bytesFree">Output parameter set to number of bytes free</param>
251
        public void GetSizeInfo(out int numFiles, out long numBytes, out long? bytesFree)
252
        {
2✔
253
            bytesFree = cachePath.GetDrive()?.AvailableFreeSpace;
2✔
254
            (numFiles, numBytes) = Enumerable.Repeat(cachePath, 1)
2✔
255
                                             .Concat(legacyDirs())
256
                                             .Select(GetDirSizeInfo)
257
                                             .Aggregate((numFiles: 0,
258
                                                         numBytes: 0L),
259
                                                        (total, next) => (numFiles: total.numFiles + next.numFiles,
2✔
260
                                                                          numBytes: total.numBytes + next.numBytes));
261
        }
2✔
262

263
        private static (int numFiles, long numBytes) GetDirSizeInfo(DirectoryInfo cacheDir)
264
            => cacheDir.EnumerateFiles("*", SearchOption.AllDirectories)
2✔
265
                       .Aggregate((numFiles: 0,
266
                                   numBytes: 0L),
267
                                  (tuple, fi) => (numFiles: tuple.numFiles + 1,
2✔
268
                                                  numBytes: tuple.numBytes + fi.Length));
269

270
        public void CheckFreeSpace(long bytesToStore)
271
        {
2✔
272
            CKANPathUtils.CheckFreeSpace(cachePath,
2✔
273
                                         bytesToStore,
274
                                         Properties.Resources.NotEnoughSpaceToCache);
275
        }
2✔
276

277
        private IEnumerable<DirectoryInfo> legacyDirs()
278
            => manager?.Instances.Values
2✔
279
                       .Where(ksp => ksp.Valid)
2✔
280
                       .Select(ksp => new DirectoryInfo(ksp.DownloadCacheDir))
2✔
281
                       .Where(dir => dir.Exists)
2✔
282
                      ?? Enumerable.Empty<DirectoryInfo>();
283

284
        public void EnforceSizeLimit(long bytes, Registry registry)
285
        {
2✔
286
            GetSizeInfo(out int numFiles, out long curBytes, out _);
2✔
287
            if (curBytes > bytes)
2✔
288
            {
2✔
289
                // This object will let us determine whether a module is compatible with any of our instances
290
                var aggregateCriteria = manager?.Instances.Values
2✔
291
                    .Where(ksp => ksp.Valid)
2✔
292
                    .Select(ksp => ksp.VersionCriteria())
2✔
293
                    .Aggregate(
294
                        manager?.CurrentInstance?.VersionCriteria()
295
                            ?? new GameVersionCriteria(null),
296
                        (combinedCrit, nextCrit) => combinedCrit.Union(nextCrit))
2✔
297
                    ?? new GameVersionCriteria(null);
298

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

302
                // Prune the module lists to only those that are compatible
303
                foreach (var val in hashMap.Values)
6✔
304
                {
×
305
                    val.RemoveAll(mod => !mod.IsCompatible(aggregateCriteria));
×
306
                }
×
307

308
                // Now get all the files in all the caches, including in progress...
309
                var files = allFiles(true);
2✔
310
                // ... and sort them by compatibility and timestamp...
311
                files.Sort((a, b) => compareFiles(hashMap, a, b));
2✔
312

313
                // ... and delete them till we're under the limit
314
                foreach (FileInfo fi in files)
8✔
315
                {
2✔
316
                    curBytes -= fi.Length;
2✔
317
                    fi.Delete();
2✔
318
                    File.Delete($"{fi.Name}.sha1");
2✔
319
                    File.Delete($"{fi.Name}.sha256");
2✔
320
                    if (curBytes <= bytes)
2✔
321
                    {
2✔
322
                        // Limit met, all done!
323
                        break;
2✔
324
                    }
325
                }
2✔
326
                OnCacheChanged();
2✔
327
                sha1Cache.Clear();
2✔
328
                sha256Cache.Clear();
2✔
329
            }
2✔
330
        }
2✔
331

332
        private static int compareFiles(IReadOnlyDictionary<string, List<CkanModule>> hashMap, FileInfo a, FileInfo b)
333
        {
2✔
334
            // Compatible modules for file A
335
            hashMap.TryGetValue(a.Name[..8], out List<CkanModule>? modulesA);
2✔
336
            bool compatA = modulesA?.Any() ?? false;
2✔
337

338
            // Compatible modules for file B
339
            hashMap.TryGetValue(b.Name[..8], out List<CkanModule>? modulesB);
2✔
340
            bool compatB = modulesB?.Any() ?? false;
2✔
341

342
            if (modulesA == null && modulesB != null)
2✔
343
            {
×
344
                // A isn't indexed but B is, delete A first
345
                return -1;
×
346
            }
347
            else if (modulesA != null && modulesB == null)
2✔
348
            {
×
349
                // A is indexed but B isn't, delete B first
350
                return 1;
×
351
            }
352
            else if (!compatA && compatB)
2✔
353
            {
×
354
                // A isn't compatible but B is, delete A first
355
                return -1;
×
356
            }
357
            else if (compatA && !compatB)
2✔
358
            {
×
359
                // A is compatible but B isn't, delete B first
360
                return 1;
×
361
            }
362
            else
363
            {
2✔
364
                // Both are either compatible or incompatible
365
                // Go by file age, oldest first
366
                return (int)(a.CreationTime - b.CreationTime).TotalSeconds;
2✔
367
            }
368
        }
2✔
369

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

387
        public IEnumerable<(string hash, long size)> CachedHashesAndSizes()
388
            => allFiles(false).Select(fi => (hash: fi.Name[..8],
2✔
389
                                             size: fi.Length));
390

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

408
            var txFileMgr = new TxFileManager();
2✔
409

410
            // Clear our cache entry first
411
            Remove(url);
2✔
412

413
            string hash = CreateURLHash(url);
2✔
414

415
            description ??= Path.GetFileName(path);
2✔
416

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

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

424
            // Purge hashes associated with the new file
425
            PurgeHashes(txFileMgr, targetPath);
2✔
426

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

429
            if (move)
2✔
430
            {
×
431
                txFileMgr.Move(path, targetPath);
×
432
            }
×
433
            else
434
            {
2✔
435
                txFileMgr.Copy(path, targetPath, true);
2✔
436
            }
2✔
437

438
            // We've changed our cache, so signal that immediately.
439
            if (!cachedFiles?.ContainsKey(hash) ?? false)
2✔
440
            {
2✔
441
                cachedFiles?.Add(hash, targetPath);
2✔
442
            }
2✔
443

444
            return targetPath;
2✔
445
        }
2✔
446

447
        /// <summary>
448
        /// Removes the given URL from the cache.
449
        /// Returns true if any work was done, false otherwise.
450
        /// This method is filesystem transaction aware.
451
        /// </summary>
452
        public bool Remove(Uri url)
453
        {
2✔
454
            if (GetCachedFilename(url) is string file
2✔
455
                && File.Exists(file))
456
            {
2✔
457
                var txFileMgr = new TxFileManager();
2✔
458
                txFileMgr.Delete(file);
2✔
459
                // We've changed our cache, so signal that immediately.
460
                cachedFiles?.Remove(CreateURLHash(url));
2✔
461
                PurgeHashes(txFileMgr, file);
2✔
462
                return true;
2✔
463
            }
464
            return false;
2✔
465
        }
2✔
466

467
        public bool Remove(IEnumerable<Uri> urls)
468
            => urls.Select(Remove)
2✔
469
                   // Force all elements to be evaluated
470
                   .ToArray()
471
                   .Any(found => found);
2✔
472

473
        private void PurgeHashes(TxFileManager? txFileMgr, string file)
474
        {
2✔
475
            try
476
            {
2✔
477
                sha1Cache.TryRemove(file, out _);
2✔
478
                sha256Cache.TryRemove(file, out _);
2✔
479

480
                txFileMgr ??= new TxFileManager();
2✔
481
                txFileMgr.Delete($"{file}.sha1");
2✔
482
                txFileMgr.Delete($"{file}.sha256");
2✔
483
            }
2✔
484
            catch
×
485
            {
×
486
            }
×
487
        }
2✔
488

489
        /// <summary>
490
        /// Clear all files in cache, including main directory and legacy directories
491
        /// </summary>
492
        public void RemoveAll()
493
        {
2✔
494
            foreach (var file in legacyDirs()
8✔
495
                                 .Prepend(inProgressPath)
496
                                 .Prepend(cachePath)
497
                                 .SelectManyWithCatch(dir => dir.EnumerateFiles()))
2✔
498
            {
2✔
499
                try
500
                {
2✔
501
                    file.Delete();
2✔
502
                }
2✔
503
                catch { }
×
504
            }
2✔
505
            sha1Cache.Clear();
2✔
506
            sha256Cache.Clear();
2✔
507
            OnCacheChanged();
2✔
508
        }
2✔
509

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

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

581
        /// <summary>
582
        /// Generate the hash used for caching
583
        /// </summary>
584
        /// <param name="url">URL to hash</param>
585
        /// <returns>
586
        /// Returns the 8-byte hash for a given url
587
        /// </returns>
588
        public static string CreateURLHash(Uri? url)
589
        {
2✔
590
            using (SHA1 sha1 = SHA1.Create())
2✔
591
            {
2✔
592
                byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(url?.ToString() ?? ""));
2✔
593

594
                return BitConverter.ToString(hash).Replace("-", "")[..8];
2✔
595
            }
596
        }
2✔
597

598
        /// <summary>
599
        /// Calculate the SHA1 hash of a file
600
        /// </summary>
601
        /// <param name="filePath">Path to file to examine</param>
602
        /// <param name="progress">Callback to notify as we traverse the input, called with percentages from 0 to 100</param>
603
        /// <param name="cancelToken">Cancellation token to cancel the operation</param>
604
        /// <returns>
605
        /// SHA1 hash, in all-caps hexadecimal format
606
        /// </returns>
607
        public string GetFileHashSha1(string             filePath,
608
                                      IProgress<int>?    progress,
609
                                      CancellationToken? cancelToken = default)
610
            => GetFileHash(filePath, "sha1", sha1Cache, SHA1.Create, progress, cancelToken);
2✔
611

612
        /// <summary>
613
        /// Calculate the SHA256 hash of a file
614
        /// </summary>
615
        /// <param name="filePath">Path to file to examine</param>
616
        /// <param name="progress">Callback to notify as we traverse the input, called with percentages from 0 to 100</param>
617
        /// <param name="cancelToken">Cancellation token to cancel the operation</param>
618
        /// <returns>
619
        /// SHA256 hash, in all-caps hexadecimal format
620
        /// </returns>
621
        public string GetFileHashSha256(string             filePath,
622
                                        IProgress<int>?    progress,
623
                                        CancellationToken? cancelToken = default)
624
            => GetFileHash(filePath, "sha256", sha256Cache, SHA256.Create, progress, cancelToken);
2✔
625

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

668
        private readonly ConcurrentDictionary<string, string> sha1Cache   = new ConcurrentDictionary<string, string>();
2✔
669
        private readonly ConcurrentDictionary<string, string> sha256Cache = new ConcurrentDictionary<string, string>();
2✔
670
    }
671
}
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

© 2025 Coveralls, Inc