Skip to content

Commit 757c417

Browse files
committed
Validate module package host compatibility on install
- Fail fast when modpkg targets a different host UI (Blazor vs Avalonia)\n- Require host-specific Modulus.Package with TargetHost==hostType\n- Improve install error rendering in Blazor and Avalonia dialogs\n- Remove Home Blazor page background override\n- Add/adjust tests for host validation
1 parent 22b25bb commit 757c417

File tree

8 files changed

+198
-33
lines changed

8 files changed

+198
-33
lines changed

src/Hosts/Modulus.Host.Blazor/Components/Pages/Modules.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
@if (!string.IsNullOrEmpty(ViewModel.ErrorMessage))
1111
{
1212
<MudAlert Severity="Severity.Error" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="@(() => ViewModel.ErrorMessage = null)">
13-
@ViewModel.ErrorMessage
13+
<MudText Typo="Typo.body2" Style="white-space: pre-line">@ViewModel.ErrorMessage</MudText>
1414
</MudAlert>
1515
}
1616

src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ModuleListViewModel.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,9 @@ public async Task InstallFromStreamAsync(Stream packageStream, string fileName,
316316

317317
if (!result.Success)
318318
{
319-
ErrorMessage = result.Error ?? "Installation failed.";
319+
ErrorMessage =
320+
result.Error ??
321+
"Installation failed. The selected package may not be compatible with this host.";
320322
IsLoading = false;
321323
return;
322324
}

src/Modules/Home/Home.UI.Blazor/HomePage.razor.css

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,8 @@
77
margin: 0 auto;
88
}
99

