1using System.Collections.Concurrent;
2using System.Diagnostics.CodeAnalysis;
3using System.IO.Compression;
4using System.Security.Cryptography;
5using System.Security.Cryptography.X509Certificates;
8using System.Text.Json.Serialization;
9using System.Text.RegularExpressions;
13using Microsoft.Extensions.Logging;
14using Microsoft.Extensions.Logging.Abstractions;
23 private readonly
string _bundleName;
24 private byte[] _rawZipContents = [];
26 private readonly ConcurrentDictionary<string, byte[]> _cache =
new();
27 private byte[] _zipCache = [];
28 private readonly
int _maxCacheSize;
29 private int _currentCacheSize;
31 private readonly ConcurrentDictionary<string, byte[]> _pendingForAdd =
new();
32 private readonly List<string> _pendingForRemove = [];
39 WriteIndented =
false,
40 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
121 public Bundle(
string bundlePath, ILogger? logger =
null,
int maxCacheSize = 0x8000000)
123 Ensure.String.IsNotNullOrEmpty(bundlePath.Trim(), nameof(bundlePath));
125 string fullPath = Path.GetFullPath(bundlePath);
127 if (Directory.Exists(fullPath))
134 _bundleName = Path.GetFileName(fullPath);
135 RootPath = Path.GetDirectoryName(fullPath) ??
throw new ArgumentException(
"Cannot resolve root path of the bundle: " + fullPath);
138 Logger = logger ?? NullLogger.Instance;
140 _maxCacheSize = maxCacheSize;
150 throw new InvalidOperationException(
"Bundle is read-only"); ;
165 if (Regex.IsMatch(entryName, pattern))
167 return throwException ?
throw new UnauthorizedAccessException(
"Entry name is protected: " + entryName) :
false;
174 private void EvictIfNecessary(
long incomingFileSize)
176 while (_currentCacheSize + incomingFileSize > _maxCacheSize && !_cache.IsEmpty)
178 string leastUsedKey = _cache.Keys.First();
179 if (_cache.TryRemove(leastUsedKey, out
byte[]? removed))
181 _currentCacheSize -= removed.Length;
184 Logger.LogDebug(
"Evicted entry: {name} from the cache", leastUsedKey);
196 Ensure.String.IsNotNullOrEmpty(entryName.Trim(), nameof(entryName));
197 Ensure.Collection.HasItems(data, nameof(data));
199 if (!
ReadOnly || _maxCacheSize < data.Length)
204 if (_cache.TryGetValue(entryName, out
byte[]? existing))
206 if (existing.SequenceEqual(data))
212 EvictIfNecessary(data.Length);
214 _cache[entryName] = data;
215 _currentCacheSize += data.Length;
217 Logger.LogDebug(
"Cached entry: {name} with {size} bytes", entryName, data.Length);
231 Logger.LogDebug(
"Opening bundle archive in {Mode} mode", mode);
235 Logger.LogDebug(
"Loading bundle from memory with {Size} bytes", _rawZipContents.Length);
237 MemoryStream ms =
new MemoryStream(_rawZipContents, writable:
false);
238 return new ZipArchive(ms, mode);
242 if (mode == ZipArchiveMode.Read)
245 if (_zipCache.Length == 0)
250 if (
ReadOnly && stream.Length < _maxCacheSize)
252 Logger.LogDebug(
"Caching bundle with {Size} bytes", stream.Length);
258 Logger.LogDebug(
"Loading bundle from cache with {Size} bytes", _zipCache.Length);
259 stream =
new MemoryStream(_zipCache, writable:
false);
262 return new ZipArchive(stream, ZipArchiveMode.Read);
265 _zipCache = Array.Empty<
byte>();
280 throw new InvalidOperationException(
"The bundle is already loaded");
303 throw new InvalidOperationException(
"The bundle is already loaded");
306 Ensure.Collection.HasItems(bundleContent, nameof(bundleContent));
309 _rawZipContents = bundleContent;
316 Logger.LogInformation(
"Bundle loaded from memory with {Size} bytes", bundleContent.Length);
323 protected virtual void Parse(ZipArchive zip)
325 Logger.LogInformation(
"Parsing bundle contents");
327 ZipArchiveEntry? entry;
328 if ((entry = zip.GetEntry(
".manifest.ec")) !=
null)
330 Logger.LogDebug(
"Parsing manifest");
335 Manifest.ProtectedEntryNames = protectedEntries;
339 Logger.LogWarning(
"Manifest not found in the bundle");
342 if ((entry = zip.GetEntry(
".signatures.ec")) !=
null)
344 Logger.LogDebug(
"Parsing signatures");
349 Logger.LogWarning(
"Signatures not found in the bundle");
361 public void AddEntry(
string path,
string destinationPath =
"./",
string? rootPath =
null)
365 Ensure.String.IsNotNullOrEmpty(path.Trim(), nameof(path));
367 if (!File.Exists(path))
369 throw new FileNotFoundException(
"File not found", path);
372 Logger.LogInformation(
"Adding file: {path}", path);
374 if (!destinationPath.EndsWith(
'/'))
375 destinationPath +=
"/";
377 if (
string.IsNullOrEmpty(rootPath))
380 using FileStream file = File.OpenRead(path);
389 name = destinationPath + name;
392 Logger.LogDebug(
"Adding entry: {name} with hash {hash} to manifest", name, BitConverter.ToString(hash).Replace(
"-",
string.Empty));
397 Logger.LogDebug(
"Pending file: {name} for adding tp the bundle", name);
398 _pendingForAdd[name] = File.ReadAllBytes(path);
410 Ensure.String.IsNotNullOrEmpty(entryName.Trim(), nameof(entryName));
412 Logger.LogInformation(
"Deleting entry: {name}", entryName);
416 Logger.LogDebug(
"Deleting entry: {name} from manifest", entryName);
421 Logger.LogDebug(
"Pending entry: {name} for deletion from the bundle", entryName);
422 _pendingForRemove.Add(entryName);
424 if (_pendingForAdd.ContainsKey(entryName))
426 Logger.LogDebug(
"Removing pending entry: {name} from the bundle", entryName);
427 _pendingForAdd.Remove(entryName, out _);
437 public void Sign(X509Certificate2 certificate, RSA privateKey)
441 Ensure.Any.IsNotNull(certificate, nameof(certificate));
442 Ensure.Any.IsNotNull(privateKey, nameof(privateKey));
444 Logger.LogInformation(
"Signing bundle with certificate: {name}", certificate.Subject);
446 Logger.LogDebug(
"Exporting certificate");
447 byte[] certData = certificate.Export(X509ContentType.Cert);
448 string name = certificate.GetCertHashString();
450 Logger.LogDebug(
"Signing manifest");
452 byte[] signature = privateKey.SignData(manifestData, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
454 Logger.LogDebug(
"Adding signature for certificate: {name} to signatures", name);
458 Logger.LogInformation(
"Bundle signed with certificate: {name}", certificate.Subject);
468 Ensure.String.IsNotNullOrEmpty(entryName.Trim(), nameof(entryName));
470 Logger.LogInformation(
"Verifying file integrity: {name}", entryName);
472 using Stream stream =
GetStream(entryName);
476 Logger.LogInformation(
"File integrity verification result for {name}: {result}", entryName, result);
488 Ensure.String.IsNotNullOrEmpty(certificateHash.Trim(), nameof(certificateHash));
492 RSA pubKey = certificate.GetRSAPublicKey() ??
throw new CryptographicException(
"Public key not found");
494 Logger.LogInformation(
"Verifying signature with certificate: {name}", certificate.Subject);
497 bool result = pubKey.VerifyHash(manifestHash, hash, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
499 Logger.LogInformation(
"Signature verification result for certificate {name}: {result}", certificate.Subject, result);
511 public bool VerifyCertificate(
string certificateHash, out X509ChainStatus[] statuses, X509ChainPolicy? policy =
null)
513 Ensure.String.IsNotNullOrEmpty(certificateHash.Trim(), nameof(certificateHash));
534 public bool VerifyCertificate(X509Certificate2 certificate, out X509ChainStatus[] statuses, X509ChainPolicy? policy =
null)
536 Ensure.Any.IsNotNull(certificate, nameof(certificate));
538 X509Chain chain =
new X509Chain
540 ChainPolicy = policy ??
new X509ChainPolicy()
543 Logger.LogInformation(
"Verifying certificate: {name}", certificate.Subject);
547 Logger.LogDebug(
"Using custom chain policy for verification");
550 bool isValid = chain.Build(certificate);
551 statuses = chain.ChainStatus;
553 Logger.LogInformation(
"Certificate verification result for {name}: {result}", certificate.Subject, isValid);
573 Ensure.String.IsNotNullOrEmpty(certificateHash.Trim(), nameof(certificateHash));
575 Logger.LogInformation(
"Getting certificate with hash: {hash}", certificateHash);
580 X509Certificate2 certificate = X509CertificateLoader.LoadCertificate(certData);
582 X509Certificate2 certificate =
new X509Certificate2(certData);
599 Ensure.String.IsNotNullOrEmpty(entryName.Trim(), nameof(entryName));
601 Logger.LogInformation(
"Getting file data for entry: {name}", entryName);
603 if (!_cache.TryGetValue(entryName, out
byte[]? data))
605 using Stream stream =
GetStream(entryName, readSource);
625 Ensure.String.IsNotNullOrEmpty(entryName.Trim(), nameof(entryName));
627 Logger.LogInformation(
"Getting file stream for entry: {name}", entryName);
629 if (_cache.TryGetValue(entryName, out
byte[]? data))
631 Logger.LogDebug(
"Reading entry {name} from cache", entryName);
632 return new MemoryStream(data, writable:
false);
636 Logger.LogDebug(
"Entry {name} not found in cache", entryName);
645 Logger.LogDebug(
"Reading file: {name} from the bundle", entryName);
649 ZipArchiveEntry entry = zip.GetEntry(entryName) ??
throw new FileNotFoundException(
"Entry not found", entryName);
650 stream = entry.Open();
654 Logger.LogDebug(
"Reading file: {name} from the file system", entryName);
656 string path = Path.GetFullPath(entryName,
RootPath);
657 stream = File.OpenRead(path);
677 Ensure.String.IsNotNullOrEmpty(entryName, nameof(entryName));
679 Logger.LogInformation(
"Checking if entry {entryName} exists", entryName);
687 result = zip.GetEntry(entryName) !=
null;
691 string path = Path.GetFullPath(entryName,
RootPath);
692 result = File.Exists(path);
695 Logger.LogInformation(
"Entry {entryName} exists: {result}", entryName, result);
713 Ensure.String.IsNotNullOrEmpty(entryName, nameof(entryName));
722 readSource = Manifest.StoreOriginalFiles ? ReadSource.Bundle :
ReadSource.Disk;
737 using (ZipArchive zip =
GetZipArchive(ZipArchiveMode.Update))
739 Logger.LogDebug(
"Invoking Updating event");
742 Logger.LogDebug(
"Processing pending files");
743 ProcessPendingFiles(zip);
745 Logger.LogDebug(
"Writing manifest to the bundle");
747 WriteEntry(zip,
".manifest.ec", manifestData);
749 Logger.LogDebug(
"Writing signatures to the bundle");
750 byte[] signatureData =
Export(
Signatures, SourceGenerationSignaturesContext.Default);
751 WriteEntry(zip,
".signatures.ec", signatureData);
755 private void ProcessPendingFiles(ZipArchive zip)
757 ZipArchiveEntry? tempEntry;
758 foreach (
string entryName
in _pendingForRemove)
760 if ((tempEntry = zip.GetEntry(entryName)) !=
null)
762 Logger.LogDebug(
"Deleting entry: {name}", entryName);
767 Logger.LogWarning(
"Entry {name} not found in the bundle", entryName);
771 foreach (KeyValuePair<
string,
byte[]> newFile
in _pendingForAdd)
785 Manifest.UpdatedBy = GetType().FullName;
797 protected byte[]
Export(
object structuredData, JsonSerializerContext jsonSerializerContext)
799 Ensure.Any.IsNotNull(structuredData, nameof(structuredData));
800 Ensure.Any.IsNotNull(jsonSerializerContext, nameof(jsonSerializerContext));
802 Logger.LogInformation(
"Exporting data from a {type} object as byte array", structuredData.GetType().Name);
804 string data = JsonSerializer.Serialize(structuredData, structuredData.GetType(), jsonSerializerContext);
805 return Encoding.UTF8.GetBytes(data);
814 [RequiresUnreferencedCode(
"This method is not compatible with AOT.")]
817 [RequiresDynamicCode(
"This method is not compatible with AOT.")]
819 protected byte[]
Export(
object structuredData)
821 Ensure.Any.IsNotNull(structuredData, nameof(structuredData));
823 Logger.LogInformation(
"Exporting data from a {type} object as byte array", structuredData.GetType().Name);
826 return Encoding.UTF8.GetBytes(data);
836 protected void WriteEntry(ZipArchive zip,
string entryName,
byte[] data)
838 Ensure.Any.IsNotNull(zip, nameof(zip));
839 Ensure.String.IsNotNullOrEmpty(entryName.Trim(), nameof(entryName));
840 Ensure.Collection.HasItems(data, nameof(data));
842 Logger.LogDebug(
"Writing entry: {name} to the bundle", entryName);
844 ZipArchiveEntry? tempEntry;
845 if ((tempEntry = zip.GetEntry(entryName)) !=
null)
847 Logger.LogDebug(
"Deleting existing entry: {name}", entryName);
852 CompressionLevel compressionLevel = CompressionLevel.SmallestSize;
854 CompressionLevel compressionLevel = CompressionLevel.Optimal;
857 Logger.LogDebug(
"Creating new entry: {name} in the bundle with compression level {level}", entryName, compressionLevel);
858 ZipArchiveEntry entry = zip.CreateEntry(entryName, compressionLevel);
860 using Stream stream = entry.Open();
861 stream.Write(data, 0, data.Length);
864 Logger.LogInformation(
"Wrote entry: {name} with {size} bytes to the bundle", entryName, data.Length);
874 Ensure.Any.IsNotNull(stream, nameof(stream));
878 if (stream is MemoryStream memoryStream)
880 result = memoryStream.ToArray();
884 MemoryStream ms =
new();
886 result = ms.ToArray();
899 Ensure.Any.IsNotNull(stream, nameof(stream));
901 using SHA512 sha512 = SHA512.Create();
902 return sha512.ComputeHash(stream);
912 Ensure.Collection.HasItems(data, nameof(data));
914 using SHA512 sha512 = SHA512.Create();
915 return sha512.ComputeHash(data);
Represents a bundle that holds file hashes and signatures.
virtual string DefaultBundleName
Gets the default name of the bundle.
Stream GetStream(string entryName, ReadSource readSource=ReadSource.Automatic)
Gets a read-only stream for an entry in the bundle and caches the entry data if the bundle is Read-on...
readonly JsonSerializerOptions SerializerOptions
Gets the JSON serializer options.
string BundleName
Gets the name of the bundle file.
void WriteEntry(ZipArchive zip, string entryName, byte[] data)
Writes an entry to a ZipArchive.
virtual void Parse(ZipArchive zip)
Parses the bundle contents from a ZipArchive.
bool CheckEntryNameSecurity(string entryName, bool throwException=true)
Checks whether the entry name is protected and throws an exception if it is.
byte[] GetBytes(string entryName, ReadSource readSource)
Gets the data of an entry in the bundle as bytes array and caches the entry data if the bundle is Rea...
byte[] Export(object structuredData)
Exports the specified structured data to a byte array.
void AddEntry(string path, string destinationPath="./", string? rootPath=null)
Adds a file entry to the bundle.
static byte[] ReadStream(Stream stream)
Reads a stream into a byte array.
string BundlePath
Gets the full path of the bundle file.
void EnsureWritable()
Throws an exception if the bundle is read-only.
string RootPath
Gets the root path of the bundle.
ILogger Logger
Gets the logger to use for logging.
bool VerifyFile(string entryName)
Verifies the integrity of a file in the bundle.
bool VerifyCertificate(string certificateHash, X509ChainPolicy? policy=null)
Verifies the validity of a certificate using the specified certificate hash.
bool VerifyCertificate(X509Certificate2 certificate, X509ChainPolicy? policy=null)
Verifies the validity of a certificate.
void DeleteEntry(string entryName)
Deletes an entry from the bundle.
void Sign(X509Certificate2 certificate, RSA privateKey)
Signs the bundle with the specified certificate and private key.
void LoadFromBytes(byte[] bundleContent)
Loads the bundle from a byte array.
static byte[] ComputeSHA512Hash(Stream stream)
Computes the SHA-512 hash of a stream.
void Update()
Writes changes to the bundle file.
void LoadFromFile(bool readOnly=true)
Loads the bundle from the file system.
bool CacheEntry(string entryName, byte[] data)
Caches an entry in memory.
static byte[] ComputeSHA512Hash(byte[] data)
Computes the SHA-512 hash of a byte array.
bool Exists(string entryName, ReadSource readSource=ReadSource.Automatic)
Checks whether an entry exists in the bundle or on the disk.
byte[] Export(object structuredData, JsonSerializerContext jsonSerializerContext)
Exports the specified structured data to a byte array.
virtual byte[] GetManifestData()
Gets the manifest data as a byte array.
bool LoadedFromMemory
Gets a value indicating whether the bundle is loaded from memory.
bool VerifyCertificate(string certificateHash, out X509ChainStatus[] statuses, X509ChainPolicy? policy=null)
Verifies the validity of a certificate using the specified certificate hash.
Bundle(string bundlePath, ILogger? logger=null, int maxCacheSize=0x8000000)
Initializes a new instance of the Bundle class.
X509Certificate2 GetCertificate(string certificateHash)
Gets a certificate from the bundle using the specified certificate hash.
ZipArchive GetZipArchive(ZipArchiveMode mode=ZipArchiveMode.Read)
Gets a ZipArchive for the bundle.
bool VerifyCertificate(X509Certificate2 certificate, out X509ChainStatus[] statuses, X509ChainPolicy? policy=null)
Verifies the validity of a certificate.
bool Loaded
Gets a value indicating whether the bundle is loaded.
Action< ZipArchive >? Updating
Occurs when the bundle file is being updated.
bool ReadOnly
Gets a value indicating whether the bundle is read-only.
Manifest Manifest
Gets the manifest of the bundle.
HashSet< string > ProtectedEntryNames
Gets the list of sensitive names.
bool VerifySignature(string certificateHash)
Verifies the signature of the bundle using the specified certificate hash.
Signatures Signatures
Gets the signatures of the bundle.
ReadSource GetReadSource(string entryName, ReadSource readSource=ReadSource.Automatic)
Gets the read source for an entry name.
Represents a manifest that holds entries of file names and their corresponding hashes.
static string GetNormalizedEntryName(string path)
Converts the path to an standard zip entry name.
void DeleteEntry(string entryName)
Deletes an entry from the manifest.
bool StoreOriginalFiles
Gets or sets a value indicating whether the files should be stored in the bundle.
void AddEntry(string entryName, byte[] hash)
Adds an entry to the manifest.
ConcurrentDictionary< string, byte[]> GetEntries()
Gets the entries as a thread-safe concurrent dictionary.
HashSet< string > ProtectedEntryNames
Gets or sets the list of entry names that should be protected by the bundle from accidental modificat...
Represents a collection of manifest signatures.
Dictionary< string, byte[]> Certificates
Gets or sets the signature certificates.
Dictionary< string, byte[]> Entries
Gets or sets the signature entries.
ReadSource
Specifies the source from which to read the data.