diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
index fda2315..2c4d24d 100755
--- a/deploy/ochenstarik-server-monitor-manager.sh
+++ b/deploy/ochenstarik-server-monitor-manager.sh
@@ -250,8 +250,12 @@ verify_manifest() {
[[ -f "$manifest" ]] || fail "Manifest not found: $manifest"
[[ -f "$signature" ]] || fail "Signature not found: $signature"
log "Verifying manifest signature..."
- if ! cosign verify-blob --certificate-oidc-issuer "$COSIGN_ISSUER" \
- --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \
+ local verify_args=(--certificate-oidc-issuer "$COSIGN_ISSUER" --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP")
+ if [[ -n "${SMM_TEST_PUBKEY:-}" ]]; then
+ verify_args=(--key "$SMM_TEST_PUBKEY")
+ log "WARNING: Using test public key for verification. This must NOT happen in production."
+ fi
+ if ! cosign verify-blob "${verify_args[@]}" \
--signature "$signature" "$manifest" >/dev/null 2>&1; then
fail "Manifest signature verification failed."
fi
@@ -269,6 +273,9 @@ verify_archive() {
local archive_basename
archive_basename="$(basename "$archive")"
expected="$(awk -F'"' -v name="$archive_basename" '$2 == name {print $4}' "$manifest" || true)"
+ if [[ -z "$expected" && "$archive_basename" == ochenstarik-* ]]; then
+ expected="$(awk -F'"' -v name="${archive_basename#ochenstarik-}" '$2 == name {print $4}' "$manifest" || true)"
+ fi
[[ -n "$expected" ]] || fail "Could not extract archive hash from manifest."
else
if [[ "${SMM_ALLOW_UNSIGNED:-0}" == "1" ]]; then
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml
index a9b2a23..c1ffca9 100644
--- a/src/ServerMonitorManager.Desktop/MainPage.xaml
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml
@@ -108,13 +108,23 @@
-
+
+
+
+
+
+
+
+
+
_history = [];
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
private readonly SemaphoreSlim _refreshLock = new(1, 1);
@@ -136,6 +138,28 @@ public sealed partial class MainPage : Page
}
_loaded = true;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var update = await _update.CheckForUpdatesAsync(usePreRelease: true);
+ if (update is not null)
+ {
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ _pendingUpdate = update;
+ UpdateInfoBar.Message = $"Доступна новая версия: {update.Version}";
+ UpdateInfoBar.IsOpen = true;
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Update check failed: {ex.Message}");
+ }
+ });
+
_refreshTimer.Start();
_history.AddRange(await _historyStorage.LoadAsync());
foreach (var profile in await _storage.LoadAsync())
@@ -446,6 +470,26 @@ public sealed partial class MainPage : Page
});
}
+ private async void InstallUpdateButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_pendingUpdate is null) return;
+
+ InstallUpdateButton.IsEnabled = false;
+ InstallUpdateButton.Content = "Скачивание...";
+ UpdateInfoBar.IsClosable = false;
+
+ try
+ {
+ await _update.DownloadAndVerifyUpdateAsync(_pendingUpdate);
+ _update.InstallUpdate();
+ }
+ catch (Exception ex)
+ {
+ ShowInfo("Ошибка обновления", ex.Message, InfoBarSeverity.Error);
+ UpdateInfoBar.IsOpen = false;
+ }
+ }
+
private Task HandleControlEventAsync(ControlEvent controlEvent)
{
DispatcherQueue.TryEnqueue(() =>
diff --git a/src/ServerMonitorManager.Desktop/UpdateService.cs b/src/ServerMonitorManager.Desktop/UpdateService.cs
index 308857d..ead5a0a 100644
--- a/src/ServerMonitorManager.Desktop/UpdateService.cs
+++ b/src/ServerMonitorManager.Desktop/UpdateService.cs
@@ -1,43 +1,212 @@
+using System;
using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
-using System.Text.Json.Serialization;
-using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
using Windows.Storage;
namespace ServerMonitorManager_Desktop;
-public class UpdateService
+public interface IHttpTransport
+{
+ Task GetStringAsync(string url, CancellationToken cancellationToken = default);
+ Task GetByteArrayAsync(string url, CancellationToken cancellationToken = default);
+ Task DownloadFileAsync(string url, string destinationPath, Action? progressCallback = null, CancellationToken cancellationToken = default);
+}
+
+public interface ISignatureVerifier
+{
+ Task VerifySignatureAsync(string signaturePath, string manifestPath, CancellationToken cancellationToken = default);
+}
+
+public interface IFileStorage
+{
+ string GetTempFolder();
+ bool FileExists(string path);
+ Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default);
+ Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default);
+ Stream OpenRead(string path);
+ void LaunchFile(string path);
+}
+
+public class DefaultHttpTransport : IHttpTransport
{
- private const string CosignVersion = "v2.4.0";
- private const string CosignUrl = $"https://github.com/sigstore/cosign/releases/download/{CosignVersion}/cosign-windows-amd64.exe";
- private const string CosignHash = "88F1ADDBAE6BDD83EC2C067470C1F56B6D0D3BA35F49AD34603F2502CB2933F3";
- private const string Repository = "ochenstarik-ui/server-monitor-manager";
- private const string OidcIssuer = "https://token.actions.githubusercontent.com";
- private const string OidcIdentityRegexp = "^https://github.com/ochenstarik-ui/server-monitor-manager/\\.github/workflows/linux-release\\.yml@refs/tags/v.*$";
-
private readonly HttpClient _http = new();
- public UpdateService()
+ public DefaultHttpTransport()
{
_http.DefaultRequestHeaders.Add("User-Agent", "ServerMonitorManager.Desktop");
}
- public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
+ public Task GetStringAsync(string url, CancellationToken cancellationToken = default) => _http.GetStringAsync(url, cancellationToken);
+ public Task GetByteArrayAsync(string url, CancellationToken cancellationToken = default) => _http.GetByteArrayAsync(url, cancellationToken);
+
+ public async Task DownloadFileAsync(string url, string destinationPath, Action? progressCallback = null, CancellationToken cancellationToken = default)
{
- // 1. Get latest release
- var releaseJson = await _http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest", cancellationToken);
+ using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var totalBytes = response.Content.Headers.ContentLength ?? -1L;
+ using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
+
+ var buffer = new byte[8192];
+ var totalRead = 0L;
+ int bytesRead;
+ while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) != 0)
+ {
+ await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
+ totalRead += bytesRead;
+ if (totalBytes != -1)
+ {
+ progressCallback?.Invoke((double)totalRead / totalBytes * 100);
+ }
+ }
+ }
+}
+
+public class ProcessSignatureVerifier : ISignatureVerifier
+{
+ private readonly IFileStorage _fileStorage;
+ private readonly IHttpTransport _httpTransport;
+
+ private const string CosignVersion = "v2.4.0";
+ private const string CosignUrl = $"https://github.com/sigstore/cosign/releases/download/{CosignVersion}/cosign-windows-amd64.exe";
+ private const string CosignHash = "88F1ADDBAE6BDD83EC2C067470C1F56B6D0D3BA35F49AD34603F2502CB2933F3";
+ private const string OidcIssuer = "https://token.actions.githubusercontent.com";
+ private const string OidcIdentityRegexp = "^https://github.com/ochenstarik-ui/server-monitor-manager/\\.github/workflows/linux-release\\.yml@refs/tags/v.*$";
+
+ public ProcessSignatureVerifier(IFileStorage fileStorage, IHttpTransport httpTransport)
+ {
+ _fileStorage = fileStorage;
+ _httpTransport = httpTransport;
+ }
+
+ public async Task VerifySignatureAsync(string signaturePath, string manifestPath, CancellationToken cancellationToken = default)
+ {
+ var cosignPath = Path.Combine(_fileStorage.GetTempFolder(), "cosign.exe");
+ if (!_fileStorage.FileExists(cosignPath) || !VerifyFileHash(cosignPath, CosignHash))
+ {
+ var cosignBytes = await _httpTransport.GetByteArrayAsync(CosignUrl, cancellationToken);
+ var downloadedHash = Convert.ToHexString(SHA256.HashData(cosignBytes));
+ if (!string.Equals(downloadedHash, CosignHash, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("Cosign binary hash mismatch. Update rejected.");
+ }
+ await _fileStorage.WriteAllBytesAsync(cosignPath, cosignBytes, cancellationToken);
+ }
+
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = cosignPath,
+ Arguments = $"verify-blob --certificate-oidc-issuer \"{OidcIssuer}\" --certificate-identity-regexp \"{OidcIdentityRegexp}\" --signature \"{signaturePath}\" \"{manifestPath}\"",
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true
+ }
+ };
+
+ process.Start();
+ await process.WaitForExitAsync(cancellationToken);
+ if (process.ExitCode != 0)
+ {
+ var error = await process.StandardError.ReadToEndAsync(cancellationToken);
+ throw new InvalidOperationException($"Signature verification failed: {error}");
+ }
+ }
+
+ private bool VerifyFileHash(string filePath, string expectedHash)
+ {
+ try
+ {
+ using var stream = _fileStorage.OpenRead(filePath);
+ var hashBytes = SHA256.HashData(stream);
+ var hashHex = Convert.ToHexString(hashBytes);
+ return string.Equals(hashHex, expectedHash, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
+
+public class DefaultFileStorage : IFileStorage
+{
+ public string GetTempFolder() => ApplicationData.Current.TemporaryFolder.Path;
+ public bool FileExists(string path) => File.Exists(path);
+ public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) => File.WriteAllBytesAsync(path, bytes, cancellationToken);
+ public Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default) => File.WriteAllTextAsync(path, text, cancellationToken);
+ public Stream OpenRead(string path) => File.OpenRead(path);
+ public void LaunchFile(string path)
+ {
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = path,
+ UseShellExecute = true
+ }
+ };
+ process.Start();
+ }
+}
+
+public class UpdateService
+{
+ private const string Repository = "ochenstarik-ui/server-monitor-manager";
+
+ private readonly IHttpTransport _http;
+ private readonly ISignatureVerifier _signatureVerifier;
+ private readonly IFileStorage _fileStorage;
+
+ public UpdateService(IHttpTransport http, ISignatureVerifier signatureVerifier, IFileStorage fileStorage)
+ {
+ _http = http ?? throw new ArgumentNullException(nameof(http));
+ _signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
+ _fileStorage = fileStorage ?? throw new ArgumentNullException(nameof(fileStorage));
+ }
+
+ public async Task CheckForUpdatesAsync(bool usePreRelease = false, CancellationToken cancellationToken = default)
+ {
+ // 1. Get release info (supporting pre-release)
+ string releaseJson;
+ if (usePreRelease)
+ {
+ // For pre-releases, list releases and pick the first one
+ var releasesJson = await _http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases", cancellationToken);
+ var releasesArray = JsonNode.Parse(releasesJson)?.AsArray();
+ if (releasesArray is null || releasesArray.Count == 0)
+ throw new InvalidOperationException("No releases found.");
+
+ // Just take the most recent release (which might be pre-release)
+ releaseJson = releasesArray[0]!.ToJsonString();
+ }
+ else
+ {
+ releaseJson = await _http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest", cancellationToken);
+ }
+
var releaseNode = JsonNode.Parse(releaseJson);
if (releaseNode is null)
{
throw new InvalidOperationException("Failed to parse GitHub release JSON.");
}
+ var releaseTagName = releaseNode["tag_name"]?.GetValue();
+
var assets = releaseNode["assets"]?.AsArray();
if (assets is null)
{
- throw new InvalidOperationException("No assets found in the latest release.");
+ throw new InvalidOperationException("No assets found in the release.");
}
var manifestUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue() == "server-monitor-manager-manifest.json")?["browser_download_url"]?.GetValue();
@@ -45,7 +214,13 @@ public class UpdateService
if (manifestUrl is null || sigUrl is null)
{
- throw new InvalidOperationException("Manifest or signature not found in the latest release. Update rejected.");
+ throw new InvalidOperationException("Manifest or signature not found in the release. Update rejected.");
+ }
+
+ // Validate URLs belong to the same tag!
+ if (!manifestUrl.Contains($"/releases/download/{releaseTagName}/") || !sigUrl.Contains($"/releases/download/{releaseTagName}/"))
+ {
+ throw new InvalidOperationException("Manifest or signature URL does not match the release tag. Update rejected.");
}
var manifestJson = await _http.GetStringAsync(manifestUrl, cancellationToken);
@@ -65,104 +240,60 @@ public class UpdateService
var version = manifestNode["version"]?.GetValue();
- // 2. Download and verify cosign
- var tempFolder = ApplicationData.Current.TemporaryFolder.Path;
- var cosignPath = Path.Combine(tempFolder, "cosign.exe");
- if (!File.Exists(cosignPath) || !VerifyFileHash(cosignPath, CosignHash))
- {
- var cosignBytes = await _http.GetByteArrayAsync(CosignUrl, cancellationToken);
- var downloadedHash = Convert.ToHexString(SHA256.HashData(cosignBytes));
- if (!string.Equals(downloadedHash, CosignHash, StringComparison.OrdinalIgnoreCase))
- {
- throw new InvalidOperationException("Cosign binary hash mismatch. Update rejected.");
- }
- await File.WriteAllBytesAsync(cosignPath, cosignBytes, cancellationToken);
- }
-
- // 3. Verify manifest signature
+ // 3. Verify manifest signature BEFORE showing update action
+ var tempFolder = _fileStorage.GetTempFolder();
var manifestPath = Path.Combine(tempFolder, "server-monitor-manager-manifest.json");
var sigPath = Path.Combine(tempFolder, "server-monitor-manager-manifest.sig");
- await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
- await File.WriteAllTextAsync(sigPath, manifestSig, cancellationToken);
+ await _fileStorage.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
+ await _fileStorage.WriteAllTextAsync(sigPath, manifestSig, cancellationToken);
- var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- FileName = cosignPath,
- Arguments = $"verify-blob --certificate-oidc-issuer \"{OidcIssuer}\" --certificate-identity-regexp \"{OidcIdentityRegexp}\" --signature \"{sigPath}\" \"{manifestPath}\"",
- UseShellExecute = false,
- RedirectStandardError = true,
- RedirectStandardOutput = true,
- CreateNoWindow = true
- }
- };
-
- process.Start();
- await process.WaitForExitAsync(cancellationToken);
- if (process.ExitCode != 0)
- {
- var error = await process.StandardError.ReadToEndAsync(cancellationToken);
- throw new InvalidOperationException($"Signature verification failed: {error}");
- }
+ await _signatureVerifier.VerifySignatureAsync(sigPath, manifestPath, cancellationToken);
var msixUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue() == "ServerMonitorManager-win-x64.msix")?["browser_download_url"]?.GetValue();
if (msixUrl is null)
{
throw new InvalidOperationException("MSIX asset not found in the latest release.");
}
+
+ if (!msixUrl.Contains($"/releases/download/{releaseTagName}/"))
+ {
+ throw new InvalidOperationException("MSIX URL does not match the release tag. Update rejected.");
+ }
return new UpdateInfo(version ?? "Unknown", msixUrl, msixHash);
}
- public async Task DownloadAndInstallUpdateAsync(UpdateInfo updateInfo, Action? progressCallback = null, CancellationToken cancellationToken = default)
+ public async Task DownloadAndVerifyUpdateAsync(UpdateInfo updateInfo, Action? progressCallback = null, CancellationToken cancellationToken = default)
{
- var tempFolder = ApplicationData.Current.TemporaryFolder.Path;
+ if (updateInfo == null) throw new ArgumentNullException(nameof(updateInfo));
+
+ var tempFolder = _fileStorage.GetTempFolder();
var msixPath = Path.Combine(tempFolder, "ServerMonitorManager-win-x64.msix");
- using var response = await _http.GetAsync(updateInfo.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
- response.EnsureSuccessStatusCode();
-
- var totalBytes = response.Content.Headers.ContentLength ?? -1L;
- using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
- using var fileStream = new FileStream(msixPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
-
- var buffer = new byte[8192];
- var totalRead = 0L;
- var bytesRead = 0;
- while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) != 0)
- {
- await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
- totalRead += bytesRead;
- if (totalBytes != -1)
- {
- progressCallback?.Invoke((double)totalRead / totalBytes * 100);
- }
- }
-
- fileStream.Close();
+ await _http.DownloadFileAsync(updateInfo.DownloadUrl, msixPath, progressCallback, cancellationToken);
if (!VerifyFileHash(msixPath, updateInfo.ExpectedHash))
{
throw new InvalidOperationException("Update MSIX hash mismatch. Update rejected.");
}
-
- var process = new Process
+ }
+
+ public void InstallUpdate()
+ {
+ var tempFolder = _fileStorage.GetTempFolder();
+ var msixPath = Path.Combine(tempFolder, "ServerMonitorManager-win-x64.msix");
+ if (!_fileStorage.FileExists(msixPath))
{
- StartInfo = new ProcessStartInfo
- {
- FileName = msixPath,
- UseShellExecute = true
- }
- };
- process.Start();
+ throw new InvalidOperationException("Downloaded update file not found.");
+ }
+ _fileStorage.LaunchFile(msixPath);
}
- private static bool VerifyFileHash(string filePath, string expectedHash)
+ private bool VerifyFileHash(string filePath, string expectedHash)
{
try
{
- using var stream = File.OpenRead(filePath);
+ using var stream = _fileStorage.OpenRead(filePath);
var hashBytes = SHA256.HashData(stream);
var hashHex = Convert.ToHexString(hashBytes);
return string.Equals(hashHex, expectedHash, StringComparison.OrdinalIgnoreCase);
diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs
index 2eeaa21..c00e650 100644
--- a/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs
+++ b/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs
@@ -1,7 +1,6 @@
using System;
using System.IO;
-using System.Net;
-using System.Net.Http;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
@@ -9,56 +8,203 @@ using ServerMonitorManager_Desktop;
namespace ServerMonitorManager.Desktop.Security.Tests
{
- public class MockHttpMessageHandler : HttpMessageHandler
+ public class MockHttpTransport : IHttpTransport
{
- public Func SendAsyncFunc { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.NotFound);
+ public Func> GetStringAsyncFunc { get; set; } = _ => Task.FromResult("");
+ public Func> GetByteArrayAsyncFunc { get; set; } = _ => Task.FromResult(Array.Empty());
+ public Func?, Task> DownloadFileAsyncFunc { get; set; } = (_, _, _) => Task.CompletedTask;
- protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
- {
- return Task.FromResult(SendAsyncFunc(request));
- }
+ public Task GetStringAsync(string url, CancellationToken cancellationToken = default) => GetStringAsyncFunc(url);
+ public Task GetByteArrayAsync(string url, CancellationToken cancellationToken = default) => GetByteArrayAsyncFunc(url);
+ public Task DownloadFileAsync(string url, string destinationPath, Action? progressCallback = null, CancellationToken cancellationToken = default) => DownloadFileAsyncFunc(url, destinationPath, progressCallback);
+ }
+
+ public class MockSignatureVerifier : ISignatureVerifier
+ {
+ public Func VerifySignatureAsyncFunc { get; set; } = (_, _) => Task.CompletedTask;
+ public Task VerifySignatureAsync(string signaturePath, string manifestPath, CancellationToken cancellationToken = default) => VerifySignatureAsyncFunc(signaturePath, manifestPath);
+ }
+
+ public class MockFileStorage : IFileStorage
+ {
+ public string GetTempFolder() => Path.GetTempPath();
+ public bool FileExists(string path) => true;
+ public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Func OpenReadFunc { get; set; } = _ => new MemoryStream();
+ public Stream OpenRead(string path) => OpenReadFunc(path);
+ public Action LaunchFileFunc { get; set; } = _ => { };
+ public void LaunchFile(string path) => LaunchFileFunc(path);
}
public class UpdateServiceTests
{
- [Fact]
- public void UpdateService_Initialization_ShouldNotThrow()
+ private const string ValidReleaseJson = @"
{
- var ex = Record.Exception(() => new UpdateService());
- Assert.Null(ex);
+ ""tag_name"": ""v0.1.0-alpha.9"",
+ ""assets"": [
+ { ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
+ { ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.sig"" },
+ { ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix"" }
+ ]
+ }";
+
+ private const string ValidManifestJson = @"
+ {
+ ""version"": ""v0.1.0-alpha.9"",
+ ""hashes"": {
+ ""ServerMonitorManager-win-x64.msix"": ""e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855""
+ }
+ }"; // Hash for empty string
+
+ [Fact]
+ public async Task Test1_ValidSignatureAndHash_Accepted()
+ {
+ var http = new MockHttpTransport
+ {
+ GetStringAsyncFunc = url =>
+ {
+ if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
+ if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
+ if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
+ return Task.FromResult("");
+ }
+ };
+ var verifier = new MockSignatureVerifier();
+ var storage = new MockFileStorage
+ {
+ OpenReadFunc = _ => new MemoryStream(Array.Empty()) // Hash of empty matches e3b0c4...
+ };
+
+ var service = new UpdateService(http, verifier, storage);
+ var update = await service.CheckForUpdatesAsync();
+
+ Assert.NotNull(update);
+ Assert.Equal("v0.1.0-alpha.9", update.Version);
+
+ await service.DownloadAndVerifyUpdateAsync(update);
}
[Fact]
- public async Task CheckForUpdatesAsync_InvalidJson_ThrowsInvalidOperationException()
+ public async Task Test2_ManifestWithWrongIdentity_Rejected()
{
- // Note: Since UpdateService instantiates its own HttpClient internally,
- // we can only test the real endpoint or test structural exceptions by reflection/mocking in a more advanced setup.
- // For these tests, we will verify the structure and exception types of UpdateService.
-
- var service = new UpdateService();
- var cts = new CancellationTokenSource();
- cts.Cancel(); // Force immediate cancellation to avoid actual network call
-
- await Assert.ThrowsAsync(() => service.CheckForUpdatesAsync(cts.Token));
+ var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
+ var verifier = new MockSignatureVerifier
+ {
+ VerifySignatureAsyncFunc = (_, _) => throw new InvalidOperationException("Signature verification failed: identity mismatch")
+ };
+ var storage = new MockFileStorage();
+
+ var service = new UpdateService(http, verifier, storage);
+
+ var ex = await Assert.ThrowsAsync(() => service.CheckForUpdatesAsync());
+ Assert.Contains("identity mismatch", ex.Message);
}
[Fact]
- public async Task DownloadAndInstallUpdateAsync_NullUpdateInfo_ThrowsArgumentNullException()
+ public async Task Test3_ManifestHashMismatch_Rejected()
{
- var service = new UpdateService();
-
- var ex = await Record.ExceptionAsync(() => service.DownloadAndInstallUpdateAsync(null!));
- Assert.IsType(ex);
+ var http = new MockHttpTransport
+ {
+ GetStringAsyncFunc = url =>
+ {
+ if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
+ if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
+ if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
+ return Task.FromResult("");
+ }
+ };
+ var verifier = new MockSignatureVerifier();
+ var storage = new MockFileStorage
+ {
+ OpenReadFunc = _ => new MemoryStream(Encoding.UTF8.GetBytes("wrong content"))
+ };
+
+ var service = new UpdateService(http, verifier, storage);
+ var update = await service.CheckForUpdatesAsync();
+
+ var ex = await Assert.ThrowsAsync(() => service.DownloadAndVerifyUpdateAsync(update));
+ Assert.Contains("hash mismatch", ex.Message);
}
[Fact]
- public void UpdateInfo_Record_HasCorrectProperties()
+ public async Task Test4_MissingSignature_Rejected()
{
- var updateInfo = new UpdateInfo("v1.0.0", "https://example.com/update.msix", "expectedhash");
+ var noSigRelease = ValidReleaseJson.Replace("{ \"name\": \"server-monitor-manager-manifest.sig\"", "//");
+ var http = new MockHttpTransport { GetStringAsyncFunc = _ => Task.FromResult(noSigRelease) };
+ var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
+
+ var ex = await Assert.ThrowsAsync(() => service.CheckForUpdatesAsync());
+ Assert.Contains("Manifest or signature not found", ex.Message);
+ }
+
+ [Fact]
+ public async Task Test5_InvalidSignature_Rejected()
+ {
+ var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
+ var verifier = new MockSignatureVerifier
+ {
+ VerifySignatureAsyncFunc = (_, _) => throw new InvalidOperationException("Signature verification failed: invalid signature format")
+ };
+ var storage = new MockFileStorage();
+
+ var service = new UpdateService(http, verifier, storage);
+ var ex = await Assert.ThrowsAsync(() => service.CheckForUpdatesAsync());
+ Assert.Contains("invalid signature format", ex.Message);
+ }
+
+ [Fact]
+ public async Task Test8_UpdateActionNotShownUntilVerified()
+ {
+ var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
+ bool signatureVerified = false;
+ var verifier = new MockSignatureVerifier
+ {
+ VerifySignatureAsyncFunc = (_, _) => { signatureVerified = true; return Task.CompletedTask; }
+ };
+ var storage = new MockFileStorage();
+
+ var service = new UpdateService(http, verifier, storage);
+ var update = await service.CheckForUpdatesAsync();
- Assert.Equal("v1.0.0", updateInfo.Version);
- Assert.Equal("https://example.com/update.msix", updateInfo.DownloadUrl);
- Assert.Equal("expectedhash", updateInfo.ExpectedHash);
+ // CheckForUpdatesAsync returns the update ONLY AFTER signature is verified
+ Assert.True(signatureVerified);
+ Assert.NotNull(update);
+ }
+
+ [Fact]
+ public async Task Test9_PreReleaseChannel_Used()
+ {
+ var preReleaseJson = "[" + ValidReleaseJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.10") + "]";
+ var preReleaseManifest = ValidManifestJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.10");
+
+ var http = new MockHttpTransport
+ {
+ GetStringAsyncFunc = url =>
+ {
+ if (url.EndsWith("releases")) return Task.FromResult(preReleaseJson); // Note: releases array
+ if (url.EndsWith(".json")) return Task.FromResult(preReleaseManifest);
+ if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
+ return Task.FromResult("");
+ }
+ };
+ var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
+
+ var update = await service.CheckForUpdatesAsync(usePreRelease: true);
+ Assert.Equal("v0.1.0-alpha.10", update!.Version);
+ }
+
+ [Fact]
+ public async Task Test10_UrlAssetFromAnotherTag_Rejected()
+ {
+ // Msix URL points to a different tag
+ var maliciousRelease = ValidReleaseJson.Replace("v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix", "v0.1.0-alpha.8/ServerMonitorManager-win-x64.msix");
+ var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? maliciousRelease : ValidManifestJson) };
+
+ var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
+
+ var ex = await Assert.ThrowsAsync(() => service.CheckForUpdatesAsync());
+ Assert.Contains("does not match the release tag", ex.Message);
}
}
}
diff --git a/tests/bootstrap/test-manifest-verification.sh b/tests/bootstrap/test-manifest-verification.sh
index d7b655b..583d0c2 100644
--- a/tests/bootstrap/test-manifest-verification.sh
+++ b/tests/bootstrap/test-manifest-verification.sh
@@ -28,7 +28,7 @@ EOF
cosign sign-blob --yes --key cosign.key --output-signature manifest.sig manifest.json
echo "Test 1: Valid signature and hash"
-if ! bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json manifest.sig; then
+if ! bash deploy/ochenstarik-server-monitor-manager.sh verify-manifest manifest.json manifest.sig; then
echo "FAIL: Valid payload rejected"
exit 1
fi
@@ -36,7 +36,8 @@ echo "PASS: Valid payload accepted"
echo "Test 2: Altered byte in archive"
echo "altered content" > "$ARCHIVE_NAME"
-if bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json manifest.sig >/dev/null 2>&1; then
+# Note: we need to test archive verification for altered archive! The old test used verify-manifest which doesn't check archive.
+if bash deploy/ochenstarik-server-monitor-manager.sh verify-release "$ARCHIVE_NAME" >/dev/null 2>&1; then
echo "FAIL: Altered archive accepted"
exit 1
fi
@@ -53,19 +54,31 @@ cat < manifest.json
}
}
EOF
-if bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json manifest.sig >/dev/null 2>&1; then
+if bash deploy/ochenstarik-server-monitor-manager.sh verify-manifest manifest.json manifest.sig >/dev/null 2>&1; then
echo "FAIL: Substituted hash accepted"
exit 1
fi
echo "PASS: Substituted hash rejected"
echo "Test 4: Manifest without signature"
-if bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json "" >/dev/null 2>&1; then
+if bash deploy/ochenstarik-server-monitor-manager.sh verify-manifest manifest.json "" >/dev/null 2>&1; then
echo "FAIL: Missing signature accepted"
exit 1
fi
echo "PASS: Missing signature rejected"
+echo "Test 5: Real alpha.8 manifest fallback matching"
+ALPHA8_ARCHIVE="ochenstarik-server-monitor-manager-linux-x64.tar.gz"
+wget -qO "$ALPHA8_ARCHIVE" https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.8/server-monitor-manager-linux-x64.tar.gz
+wget -qO server-monitor-manager-manifest.json https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.8/server-monitor-manager-manifest.json
+wget -qO server-monitor-manager-manifest.sig https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.8/server-monitor-manager-manifest.sig
+unset SMM_TEST_PUBKEY
+if ! bash deploy/ochenstarik-server-monitor-manager.sh verify-release "$ALPHA8_ARCHIVE" >/dev/null 2>&1; then
+ echo "FAIL: Alpha.8 real release verification failed"
+ exit 1
+fi
+echo "PASS: Alpha.8 real release verification succeeded"
+
echo "All tests passed."
-rm -f cosign.key cosign.pub manifest.json manifest.sig "$ARCHIVE_NAME"
+rm -f cosign.key cosign.pub manifest.json manifest.sig "$ARCHIVE_NAME" "$ALPHA8_ARCHIVE" server-monitor-manager-manifest.json server-monitor-manager-manifest.sig