10-
/* Theme backgrounds */
11-
.home-container.dark-theme {
12-
background: linear-gradient(135deg, #0a0a0f 0%, #12121a 100%);
13-
color: #e8e8e8;
14-
}
15-
16-
.home-container.light-theme {
17-
background: linear-gradient(135deg, #E8DBFF 0%, #D4C4F0 50%, #C9B8E8 100%);
18-
color: #1a1a1a;
19-
}
10+
/* Theme backgrounds
11+
Intentionally NOT setting page background here. The host app owns the content background. */
2012

2113
.loading-container {
2214
display: flex;

src/Modulus.Core/Installation/ModuleInstallerService.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,34 @@ public async Task<ModuleInstallResult> InstallFromPackageAsync(string packagePat
256256
return ModuleInstallResult.Failed(
257257
$"Module '{existingModule.DisplayName}' is a system module and cannot be overwritten by user installation.");
258258
}
259-
260-
// User module - require confirmation
259+
}
260+
261+
// ===== CRITICAL: Host-aware manifest validation BEFORE confirmation / file operations =====
262+
// This prevents a host from "installing" a package that targets a different UI host
263+
// (e.g., Blazor host installing an Avalonia-only package) and then attempting to load it.
264+
if (!string.IsNullOrWhiteSpace(hostType))
265+
{
266+
var validation = await _manifestValidator.ValidateAsync(tempDir, manifestPath, manifest, hostType, cancellationToken);
267+
if (!validation.IsValid)
268+
{
269+
// Fail fast: do not copy files, do not write DB records.
270+
var details = string.Join(Environment.NewLine, validation.Errors.Select(e => $"- {e}"));
271+
var message =
272+
$"Package is not compatible with host '{hostType}'." +
273+
Environment.NewLine +
274+
details;
275+
return ModuleInstallResult.Failed(message);
276+
}
277+
}
278+
279+
// User module - require confirmation (only after compatibility validation)
280+
if (existingModule != null)
281+
{
261282
if (!overwrite)
262283
{
263284
return ModuleInstallResult.ConfirmationRequired(identity.Id, manifest.Metadata.DisplayName);
264285
}
265-
286+
266287
// Get existing module's directory path for cleanup
267288
existingModulePath = Path.GetDirectoryName(
268289
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, existingModule.Path)));

src/Modulus.Core/Manifest/DefaultManifestValidator.cs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,45 @@ public Task<ManifestValidationResult> ValidateAsync(string packagePath, string m
145145
// Host-specific Asset validation
146146
if (hostType != null)
147147
{
148-
var hasHostPackage = packageAssets.Any(a =>
149-
string.IsNullOrEmpty(a.TargetHost) || // No target = all hosts
150-
string.Equals(a.TargetHost, hostType, StringComparison.OrdinalIgnoreCase));
151-
152-
if (!hasHostPackage)
148+
// IMPORTANT:
149+
// - TargetHost is used to differentiate host UI packages (Blazor/Avalonia).
150+
// - A host-agnostic core package (TargetHost empty) MUST NOT satisfy the UI requirement.
151+
var hostSpecificPackages = packageAssets
152+
.Where(a =>
153+
!string.IsNullOrWhiteSpace(a.TargetHost) &&
154+
string.Equals(a.TargetHost, hostType, StringComparison.OrdinalIgnoreCase))
155+
.ToList();
156+
157+
if (hostSpecificPackages.Count == 0)
158+
{
159+
var declaredTargets = packageAssets
160+
.Select(a => a.TargetHost)
161+
.Where(t => !string.IsNullOrWhiteSpace(t))
162+
.Distinct(StringComparer.OrdinalIgnoreCase)
163+
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
164+
.ToList();
165+
166+
var declaredTargetsText = declaredTargets.Count == 0
167+
? "(none)"
168+
: string.Join(", ", declaredTargets);
169+
170+
errors.Add(
171+
$"No host-specific Package asset found for host '{hostType}'. " +
172+
$"Expected a '{ModulusAssetTypes.Package}' asset with TargetHost='{hostType}'. " +
173+
$"Declared TargetHost values: {declaredTargetsText}.");
174+
}
175+
else
153176
{
154-
errors.Add($"No Package asset available for host '{hostType}'.");
177+
// Ensure host-specific package entries have a usable path.
178+
foreach (var a in hostSpecificPackages)
179+
{
180+
if (string.IsNullOrWhiteSpace(a.Path))
181+
{
182+
errors.Add(
183+
$"Host-specific Package asset for host '{hostType}' is missing Path. " +
184+
$"Asset Type='{a.Type}', TargetHost='{a.TargetHost}'.");
185+
}
186+
}
155187
}
156188
}
157189

src/Modulus.UI.Avalonia/Controls/MessageDialog/MessageDialog.cs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,21 +195,28 @@ private void BuildContent()
195195
contentPanel.Children.Add(buttonPanel);
196196

197197
// Message
198-
var messageScroll = new ScrollViewer
198+
// Use read-only TextBox so multi-line diagnostics render reliably and users can copy details.
199+
var messageBox = new TextBox
199200
{
200-
MaxHeight = 200,
201-
VerticalScrollBarVisibility = ScrollBarVisibility.Auto
201+
Text = _message.Replace("\r\n", "\n"),
202+
IsReadOnly = true,
203+
AcceptsReturn = true,
204+
TextWrapping = TextWrapping.Wrap,
205+
FontSize = 14,
206+
Foreground = new SolidColorBrush(MessageTextColor),
207+
Background = Brushes.Transparent,
208+
BorderThickness = new Thickness(0),
209+
MinHeight = 60
202210
};
203211

204-
var messageText = new TextBlock
212+
var messageScroll = new ScrollViewer
205213
{
206-
Text = _message,
207-
TextWrapping = TextWrapping.Wrap,
208-
FontSize = 14,
209-
Foreground = new SolidColorBrush(MessageTextColor)
214+
MaxHeight = 200,
215+
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
216+
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
217+
Content = messageBox
210218
};
211219

212-
messageScroll.Content = messageText;
213220
contentPanel.Children.Add(messageScroll);
214221

215222
contentBorder.Child = contentPanel;

tests/Modulus.Core.Tests/DefaultManifestValidatorTests.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ public async Task ValidateAsync_ValidManifest_ReturnsSuccess()
8080
var manifestPath = Path.Combine(tempDir, "extension.vsixmanifest");
8181
var manifest = CreateTestManifest();
8282

83-
// Create the dll file so path validation passes
83+
// Create the dll files so path validation passes
8484
File.WriteAllBytes(Path.Combine(tempDir, "Test.Core.dll"), Array.Empty<byte>());
85+
File.WriteAllBytes(Path.Combine(tempDir, "Test.UI.Avalonia.dll"), Array.Empty<byte>());
8586

8687
var validator = new DefaultManifestValidator(_logger);
8788
var result = await validator.ValidateAsync(tempDir, manifestPath, manifest, ModulusHostIds.Avalonia);
@@ -97,8 +98,9 @@ public async Task ValidateAsync_UnsupportedAssetType_IsRejected()
9798
var manifestPath = Path.Combine(tempDir, "extension.vsixmanifest");
9899
var manifest = CreateTestManifest();
99100

100-
// Create the dll file so path validation passes
101+
// Create the dll files so path validation passes
101102
File.WriteAllBytes(Path.Combine(tempDir, "Test.Core.dll"), Array.Empty<byte>());
103+
File.WriteAllBytes(Path.Combine(tempDir, "Test.UI.Avalonia.dll"), Array.Empty<byte>());
102104

103105
// Unsupported asset types are rejected (only latest design is allowed).
104106
manifest.Assets.Add(new ManifestAsset { Type = "Unsupported.Asset", TargetHost = ModulusHostIds.Avalonia });
@@ -110,6 +112,28 @@ public async Task ValidateAsync_UnsupportedAssetType_IsRejected()
110112
Assert.Contains(result.Errors, e => e.Contains("Unsupported asset type", StringComparison.OrdinalIgnoreCase));
111113
}
112114

115+
[Fact]
116+
public async Task ValidateAsync_HostSpecificPackageAssetMissing_ReturnsError()
117+
{
118+
var tempDir = CreateTempDir();
119+
var manifestPath = Path.Combine(tempDir, "extension.vsixmanifest");
120+
var manifest = CreateTestManifest();
121+
122+
// Remove host-specific package asset to simulate Avalonia-only host UI missing
123+
manifest.Assets.RemoveAll(a =>
124+
string.Equals(a.Type, ModulusAssetTypes.Package, StringComparison.OrdinalIgnoreCase) &&
125+
string.Equals(a.TargetHost, ModulusHostIds.Avalonia, StringComparison.OrdinalIgnoreCase));
126+
127+
// Create only core dll file
128+
File.WriteAllBytes(Path.Combine(tempDir, "Test.Core.dll"), Array.Empty<byte>());
129+
130+
var validator = new DefaultManifestValidator(_logger);
131+
var result = await validator.ValidateAsync(tempDir, manifestPath, manifest, ModulusHostIds.Avalonia);
132+
133+
Assert.False(result.IsValid);
134+
Assert.Contains(result.Errors, e => e.Contains("No host-specific Package asset found", StringComparison.OrdinalIgnoreCase));
135+
}
136+
113137
private static VsixManifest CreateTestManifest(
114138
string id = "test-module",
115139
string version = "1.0.0",
@@ -135,7 +159,8 @@ private static VsixManifest CreateTestManifest(
135159
},
136160
Assets = new List<ManifestAsset>
137161
{
138-
new() { Type = ModulusAssetTypes.Package, Path = "Test.Core.dll" }
162+
new() { Type = ModulusAssetTypes.Package, Path = "Test.Core.dll" },
163+
new() { Type = ModulusAssetTypes.Package, Path = "Test.UI.Avalonia.dll", TargetHost = ModulusHostIds.Avalonia }
139164
}
140165
};
141166
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.IO.Compression;
2+
using Microsoft.Extensions.Logging;
3+
using Modulus.Core.Installation;
4+
using Modulus.Core.Manifest;
5+
using Modulus.Infrastructure.Data.Repositories;
6+
using Modulus.Sdk;
7+
using NSubstitute;
8+
9+
namespace Modulus.Core.Tests.Installation;
10+
11+
public class ModuleInstallerServicePackageHostValidationTests
12+
{
13+
[Fact]
14+
public async Task InstallFromPackageAsync_WhenHostNotSupported_ReturnsFailed_AndDoesNotWriteDb()
15+
{
16+
var tempRoot = Path.Combine(Path.GetTempPath(), "ModulusTests", Guid.NewGuid().ToString("N"));
17+
Directory.CreateDirectory(tempRoot);
18+
19+
var packageDir = Path.Combine(tempRoot, "pkg");
20+
Directory.CreateDirectory(packageDir);
21+
22+
// Create a minimal package layout: manifest + one dll (core)
23+
File.WriteAllBytes(Path.Combine(packageDir, "Test.Core.dll"), Array.Empty<byte>());
24+
WriteManifest(
25+
packageDir,
26+
moduleId: Guid.NewGuid().ToString(),
27+
supportedHostId: ModulusHostIds.Avalonia,
28+
hostSpecificUiDllName: "Test.UI.Avalonia.dll",
29+
hostSpecificUiHostId: ModulusHostIds.Avalonia);
30+
File.WriteAllBytes(Path.Combine(packageDir, "Test.UI.Avalonia.dll"), Array.Empty<byte>());
31+
32+
var modpkgPath = Path.Combine(tempRoot, "Test.modpkg");
33+
ZipFile.CreateFromDirectory(packageDir, modpkgPath);
34+
35+
var moduleRepo = Substitute.For<IModuleRepository>();
36+
var menuRepo = Substitute.For<IMenuRepository>();
37+
var cleanup = Substitute.For<IModuleCleanupService>();
38+
var logger = Substitute.For<ILogger<ModuleInstallerService>>();
39+
var validator = new DefaultManifestValidator(Substitute.For<ILogger<DefaultManifestValidator>>());
40+
41+
var sut = new ModuleInstallerService(moduleRepo, menuRepo, validator, cleanup, logger);
42+
43+
var result = await sut.InstallFromPackageAsync(modpkgPath, overwrite: false, hostType: ModulusHostIds.Blazor);
44+
45+
Assert.False(result.Success);
46+
Assert.False(result.RequiresConfirmation);
47+
Assert.NotNull(result.Error);
48+
Assert.Contains("not compatible", result.Error!, StringComparison.OrdinalIgnoreCase);
49+
50+
await moduleRepo.DidNotReceiveWithAnyArgs().UpsertAsync(default!, default);
51+
await menuRepo.DidNotReceiveWithAnyArgs().ReplaceModuleMenusAsync(default!, default!, default);
52+
53+
try { Directory.Delete(tempRoot, true); } catch { /* ignore */ }
54+
}
55+
56+
private static void WriteManifest(
57+
string moduleDir,
58+
string moduleId,
59+
string supportedHostId,
60+
string hostSpecificUiDllName,
61+
string hostSpecificUiHostId)
62+
{
63+
var manifest =
64+
$@"<?xml version=""1.0"" encoding=""utf-8""?>
65+
<PackageManifest Version=""2.0.0"" xmlns=""http://schemas.microsoft.com/developer/vsx-schema/2011"">
66+
<Metadata>
67+
<Identity Id=""{moduleId}"" Version=""1.0.0"" Language=""en-US"" Publisher=""Test"" />
68+
<DisplayName>Test Module</DisplayName>
69+
<Description>Test</Description>
70+
</Metadata>
71+
72+
<Installation>
73+
<InstallationTarget Id=""{supportedHostId}"" Version=""[1.0,)"" />
74+
</Installation>
75+
76+
<Assets>
77+
<Asset Type=""{ModulusAssetTypes.Package}"" Path=""Test.Core.dll"" />
78+
<Asset Type=""{ModulusAssetTypes.Package}"" Path=""{hostSpecificUiDllName}"" TargetHost=""{hostSpecificUiHostId}"" />
79+
</Assets>
80+
</PackageManifest>";
81+
82+
File.WriteAllText(Path.Combine(moduleDir, SystemModuleInstaller.VsixManifestFileName), manifest);
83+
}
84+
}
85+
86+

0 commit comments

Comments
 (0)