1 //
2 // Copyright (c) 2010-2024 Antmicro
3 // Copyright (c) 2011-2015 Realtime Embedded
4 //
5 // This file is licensed under the MIT License.
6 // Full license text is available in 'licenses/MIT.txt'.
7 //
8 using System;
9 using System.Linq;
10 using System.Net;
11 using System.IO;
12 using Antmicro.Renode.Exceptions;
13 using Antmicro.Renode.Logging;
14 using System.Collections.Generic;
15 using Antmicro.Migrant;
16 using System.IO.Compression;
17 using System.Threading;
18 using System.Security.Cryptography;
19 using System.Text.RegularExpressions;
20 using System.Globalization;
21 using System.ComponentModel;
22 using Antmicro.Renode.Core;
23 using System.Text;
24 using Antmicro.Migrant.Customization;
25 
26 namespace Antmicro.Renode.Utilities
27 {
28     public class CachingFileFetcher : IDisposable
29     {
CachingFileFetcher()30         public CachingFileFetcher()
31         {
32             fetchedFiles = new Dictionary<string, string>();
33             progressUpdateThreshold = TimeSpan.FromSeconds(0.25);
34         }
35 
GetFetchedFiles()36         public IDictionary<string, string> GetFetchedFiles()
37         {
38             return fetchedFiles.ToDictionary(x => x.Key, x => x.Value);
39         }
40 
FetchFromUri(Uri uri)41         public string FetchFromUri(Uri uri)
42         {
43             string fileName;
44             if(!TryFetchFromUri(uri, out fileName))
45             {
46                 throw new RecoverableException("Could not download file from {0}.".FormatWith(uri));
47             }
48             return fileName;
49         }
50 
CancelDownload()51         public void CancelDownload()
52         {
53             if(client != null && client.IsBusy)
54             {
55                 client.CancelAsync();
56             }
57         }
58 
TryFetchFromUri(Uri uri, out string fileName)59         public bool TryFetchFromUri(Uri uri, out string fileName)
60         {
61             fileName = null;
62             if(!Monitor.TryEnter(concurrentLock))
63             {
64                 Logger.LogAs(this, LogLevel.Error, "Cannot perform concurrent downloads, aborting...");
65                 return false;
66             }
67 
68             try
69             {
70                 var disableCaching = Emulator.InCIMode;
71 
72                 return disableCaching
73                     ? TryFetchFromUriInner(uri, out fileName)
74                     : TryFetchFromCacheOrUriInner(uri, out fileName);
75             }
76             finally
77             {
78                 Monitor.Exit(concurrentLock);
79             }
80         }
81 
Dispose()82         public void Dispose()
83         {
84             if(EmulationManager.DisableEmulationFilesCleanup)
85             {
86                 return;
87             }
88 
89             foreach(var file in fetchedFiles.Keys)
90             {
91                 try
92                 {
93                     File.Delete(file);
94                 }
95                 catch(Exception)
96                 {
97                     // nothing we can do
98                 }
99             }
100         }
101 
TryFetchFromCacheOrUriInner(Uri uri, out string fileName)102         private bool TryFetchFromCacheOrUriInner(Uri uri, out string fileName)
103         {
104             using(var locker = new FileLocker(GetCacheIndexLockLocation()))
105             {
106                 if(TryGetFromCache(uri, out fileName))
107                 {
108                     fetchedFiles.Add(fileName, uri.ToString());
109                     return true;
110                 }
111 
112                 if(TryFetchFromUriInner(uri, out fileName))
113                 {
114                     UpdateInCache(uri, fileName);
115                     return true;
116                 }
117             }
118 
119             fileName = null;
120             return false;
121         }
122 
TryFetchFromUriInner(Uri uri, out string fileName)123         private bool TryFetchFromUriInner(Uri uri, out string fileName)
124         {
125             fileName = TemporaryFilesManager.Instance.GetTemporaryFile(Path.GetExtension(uri.AbsoluteUri));
126             // try download the file a few times times
127             // in order to handle some intermittent
128             // network problems
129             if(!TryDownload(uri, fileName, DownloadAttempts))
130             {
131                 return false;
132             }
133 
134             // at this point the file has been successfully downloded;
135             // now verify its size and checksum based on the information
136             // encoded in URI
137             // NOTE: there is no point in redownloading the file as a result
138             // of checksum/size verification failure; we are using TCP/IP
139             // protocol that guarantee a failure-free communication, so
140             // a checksum/size mismatch must be a result of a broken file
141             // at server side or a wrong URI
142             if(TryGetChecksumAndSizeFromUri(uri, out var checksum, out var size))
143             {
144                 if(!VerifySize(fileName, size))
145                 {
146                     Logger.Log(LogLevel.Error, "Wrong size of the downloaded file, aborting");
147                     return false;
148                 }
149 
150                 if(!VerifyChecksum(fileName, checksum))
151                 {
152                     Logger.Log(LogLevel.Error, "Wrong checksum of the downloaded file, aborting");
153                     return false;
154                 }
155             }
156 
157             if(uri.ToString().EndsWith(".gz", StringComparison.InvariantCulture))
158             {
159                 fileName = Decompress(fileName);
160             }
161 
162             fetchedFiles.Add(fileName, uri.ToString());
163 
164             return true;
165         }
166 
TryDownload(Uri uri, string fileName, int attemptsLimit)167         private bool TryDownload(Uri uri, string fileName, int attemptsLimit)
168         {
169             var attempts = 0;
170             do
171             {
172                 if(!TryDownloadInner(uri, fileName, out var error))
173                 {
174                     if(error == null)
175                     {
176                         Logger.LogAs(this, LogLevel.Info, "Download cancelled.");
177                         return false;
178                     }
179                     else
180                     {
181                         var webException = error as WebException;
182                         Logger.Log(LogLevel.Error, "Failed to download from {0}, reason: {1} (attempt {2}/{3})", uri, webException != null ? ResolveWebException(webException) : error.Message, attempts + 1, attemptsLimit);
183                     }
184                 }
185                 else
186                 {
187                     Logger.LogAs(this, LogLevel.Info, "Download done.");
188                     return true;
189                 }
190             }
191             while (++attempts < attemptsLimit);
192 
193             Logger.Log(LogLevel.Error, "Download failed {0} times, aborting.", attempts);
194             return false;
195         }
196 
TryDownloadInner(Uri uri, string fileName, out Exception error)197         private bool TryDownloadInner(Uri uri, string fileName, out Exception error)
198         {
199             Exception localError = null;
200             var wasCancelled = false;
201 
202             using(var downloadProgressHandler = EmulationManager.Instance.ProgressMonitor.Start(GenerateProgressMessage(uri), false, true))
203 #pragma warning disable SYSLIB0014 // Even though WebClient is technically obselete, there's no better replacement for our use case
204             using(client = new WebClient())
205 #pragma warning restore SYSLIB0014
206             {
207                 Logger.LogAs(this, LogLevel.Info, "Downloading {0}.", uri);
208                 var now = CustomDateTime.Now;
209                 var bytesDownloaded = 0L;
210                 client.DownloadProgressChanged += (sender, e) =>
211                 {
212                     var newNow = CustomDateTime.Now;
213 
214                     var period = newNow - now;
215                     if (period > progressUpdateThreshold)
216                     {
217                         downloadProgressHandler.UpdateProgress(e.ProgressPercentage,
218                             GenerateProgressMessage(uri,
219                                 e.BytesReceived, e.TotalBytesToReceive, e.ProgressPercentage, 1.0 * (e.BytesReceived - bytesDownloaded) / period.TotalSeconds));
220 
221                         now = newNow;
222                         bytesDownloaded = e.BytesReceived;
223                     }
224                 };
225                 var resetEvent = new ManualResetEvent(false);
226                 client.DownloadFileCompleted += delegate (object sender, AsyncCompletedEventArgs e)
227                 {
228                     localError = e.Error;
229                     if (e.Cancelled)
230                     {
231                         wasCancelled = true;
232                     }
233                     resetEvent.Set();
234                 };
235                 client.DownloadFileAsync(uri, fileName);
236                 resetEvent.WaitOne();
237                 error = localError;
238             }
239             client = null;
240 
241             return !wasCancelled && error == null;
242         }
243 
Decompress(string fileName)244         private string Decompress(string fileName)
245         {
246             var decompressedFile = TemporaryFilesManager.Instance.GetTemporaryFile();
247 
248             using(var decompressionProgressHandler = EmulationManager.Instance.ProgressMonitor.Start("Decompressing file"))
249             {
250                 Logger.Log(LogLevel.Info, "Decompressing file");
251                 using (var gzipStream = new GZipStream(File.OpenRead(fileName), CompressionMode.Decompress))
252                 using (var outputStream = File.OpenWrite(decompressedFile))
253                 {
254                     gzipStream.CopyTo(outputStream);
255                 }
256                 Logger.Log(LogLevel.Info, "Decompression done");
257             }
258 
259             return decompressedFile;
260         }
261 
ResolveWebException(WebException e)262         private static string ResolveWebException(WebException e)
263         {
264             string reason;
265             switch(e.Status)
266             {
267             case WebExceptionStatus.ConnectFailure:
268                 reason = "unable to connect to the server";
269                 break;
270 
271             case WebExceptionStatus.ConnectionClosed:
272                 reason = "the connection was prematurely closed";
273                 break;
274 
275             case WebExceptionStatus.NameResolutionFailure:
276                 reason = "server name resolution error";
277                 break;
278 
279             case WebExceptionStatus.ProtocolError:
280                 switch(((HttpWebResponse)e.Response).StatusCode)
281                 {
282                 case HttpStatusCode.NotFound:
283                     reason = "file was not found on a server";
284                     break;
285 
286                 default:
287                     reason = string.Format("http protocol status code {0}", (int)((HttpWebResponse)e.Response).StatusCode);
288                     break;
289                 }
290                 break;
291 
292             default:
293                 reason = e.Status.ToString();
294                 break;
295             }
296 
297             return reason;
298         }
299 
GenerateProgressMessage(Uri uri, long? bytesDownloaded = null, long? totalBytes = null, int? progressPercentage = null, double? speed = null)300         private string GenerateProgressMessage(Uri uri, long? bytesDownloaded = null, long? totalBytes = null, int? progressPercentage = null, double? speed = null)
301         {
302             var strBldr = new StringBuilder();
303             strBldr.AppendFormat("Downloading: {0}", uri);
304             if(bytesDownloaded.HasValue && totalBytes.HasValue)
305             {
306                 // A workaround for a bug in Mono misreporting TotalBytesToReceive
307                 // https://github.com/mono/mono/issues/9808
308                 if(totalBytes == -1)
309                 {
310                     strBldr.AppendFormat("\nProgress: {0}B downloaded", Misc.NormalizeBinary(bytesDownloaded.Value));
311                 }
312                 else
313                 {
314                     strBldr.AppendFormat("\nProgress: {0}% ({1}B/{2}B)", progressPercentage, Misc.NormalizeBinary(bytesDownloaded.Value), Misc.NormalizeBinary(totalBytes.Value));
315                 }
316             }
317             if(speed != null)
318             {
319                 double val;
320                 string unit;
321 
322                 Misc.CalculateUnitSuffix(speed.Value, out val, out unit);
323                 strBldr.AppendFormat("\nSpeed: {0:F2}{1}/s", val, unit);
324             }
325             return strBldr.Append(".").ToString();
326         }
327 
TryGetFromCache(Uri uri, out string fileName)328         private bool TryGetFromCache(Uri uri, out string fileName)
329         {
330             lock(CacheDirectory)
331             {
332                 fileName = null;
333                 var index = ReadBinariesIndex();
334                 BinaryEntry entry;
335                 if(!index.TryGetValue(uri.ToString(), out entry))
336                 {
337                     return false;
338                 }
339                 var fileToCopy = GetBinaryFileName(entry.Index);
340                 if(!VerifyCachedFile(fileToCopy, entry))
341                 {
342                     return false;
343                 }
344                 fileName = TemporaryFilesManager.Instance.GetTemporaryFile();
345                 FileCopier.Copy(GetBinaryFileName(entry.Index), fileName, true);
346                 return true;
347             }
348         }
349 
VerifyCachedFile(string fileName, BinaryEntry entry)350         private bool VerifyCachedFile(string fileName, BinaryEntry entry)
351         {
352             if(!File.Exists(fileName))
353             {
354                 Logger.LogAs(this, LogLevel.Warning, "Binary {0} found in index but is missing in cache.", fileName);
355                 return false;
356             }
357 
358             if(entry.Checksum == null)
359             {
360                 return true;
361             }
362 
363             return VerifySize(fileName, entry.Size) && VerifyChecksum(fileName, entry.Checksum);
364         }
365 
VerifySize(string fileName, long expectedSize)366         private bool VerifySize(string fileName, long expectedSize)
367         {
368             var actualSize = new FileInfo(fileName).Length;
369             if(actualSize != expectedSize)
370             {
371                 Logger.LogAs(this, LogLevel.Warning, "Size of the file differs: is {0}B, should be {1}B.", actualSize, expectedSize);
372                 return false;
373             }
374             return true;
375         }
376 
VerifyChecksum(string fileName, byte[] expectedChecksum)377         private bool VerifyChecksum(string fileName, byte[] expectedChecksum)
378         {
379             if(!ConfigurationManager.Instance.Get("file-fetcher", "calculate-checksum", true))
380             {
381                 // with a disabled checksum verification we pretend everything is peachy
382                 return true;
383             }
384 
385             byte[] checksum;
386             using(var progressHandler = EmulationManager.Instance.ProgressMonitor.Start("Calculating SHA1 checksum..."))
387             {
388                 checksum = GetSHA1Checksum(fileName);
389             }
390             if(!checksum.SequenceEqual(expectedChecksum))
391             {
392                 Logger.LogAs(this, LogLevel.Warning, "Checksum of the file differs, is {0}, should be {1}.", ChecksumToText(checksum), ChecksumToText(expectedChecksum));
393                 return false;
394             }
395             return true;
396         }
397 
UpdateInCache(Uri uri, string withFile)398         private void UpdateInCache(Uri uri, string withFile)
399         {
400             using(var progressHandler = EmulationManager.Instance.ProgressMonitor.Start("Updating cache"))
401             {
402                 lock(CacheDirectory)
403                 {
404                     var index = ReadBinariesIndex();
405                     BinaryEntry entry;
406                     var fileId = 0;
407                     if(!index.TryGetValue(uri.ToString(), out entry))
408                     {
409                         foreach(var element in index)
410                         {
411                             fileId = Math.Max(fileId, element.Value.Index) + 1;
412                         }
413                     }
414                     else
415                     {
416                         fileId = entry.Index;
417                     }
418                     FileCopier.Copy(withFile, GetBinaryFileName(fileId), true);
419 
420                     // checksum will be 'null' if the uri pattern does not contain
421                     // checksum/size information
422                     TryGetChecksumAndSizeFromUri(uri, out var checksum, out var size);
423                     index[uri.ToString()] = new BinaryEntry(fileId, size, checksum);
424                     WriteBinariesIndex(index);
425                 }
426             }
427         }
428 
ReadBinariesIndex()429         private Dictionary<string, BinaryEntry> ReadBinariesIndex()
430         {
431             using(var progressHandler = EmulationManager.Instance.ProgressMonitor.Start("Reading cache"))
432             {
433                 using(var fStream = GetIndexFileStream())
434                 {
435                     if(fStream.Length == 0)
436                     {
437                         return new Dictionary<string, BinaryEntry>();
438                     }
439                     Dictionary<string, BinaryEntry> result;
440                     if(Serializer.TryDeserialize<Dictionary<string, BinaryEntry>>(fStream, out result) != DeserializationResult.OK)
441                     {
442                         Logger.LogAs(this, LogLevel.Warning, "There was an error while loading index file. Cache will be rebuilt.");
443                         fStream.Close();
444                         ResetIndex();
445                         return new Dictionary<string, BinaryEntry>();
446                     }
447                     return result;
448                 }
449             }
450         }
451 
WriteBinariesIndex(Dictionary<string, BinaryEntry> index)452         private void WriteBinariesIndex(Dictionary<string, BinaryEntry> index)
453         {
454             using(var progressHandler = EmulationManager.Instance.ProgressMonitor.Start("Writing binaries index"))
455             {
456                 using(var fStream = GetIndexFileStream())
457                 {
458                     Serializer.Serialize(index, fStream);
459                 }
460             }
461         }
462 
GetIndexFileStream()463         private FileStream GetIndexFileStream()
464         {
465             return new FileStream(GetCacheIndexLocation(), FileMode.OpenOrCreate);
466         }
467 
ResetIndex()468         private void ResetIndex()
469         {
470             File.WriteAllText(GetCacheIndexLocation(), string.Empty);
471             var cacheDir = GetCacheLocation();
472             if (Directory.Exists(cacheDir))
473             {
474                 Directory.Delete(cacheDir, true);
475             }
476         }
477 
GetBinaryFileName(int id)478         private string GetBinaryFileName(int id)
479         {
480             var cacheDir = GetCacheLocation();
481             if(!Directory.Exists(cacheDir))
482             {
483                 Directory.CreateDirectory(cacheDir);
484             }
485             return Path.Combine(cacheDir, "bin" + id);
486         }
487 
ChecksumToText(byte[] checksum)488         private static string ChecksumToText(byte[] checksum)
489         {
490             return checksum.Select(x => x.ToString("x2")).Aggregate((x, y) => x + y);
491         }
492 
GetCacheLocation()493         private static string GetCacheLocation()
494         {
495             return Path.Combine(Emulator.UserDirectoryPath, CacheDirectory);
496         }
497 
GetCacheIndexLocation()498         private static string GetCacheIndexLocation()
499         {
500             return Path.Combine(Emulator.UserDirectoryPath, CacheIndex);
501         }
502 
GetCacheIndexLockLocation()503         private static string GetCacheIndexLockLocation()
504         {
505             return Path.Combine(Emulator.UserDirectoryPath, CacheLock);
506         }
507 
GetSHA1Checksum(string fileName)508         private static byte[] GetSHA1Checksum(string fileName)
509         {
510             using(var file = new FileStream(fileName, FileMode.Open))
511             using(var sha = SHA1.Create())
512             {
513                 sha.Initialize();
514                 return sha.ComputeHash(file);
515             }
516         }
517 
TryGetChecksumAndSizeFromUri(Uri uri, out byte[] checksum, out long size)518         private static bool TryGetChecksumAndSizeFromUri(Uri uri, out byte[] checksum, out long size)
519         {
520             size = 0;
521             checksum = null;
522 
523             var groups = ChecksumRegex.Match(uri.ToString()).Groups;
524             if(groups.Count != 3)
525             {
526                 return false;
527             }
528 
529             // regex check above ensures that all the data below is parsable
530             size = long.Parse(groups[1].Value);
531             var checksumAsString = groups[2].Value;
532             checksum = new byte[20];
533             for(var i = 0; i < checksum.Length; i++)
534             {
535                 checksum[i] = byte.Parse(checksumAsString.Substring(2 * i, 2), NumberStyles.HexNumber);
536             }
537 
538             return true;
539         }
540 
CachingFileFetcher()541         static CachingFileFetcher()
542         {
543             ServicePointManager.ServerCertificateValidationCallback = delegate
544             {
545                 return true;
546             };
547             ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
548         }
549 
550         private TimeSpan progressUpdateThreshold;
551         private WebClient client;
552         private object concurrentLock = new object();
553         private const string CacheDirectory = "cached_binaries";
554         private const string CacheIndex = "binaries_index";
555         private const string CacheLock = "cache_lock";
556         private readonly Dictionary<string, string> fetchedFiles;
557 
558         private static readonly Serializer Serializer = new Serializer(new Settings(versionTolerance: VersionToleranceLevel.AllowGuidChange, disableTypeStamping: true));
559         private static readonly Regex ChecksumRegex = new Regex(@"-s_(\d+)-([a-f,0-9]{40})$");
560 
561         private const int DownloadAttempts = 5;
562 
563         private class BinaryEntry
564         {
BinaryEntry(int index, long size, byte[] checksum)565             public BinaryEntry(int index, long size, byte[] checksum)
566             {
567                 this.Index = index;
568                 this.Size = size;
569                 this.Checksum = checksum;
570             }
571 
572             public int Index { get; set; }
573             public long Size { get; set; }
574             public byte[] Checksum { get; set; }
575         }
576     }
577 }
578