diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index 5814623..c39f1e4 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -1,5 +1,6 @@ -using System.IO.Compression; +using System.IO.Compression; using System.Reflection; +using System.Security.Cryptography; using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPackage; @@ -246,5 +247,198 @@ public void LoadPackageReadsSpec() Directory.Delete(packagePath, true); } } + + /// + /// Tests package validation with extension/type checks. + /// + [Fact] + public void ValidatePackageRejectsInvalidExtension() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var dummyFile = Path.Combine(packagePath, "dummy.zip"); + + try + { + Directory.CreateDirectory(packagePath); + File.WriteAllText(dummyFile, "not-a-package"); + + // act + var validation = packageManager.ValidatePackage(dummyFile); + + // validation + Assert.False(validation.IsValid); + Assert.Contains(validation.Messages, x => x.Contains("extension", StringComparison.OrdinalIgnoreCase)); + } + finally + { + if (File.Exists(dummyFile)) + { + File.Delete(dummyFile); + } + + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests a complete package lifecycle using explicit package manager operations. + /// + [Fact] + public void PackageLifecycleInstallDeactivateActivateUninstall() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "lifecycle.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "lifecycle", "1.0.0"); + + // act + validation (install active) + var install = packageManager.InstallPackage(packageFile, true); + Assert.True(install.Success); + Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage("lifecycle")?.State); + + // act + validation (deactivate) + var deactivate = packageManager.DeactivatePackage("lifecycle"); + Assert.True(deactivate.Success); + Assert.Equal(PackageCatalogeItemState.Disable, packageManager.GetPackage("lifecycle")?.State); + + // act + validation (activate) + var activate = packageManager.ActivatePackage("lifecycle"); + Assert.True(activate.Success); + Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage("lifecycle")?.State); + + // act + validation (uninstall) + var uninstall = packageManager.UninstallPackage("lifecycle"); + Assert.True(uninstall.Success); + Assert.Null(packageManager.GetPackage("lifecycle")); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests that update fails when uploaded package id does not match the requested package id. + /// + [Fact] + public void UpdatePackageRejectsMismatchedId() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "other.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "other", "1.0.0"); + + // act + var result = packageManager.UpdatePackage("expected", packageFile); + + // validation + Assert.False(result.Success); + Assert.Contains("does not match", result.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests package SHA-256 verification support. + /// + [Fact] + public void ValidatePackageWithSha256() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "signed.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "signed", "1.0.0"); + var expectedHash = ComputeSha256(packageFile); + + // act + var valid = packageManager.ValidatePackage(packageFile, expectedSha256: expectedHash); + var invalid = packageManager.ValidatePackage(packageFile, expectedSha256: "deadbeef"); + + // validation + Assert.True(valid.IsValid); + Assert.False(invalid.IsValid); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Creates a simple package archive for tests. + /// + /// The package file path. + /// The package id. + /// The package version. + private static void CreatePackageArchive(string file, string id, string version) + { + using var zip = ZipFile.Open(file, ZipArchiveMode.Create); + var specEntry = zip.CreateEntry($"{id}.spec"); + using (var writer = new StreamWriter(specEntry.Open())) + { + writer.Write($@" + + {id} + {version} + {id}-title + UnitTest + "); + } + + // add minimal lib folder marker to resemble package layout + zip.CreateEntry("lib/"); + } + + /// + /// Computes SHA-256 for a test file. + /// + /// The file path. + /// The sha-256 hash as lowercase hex. + private static string ComputeSha256(string file) + { + using var stream = File.OpenRead(file); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs index 815f347..16b6a81 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs @@ -1,6 +1,7 @@ -using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebPlugin.Model; namespace WebExpress.WebCore.Test.Manager { @@ -253,5 +254,24 @@ public void IsIContext() Assert.True(typeof(IContext).IsAssignableFrom(plugin.GetType()), $"Plugin context {plugin.GetType().Name} does not implement IContext."); } } + + /// + /// Tests runtime plugin metadata retrieval. + /// + [Fact] + public void GetPluginRuntimeInfos() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var pluginManager = componentHub.PluginManager; + + // act + var infos = pluginManager.GetPluginRuntimeInfos().ToList(); + + // validation + Assert.NotEmpty(infos); + Assert.Contains(infos, x => x.PluginContext.PluginId.ToString() == "webexpress.webcore.test"); + Assert.All(infos, x => Assert.Equal(PluginRuntimeState.Active, x.State)); + } } } diff --git a/src/WebExpress.WebCore/WebPackage/IPackageManager.cs b/src/WebExpress.WebCore/WebPackage/IPackageManager.cs index 40c14c3..0f05615 100644 --- a/src/WebExpress.WebCore/WebPackage/IPackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/IPackageManager.cs @@ -1,4 +1,6 @@ -using System; +using System; +using System.IO; +using System.Collections.Generic; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPackage.Model; @@ -24,5 +26,80 @@ public interface IPackageManager : IComponentManager /// Gets the catalog of installed packages. /// PackageCatalog Catalog { get; } + + /// + /// Returns all package entries from the package catalog. + /// + /// An enumerable collection with all package entries. + IEnumerable GetPackages(); + + /// + /// Returns a package by id. + /// + /// The package id. + /// The package or null. + PackageCatalogItem GetPackage(string packageId); + + /// + /// Validates a package file. + /// + /// The package file path. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The validation result. + PackageValidationResult ValidatePackage(string packageFile, long maxPackageBytes = 0, string expectedSha256 = null); + + /// + /// Uploads and installs a package from a stream. + /// + /// The package stream. + /// The package file name. + /// True to activate directly after install; false to keep it disabled. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The operation result. + PackageOperationResult UploadPackage(Stream packageStream, string fileName, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null); + + /// + /// Installs a package from a file. + /// + /// The package file path. + /// True to activate directly after install; false to keep it disabled. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The operation result. + PackageOperationResult InstallPackage(string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null); + + /// + /// Activates a package. + /// + /// The package id. + /// The operation result. + PackageOperationResult ActivatePackage(string packageId); + + /// + /// Deactivates a package. + /// + /// The package id. + /// The operation result. + PackageOperationResult DeactivatePackage(string packageId); + + /// + /// Updates a package from a file path. + /// + /// The package id. + /// The package file path. + /// True to activate directly after update; false to keep it disabled. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The operation result. + PackageOperationResult UpdatePackage(string packageId, string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null); + + /// + /// Uninstalls and removes a package. + /// + /// The package id. + /// The operation result. + PackageOperationResult UninstallPackage(string packageId); } } diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs index f74228f..3fee70c 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs @@ -62,6 +62,11 @@ public class PackageItem /// public IEnumerable PluginSources { get; set; } + /// + /// Gets or sets the package dependencies. + /// + public IEnumerable Dependencies { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs index 5486934..5b3dffb 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs @@ -80,6 +80,12 @@ public class PackageItemSpec [XmlElement("plugin", IsNullable = true)] public string[] Plugins { get; set; } + /// + /// Gets or sets the package dependencies. + /// + [XmlElement("dependency", IsNullable = true)] + public string[] Dependencies { get; set; } + /// /// Gets or sets the artifacts. /// diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageOperationResult.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageOperationResult.cs new file mode 100644 index 0000000..67c18b4 --- /dev/null +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageOperationResult.cs @@ -0,0 +1,55 @@ +namespace WebExpress.WebCore.WebPackage.Model +{ + /// + /// Represents the result of a package lifecycle operation. + /// + public sealed class PackageOperationResult + { + /// + /// Gets or sets whether the operation was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets an optional operation message. + /// + public string Message { get; set; } + + /// + /// Gets or sets the affected package, if available. + /// + public PackageCatalogItem Package { get; set; } + + /// + /// Creates a successful operation result. + /// + /// The result message. + /// The affected package. + /// The created operation result. + public static PackageOperationResult Ok(string message, PackageCatalogItem package = null) + { + return new PackageOperationResult() + { + Success = true, + Message = message, + Package = package + }; + } + + /// + /// Creates a failed operation result. + /// + /// The result message. + /// The affected package. + /// The created operation result. + public static PackageOperationResult Failed(string message, PackageCatalogItem package = null) + { + return new PackageOperationResult() + { + Success = false, + Message = message, + Package = package + }; + } + } +} diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageValidationResult.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageValidationResult.cs new file mode 100644 index 0000000..502d527 --- /dev/null +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageValidationResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebPackage.Model +{ + /// + /// Represents the result of package file validation. + /// + public sealed class PackageValidationResult + { + /// + /// Gets or sets whether the package is valid. + /// + public bool IsValid { get; set; } + + /// + /// Gets validation messages. + /// + public List Messages { get; } = []; + + /// + /// Gets or sets metadata resolved from the package file. + /// + public PackageCatalogItem Package { get; set; } + } +} diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs index 72b4d4b..0c9e7a5 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs @@ -214,6 +214,7 @@ private static void SpecToZip(ZipArchive archive, PackageItemSpec package) Description = package?.Description, Tags = package?.Tags, Plugins = package?.Plugins?.Select(x => $"{zipBinarys}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray(), + Dependencies = package?.Dependencies, }; serializer.Serialize(zipStream, newPackage); @@ -442,4 +443,4 @@ private static string SanitizeEntryPath(string entryPath) return string.Join("/", safeSegments); } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore/WebPackage/PackageManager.cs b/src/WebExpress.WebCore/WebPackage/PackageManager.cs index 992b0b5..597e574 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageManager.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reflection; using System.Runtime.Versioning; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -291,6 +292,398 @@ public void Scan() } } + /// + /// Returns all package entries from the package catalog. + /// + /// An enumerable collection with all package entries. + public IEnumerable GetPackages() + { + lock (_scanLock) + { + return [.. Catalog.Packages.Where(x => x is not null)]; + } + } + + /// + /// Returns a package by id. + /// + /// The package id. + /// The package or null. + public PackageCatalogItem GetPackage(string packageId) + { + if (string.IsNullOrWhiteSpace(packageId)) + { + return null; + } + + lock (_scanLock) + { + return Catalog.Packages + .FirstOrDefault(x => x is not null && x.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + /// Validates a package file. + /// + /// The package file path. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The validation result. + public PackageValidationResult ValidatePackage(string packageFile, long maxPackageBytes = 0, string expectedSha256 = null) + { + var result = new PackageValidationResult(); + + if (string.IsNullOrWhiteSpace(packageFile)) + { + result.Messages.Add("The package path is empty."); + return result; + } + + if (!File.Exists(packageFile)) + { + result.Messages.Add("The package file does not exist."); + return result; + } + + if (!Path.GetExtension(packageFile).Equals(".wxp", StringComparison.OrdinalIgnoreCase)) + { + result.Messages.Add("The package file extension must be '.wxp'."); + return result; + } + + if (maxPackageBytes > 0) + { + var fileInfo = new FileInfo(packageFile); + if (fileInfo.Length > maxPackageBytes) + { + result.Messages.Add($"The package size exceeds the allowed limit ({maxPackageBytes} bytes)."); + return result; + } + } + + if (!string.IsNullOrWhiteSpace(expectedSha256)) + { + var hash = ComputeSha256(packageFile); + if (!hash.Equals(expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase)) + { + result.Messages.Add("The package signature/hash verification failed."); + return result; + } + } + + try + { + using var zip = ZipFile.OpenRead(packageFile); + var specEntry = zip.Entries.FirstOrDefault(x => Path.GetExtension(x.FullName).Equals(".spec", StringComparison.OrdinalIgnoreCase)); + if (specEntry is null) + { + result.Messages.Add("The package does not contain a .spec file."); + return result; + } + + var spec = ReadSpec(specEntry); + if (spec is null) + { + result.Messages.Add("The package specification could not be read."); + return result; + } + + if (string.IsNullOrWhiteSpace(spec.Id)) + { + result.Messages.Add("The package id is missing in the .spec file."); + } + + if (string.IsNullOrWhiteSpace(spec.Version)) + { + result.Messages.Add("The package version is missing in the .spec file."); + } + + foreach (var plugin in spec.Plugins ?? []) + { + if (string.IsNullOrWhiteSpace(plugin)) + { + result.Messages.Add("A plugin entry in the .spec file is empty."); + continue; + } + + if (Path.IsPathRooted(plugin)) + { + result.Messages.Add($"The plugin entry '{plugin}' must be a relative path."); + } + + var normalized = plugin.Replace('\\', '/'); + if (normalized.Contains("..", StringComparison.Ordinal)) + { + result.Messages.Add($"The plugin entry '{plugin}' contains invalid traversal segments."); + } + } + + result.Package = CreateCatalogItem(packageFile, spec); + } + catch (Exception ex) + { + _httpServerContext.Log.Exception(ex); + result.Messages.Add("The package archive is invalid or corrupted."); + } + + result.IsValid = result.Messages.Count == 0; + return result; + } + + /// + /// Uploads and installs a package from a stream. + /// + /// The package stream. + /// The package file name. + /// True to activate directly after install; false to keep it disabled. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The operation result. + public PackageOperationResult UploadPackage(Stream packageStream, string fileName, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null) + { + if (packageStream is null) + { + return PackageOperationResult.Failed("The upload stream is null."); + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + return PackageOperationResult.Failed("The upload file name is empty."); + } + + var safeFileName = Path.GetFileName(fileName); + if (!safeFileName.EndsWith(".wxp", StringComparison.OrdinalIgnoreCase)) + { + return PackageOperationResult.Failed("The upload file extension must be '.wxp'."); + } + + var tmpFile = Path.Combine(_httpServerContext.PackagePath, $"{Guid.NewGuid()}.{safeFileName}"); + Directory.CreateDirectory(_httpServerContext.PackagePath); + + try + { + using (var fileStream = new FileStream(tmpFile, FileMode.Create, FileAccess.Write, FileShare.None)) + { + CopyStream(packageStream, fileStream, maxPackageBytes); + } + + var targetFile = Path.Combine(_httpServerContext.PackagePath, safeFileName); + File.Copy(tmpFile, targetFile, true); + + return InstallPackage(targetFile, activate, maxPackageBytes, expectedSha256); + } + catch (Exception ex) + { + _httpServerContext.Log.Exception(ex); + return PackageOperationResult.Failed("The package upload failed."); + } + finally + { + if (File.Exists(tmpFile)) + { + File.Delete(tmpFile); + } + } + } + + /// + /// Installs a package from a file. + /// + /// The package file path. + /// True to activate directly after install; false to keep it disabled. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The operation result. + public PackageOperationResult InstallPackage(string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null) + { + lock (_scanLock) + { + var validation = ValidatePackage(packageFile, maxPackageBytes, expectedSha256); + if (!validation.IsValid) + { + return PackageOperationResult.Failed(string.Join(" ", validation.Messages)); + } + + var package = validation.Package; + var safeFile = Path.GetFileName(packageFile); + var targetFile = Path.Combine(_httpServerContext.PackagePath, safeFile); + Directory.CreateDirectory(_httpServerContext.PackagePath); + + if (!Path.GetFullPath(packageFile).Equals(Path.GetFullPath(targetFile), StringComparison.OrdinalIgnoreCase)) + { + File.Copy(packageFile, targetFile, true); + } + + package.File = safeFile; + + var existing = Catalog.Packages + .FirstOrDefault(x => x is not null && x.Id.Equals(package.Id, StringComparison.OrdinalIgnoreCase)); + + if (existing is null) + { + Catalog.Packages.Add(package); + existing = package; + OnAddPackage(existing); + } + else + { + var oldPackageFile = Path.Combine(_httpServerContext.PackagePath, existing.File); + DeactivateAndUnregisterPackage(existing); + RemoveExtractedDirectory(existing); + + existing.File = package.File; + existing.Metadata = package.Metadata; + + if (!oldPackageFile.Equals(targetFile, StringComparison.OrdinalIgnoreCase) && File.Exists(oldPackageFile)) + { + File.Delete(oldPackageFile); + } + } + + if (!activate) + { + existing.State = PackageCatalogeItemState.Disable; + SaveCatalog(); + _componentHub.SitemapManager.Refresh(); + return PackageOperationResult.Ok($"Package '{existing.Id}' installed (disabled).", existing); + } + + var activateResult = ActivatePackage(existing.Id); + return activateResult.Success + ? PackageOperationResult.Ok($"Package '{existing.Id}' installed and activated.", existing) + : activateResult; + } + } + + /// + /// Activates a package. + /// + /// The package id. + /// The operation result. + public PackageOperationResult ActivatePackage(string packageId) + { + lock (_scanLock) + { + var package = GetPackage(packageId); + if (package is null) + { + return PackageOperationResult.Failed($"Package '{packageId}' was not found."); + } + + if (package.State == PackageCatalogeItemState.Active) + { + return PackageOperationResult.Ok($"Package '{packageId}' is already active.", package); + } + + var missingDependencies = GetUnfulfilledPackageDependencies(package).ToList(); + if (missingDependencies.Count > 0) + { + package.State = PackageCatalogeItemState.Disable; + SaveCatalog(); + return PackageOperationResult.Failed($"Package '{packageId}' has missing dependencies: {string.Join(", ", missingDependencies)}", package); + } + + DeactivateAndUnregisterPackage(package); + RemoveExtractedDirectory(package); + + ExtractPackage(package); + RegisterPackage(package); + BootPackage(package); + package.State = PackageCatalogeItemState.Active; + + SaveCatalog(); + _componentHub.SitemapManager.Refresh(); + + return PackageOperationResult.Ok($"Package '{packageId}' activated.", package); + } + } + + /// + /// Deactivates a package. + /// + /// The package id. + /// The operation result. + public PackageOperationResult DeactivatePackage(string packageId) + { + lock (_scanLock) + { + var package = GetPackage(packageId); + if (package is null) + { + return PackageOperationResult.Failed($"Package '{packageId}' was not found."); + } + + DeactivateAndUnregisterPackage(package); + RemoveExtractedDirectory(package); + package.State = PackageCatalogeItemState.Disable; + + SaveCatalog(); + _componentHub.SitemapManager.Refresh(); + + return PackageOperationResult.Ok($"Package '{packageId}' deactivated.", package); + } + } + + /// + /// Updates a package from a file path. + /// + /// The package id. + /// The package file path. + /// True to activate directly after update; false to keep it disabled. + /// Optional max allowed package size in bytes. 0 disables the limit check. + /// Optional expected SHA-256 hash in hex format. + /// The operation result. + public PackageOperationResult UpdatePackage(string packageId, string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null) + { + var validation = ValidatePackage(packageFile, maxPackageBytes, expectedSha256); + if (!validation.IsValid) + { + return PackageOperationResult.Failed(string.Join(" ", validation.Messages)); + } + + if (validation.Package is null || !validation.Package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + { + return PackageOperationResult.Failed($"The uploaded package id does not match '{packageId}'."); + } + + return InstallPackage(packageFile, activate, maxPackageBytes, expectedSha256); + } + + /// + /// Uninstalls and removes a package. + /// + /// The package id. + /// The operation result. + public PackageOperationResult UninstallPackage(string packageId) + { + lock (_scanLock) + { + var package = GetPackage(packageId); + if (package is null) + { + return PackageOperationResult.Failed($"Package '{packageId}' was not found."); + } + + DeactivateAndUnregisterPackage(package); + RemoveExtractedDirectory(package); + + var packageFile = Path.Combine(_httpServerContext.PackagePath, package.File); + if (File.Exists(packageFile)) + { + File.Delete(packageFile); + } + + Catalog.Packages.Remove(package); + OnRemovePackage(package); + + SaveCatalog(); + _componentHub.SitemapManager.Refresh(); + + return PackageOperationResult.Ok($"Package '{packageId}' uninstalled.", package); + } + } + /// /// Opens a package and finds the meta information. /// @@ -304,40 +697,16 @@ private PackageCatalogItem LoadPackage(string file) { using var zip = ZipFile.Open(file, ZipArchiveMode.Read); - var specEntry = zip.Entries.Where(x => Path.GetExtension(x.FullName) == ".spec").FirstOrDefault(); + var specEntry = zip.Entries + .FirstOrDefault(x => Path.GetExtension(x.FullName).Equals(".spec", StringComparison.OrdinalIgnoreCase)); if (specEntry is null) { _httpServerContext.Log.Warning($"package spec was not found in '{file}'"); return null; } - var serializer = new XmlSerializer(typeof(PackageItemSpec)); - PackageItemSpec spec; - using (var stream = specEntry.Open()) - { - spec = (PackageItemSpec)serializer.Deserialize(stream); - } - - return new PackageCatalogItem() - { - Id = spec.Id, - File = Path.GetFileName(file), - State = PackageCatalogeItemState.Available, - Metadata = new PackageItem() - { - FileName = Path.GetFileName(file), - Id = spec.Id, - Version = spec.Version, - Title = spec.Title, - Authors = spec.Authors, - License = spec.License, - Icon = spec.Icon, - Readme = spec.Readme, - Description = spec.Description, - Tags = spec.Tags, - PluginSources = spec.Plugins - } - }; + var spec = ReadSpec(specEntry); + return CreateCatalogItem(file, spec); } } catch (Exception ex) @@ -416,18 +785,41 @@ private void ExtractPackage(PackageCatalogItem package) using var zip = ZipFile.Open(packageFile, ZipArchiveMode.Read); var extractedPath = Path.Combine(_httpServerContext.PackagePath, Path.GetFileNameWithoutExtension(package?.File)); + var extractedPathFull = Path.GetFullPath(extractedPath); if (!Directory.Exists(extractedPath)) { Directory.CreateDirectory(extractedPath); } - foreach (var entry in zip.Entries.Where(x => Path.GetDirectoryName(x.FullName).StartsWith("lib", StringComparison.OrdinalIgnoreCase))) + foreach (var entry in zip.Entries) { + var normalized = (entry.FullName ?? string.Empty).Replace('\\', '/').TrimStart('/'); + + if (!normalized.StartsWith("lib/", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (normalized.Contains("../", StringComparison.Ordinal) || normalized.StartsWith("..", StringComparison.Ordinal)) + { + _httpServerContext.Log.Warning($"Unsafe package entry '{entry.FullName}' ignored."); + continue; + } + + var targetFilePath = Path.GetFullPath(Path.Combine(extractedPath, normalized)); + var isInExtractedPath = targetFilePath.StartsWith(extractedPathFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || targetFilePath.Equals(extractedPathFull, StringComparison.OrdinalIgnoreCase); + if (!isInExtractedPath) + { + _httpServerContext.Log.Warning($"Unsafe package entry '{entry.FullName}' ignored."); + continue; + } + // directory entries in the zip have an empty Name if (string.IsNullOrEmpty(entry.Name)) { - var dirPath = Path.Combine(extractedPath, entry.FullName); + var dirPath = targetFilePath; if (!Directory.Exists(dirPath)) { Directory.CreateDirectory(dirPath); @@ -436,7 +828,6 @@ private void ExtractPackage(PackageCatalogItem package) continue; } - var targetFilePath = Path.Combine(extractedPath, entry.FullName); var targetDir = Path.GetDirectoryName(targetFilePath); if (!Directory.Exists(targetDir)) @@ -444,10 +835,7 @@ private void ExtractPackage(PackageCatalogItem package) Directory.CreateDirectory(targetDir); } - if (!File.Exists(targetFilePath)) - { - entry.ExtractToFile(targetFilePath); - } + entry.ExtractToFile(targetFilePath, true); } } } @@ -592,6 +980,22 @@ private static bool HasPackageChanged(PackageCatalogItem existing, PackageCatalo } } + var dependenciesA = (existing.Metadata.Dependencies ?? []).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + var dependenciesB = (fromFile.Metadata.Dependencies ?? []).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + + if (dependenciesA.Length != dependenciesB.Length) + { + return true; + } + + for (int i = 0; i < dependenciesA.Length; i++) + { + if (!string.Equals(dependenciesA[i], dependenciesB[i], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; } @@ -639,6 +1043,235 @@ private void RemoveExtractedDirectory(PackageCatalogItem package) } } + /// + /// Reads and deserializes the package specification from an archive entry. + /// + /// The spec archive entry. + /// The deserialized package spec. + private static PackageItemSpec ReadSpec(ZipArchiveEntry specEntry) + { + var serializer = new XmlSerializer(typeof(PackageItemSpec)); + using var stream = specEntry.Open(); + using var xmlReader = XmlReader.Create(stream, new XmlReaderSettings() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }); + + return (PackageItemSpec)serializer.Deserialize(xmlReader); + } + + /// + /// Creates a package catalog item from a package specification. + /// + /// The package file path. + /// The package specification. + /// The package catalog item. + private static PackageCatalogItem CreateCatalogItem(string file, PackageItemSpec spec) + { + if (spec is null) + { + return null; + } + + return new PackageCatalogItem() + { + Id = spec.Id, + File = Path.GetFileName(file), + State = PackageCatalogeItemState.Available, + Metadata = new PackageItem() + { + FileName = Path.GetFileName(file), + Id = spec.Id, + Version = spec.Version, + Title = spec.Title, + Authors = spec.Authors, + License = spec.License, + Icon = spec.Icon, + Readme = spec.Readme, + Description = spec.Description, + Tags = spec.Tags, + PluginSources = spec.Plugins ?? [], + Dependencies = spec.Dependencies ?? [] + } + }; + } + + /// + /// Copies an input stream to an output stream while optionally enforcing a maximum number of bytes. + /// + /// The source stream. + /// The target stream. + /// The maximum allowed bytes. 0 disables the size check. + private static void CopyStream(Stream source, Stream target, long maxBytes) + { + long totalBytes = 0; + var buffer = new byte[81920]; + int read; + + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + totalBytes += read; + if (maxBytes > 0 && totalBytes > maxBytes) + { + throw new InvalidOperationException($"The uploaded package exceeds the allowed size ({maxBytes} bytes)."); + } + + target.Write(buffer, 0, read); + } + } + + /// + /// Computes the SHA-256 hash for a file. + /// + /// The file path. + /// The SHA-256 hash as lowercase hex string. + private static string ComputeSha256(string file) + { + using var stream = File.OpenRead(file); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + /// + /// Returns unfulfilled dependency expressions for a package. + /// + /// The package to evaluate. + /// The unfulfilled dependency list. + private IEnumerable GetUnfulfilledPackageDependencies(PackageCatalogItem package) + { + var dependencies = package?.Metadata?.Dependencies ?? []; + var missing = new List(); + + foreach (var dependency in dependencies.Where(x => !string.IsNullOrWhiteSpace(x))) + { + if (!TryParseDependency(dependency, out string id, out string op, out string requiredVersion)) + { + missing.Add(dependency); + continue; + } + + var dependencyPackage = Catalog.Packages + .FirstOrDefault(x => x is not null && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + + if (dependencyPackage is null || dependencyPackage.State == PackageCatalogeItemState.Disable) + { + missing.Add(dependency); + continue; + } + + if (!string.IsNullOrWhiteSpace(op)) + { + var currentVersion = dependencyPackage.Metadata?.Version; + if (!IsVersionConstraintSatisfied(currentVersion, op, requiredVersion)) + { + missing.Add(dependency); + } + } + } + + return missing; + } + + /// + /// Parses a dependency expression. + /// + /// The dependency expression (e.g. "pkg>=1.0.0"). + /// The dependency id. + /// The comparison operator. + /// The version constraint. + /// True if parsing was successful; otherwise false. + private static bool TryParseDependency(string expression, out string id, out string op, out string version) + { + id = null; + op = null; + version = null; + + if (string.IsNullOrWhiteSpace(expression)) + { + return false; + } + + var value = expression.Trim(); + var operators = new[] { ">=", "<=", "==", "=", ">", "<" }; + var index = -1; + var selectedOperator = string.Empty; + + foreach (var candidate in operators) + { + index = value.IndexOf(candidate, StringComparison.Ordinal); + if (index > 0) + { + selectedOperator = candidate; + break; + } + } + + if (index < 0) + { + id = value; + return !string.IsNullOrWhiteSpace(id); + } + + id = value[..index].Trim(); + op = selectedOperator; + version = value[(index + selectedOperator.Length)..].Trim(); + + return !string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(op) && !string.IsNullOrWhiteSpace(version); + } + + /// + /// Evaluates whether a version satisfies a version constraint. + /// + /// The current version. + /// The operator. + /// The required version. + /// True if the constraint is satisfied; otherwise false. + private static bool IsVersionConstraintSatisfied(string currentVersion, string op, string requiredVersion) + { + if (!TryParseVersion(currentVersion, out var current) || !TryParseVersion(requiredVersion, out var required)) + { + return false; + } + + var compare = current.CompareTo(required); + + return op switch + { + ">" => compare > 0, + ">=" => compare >= 0, + "<" => compare < 0, + "<=" => compare <= 0, + "=" => compare == 0, + "==" => compare == 0, + _ => false + }; + } + + /// + /// Parses a version string with support for prerelease/build suffixes. + /// + /// The version string. + /// The parsed version. + /// True if parsing succeeded; otherwise false. + private static bool TryParseVersion(string value, out Version version) + { + version = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim(); + var suffixIndex = normalized.IndexOfAny(['-', '+']); + if (suffixIndex >= 0) + { + normalized = normalized[..suffixIndex]; + } + + return Version.TryParse(normalized, out version); + } + /// /// Release of unmanaged resources reserved during use. /// @@ -646,4 +1279,4 @@ public void Dispose() { } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs b/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs index db05863..37ead1e 100644 --- a/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin.Model; namespace WebExpress.WebCore.WebPlugin { @@ -52,5 +53,11 @@ public interface IPluginManager : IComponentManager /// The context of the plugin. /// A collection of ApplicationContext instances. IEnumerable GetAssociatedApplications(IPluginContext pluginContext); + + /// + /// Gets runtime metadata for all known plugins, including dependency and status information. + /// + /// A list of plugin runtime metadata entries. + IEnumerable GetPluginRuntimeInfos(); } } diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeInfo.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeInfo.cs new file mode 100644 index 0000000..c1a3bb2 --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeInfo.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebPlugin.Model +{ + /// + /// Represents runtime metadata for a plugin. + /// + public sealed class PluginRuntimeInfo + { + /// + /// Gets or sets the plugin context. + /// + public IPluginContext PluginContext { get; set; } + + /// + /// Gets or sets the plugin dependency ids. + /// + public IEnumerable Dependencies { get; set; } = []; + + /// + /// Gets or sets the plugin runtime state. + /// + public PluginRuntimeState State { get; set; } + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs new file mode 100644 index 0000000..9cd6592 --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs @@ -0,0 +1,18 @@ +namespace WebExpress.WebCore.WebPlugin.Model +{ + /// + /// Represents the runtime state of a plugin. + /// + public enum PluginRuntimeState + { + /// + /// The plugin is loaded and active. + /// + Active, + + /// + /// The plugin is known but waiting for one or more dependencies. + /// + WaitingForDependencies + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index 3b7fe20..a8dc2e4 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -521,6 +521,33 @@ public IEnumerable GetAssociatedApplications(IPluginContext .Where(x => x is not null) ?? []; } + /// + /// Gets runtime metadata for all known plugins, including dependency and status information. + /// + /// A list of plugin runtime metadata entries. + public IEnumerable GetPluginRuntimeInfos() + { + var active = _dictionary.Values + .Where(x => x?.PluginContext is not null) + .Select(x => new PluginRuntimeInfo() + { + PluginContext = x.PluginContext, + Dependencies = x.Dependencies ?? [], + State = PluginRuntimeState.Active + }); + + var waiting = _unfulfilledDependencies.Values + .Where(x => x?.PluginContext is not null) + .Select(x => new PluginRuntimeInfo() + { + PluginContext = x.PluginContext, + Dependencies = x.Dependencies ?? [], + State = PluginRuntimeState.WaitingForDependencies + }); + + return [.. active.Concat(waiting)]; + } + /// /// Returns a plugin item based on the context.