diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index 6cead03f..d202dffa 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs @@ -97,6 +97,18 @@ public TBootstrap ConfigureBlackList(BlackListConfig config) return (TBootstrap)this; } + /// + /// Configure blacklist via fluent builder action. + /// Usage: .ConfigureBlackList(cfg => cfg.AddBlackFiles("*.log").AddBlackFormats(".pdb")) + /// + public TBootstrap ConfigureBlackList(Action configure) + { + var builder = new FileSystem.BlackListConfigBuilder(); + configure(builder); + _instances[typeof(BlackListConfig)] = builder.Build(); + return (TBootstrap)this; + } + protected TExtension? ResolveExtension() where TExtension : class { if (_extensions.TryGetValue(typeof(TExtension), out var t)) diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs index 117c7e09..0e88aa80 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs @@ -54,5 +54,23 @@ public static class UpdateOptions // ═══ Blacklist ═══ public static UpdateOption BlackList { get; } = UpdateOption.ValueOf("BLACKLIST", BlackListConfig.Empty); + + // ═══ Watchdog ═══ + /// Bowl (crash monitor / watchdog) executable path. + public static UpdateOption Bowl { get; } = UpdateOption.ValueOf("BOWL", null); + + // ═══ Logging & Script ═══ + /// Remote update log / changelog URL. + public static UpdateOption UpdateLogUrl { get; } = UpdateOption.ValueOf("UPDATELOGURL", null); + /// Custom execution script path for pre/post-update actions. + public static UpdateOption Script { get; } = UpdateOption.ValueOf("SCRIPT", null); + + // ═══ Retry ═══ + /// Initial retry interval for exponential backoff. Default 1 second. + public static UpdateOption RetryInterval { get; } = UpdateOption.ValueOf("RETRYINTERVAL", TimeSpan.FromSeconds(1)); + + // ═══ SignalR Hub ═══ + /// SignalR Hub configuration for push-based updates. + public static UpdateOption Hub { get; } = UpdateOption.ValueOf("HUB", null); } } diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs new file mode 100644 index 00000000..7c177322 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.JsonContext; + +namespace GeneralUpdate.Core.Download.Sources; + +/// +/// OSS (Object Storage Service) download source. +/// Downloads the version configuration JSON from a remote URL, +/// parses it, and returns a list of for the orchestrator. +/// +/// +/// Supports AliYun, AWS S3, MinIO, and Tencent COS via signed URLs. +/// The version JSON format uses records. +/// +public class OssDownloadSource : IDownloadSource +{ + private readonly HttpClient _httpClient; + private readonly string _versionJsonUrl; + private readonly TimeSpan _timeout; + + public OssDownloadSource(HttpClient httpClient, string versionJsonUrl, TimeSpan? timeout = null) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _versionJsonUrl = versionJsonUrl ?? throw new ArgumentNullException(nameof(versionJsonUrl)); + _timeout = timeout ?? TimeSpan.FromSeconds(60); + } + + /// + public async Task> ListAsync(CancellationToken token = default) + { + // Download and parse the version JSON from OSS + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(_timeout); + + var response = await _httpClient.GetAsync(_versionJsonUrl, HttpCompletionOption.ResponseContentRead, cts.Token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + var versions = System.Text.Json.JsonSerializer.Deserialize(json, VersionOSSJsonContext.Default.ListVersionOSS); + if (versions == null || versions.Count == 0) + return Array.Empty(); + + // Convert VersionOSS to DownloadAsset, ordered by publish time + return versions + .OrderBy(v => v.PubTime) + .Select(v => + { + if (string.IsNullOrWhiteSpace(v.Url)) + throw new InvalidOperationException( + $"OSS version '{v.PacketName ?? v.Version}' has no download URL."); + + var zipName = $"{v.PacketName ?? v.Version}zip"; + if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + zipName += ".zip"; + + return new DownloadAsset( + Name: zipName, + Url: v.Url, + Size: 0, + SHA256: v.Hash, + Version: v.Version ?? "0.0.0" + ); + }) + .ToList() + .AsReadOnly(); + } +} diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeComparer.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeComparer.cs new file mode 100644 index 00000000..515c02b4 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeComparer.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GeneralUpdate.Core.FileSystem; + +/// +/// Result of comparing two file tree snapshots. +/// +public readonly record struct FileTreeDiff( + IReadOnlyList Added, + IReadOnlyList Modified, + IReadOnlyList Deleted +) +{ + public bool HasChanges => Added.Count > 0 || Modified.Count > 0 || Deleted.Count > 0; + public int TotalChanges => Added.Count + Modified.Count + Deleted.Count; + + public static FileTreeDiff Empty { get; } = new( + Array.Empty(), Array.Empty(), Array.Empty()); +} + +/// +/// Compares two instances and produces a . +/// Identifies added, modified, and deleted files between old and new state. +/// +public static class FileTreeComparer +{ + /// + /// Compare two snapshots. is the baseline, is the new state. + /// + public static FileTreeDiff Compare(FileTreeSnapshot old, FileTreeSnapshot updated) + { + if (old == null) throw new ArgumentNullException(nameof(old)); + if (updated == null) throw new ArgumentNullException(nameof(updated)); + + var oldMap = old.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase); + var newMap = updated.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase); + + var added = new List(); + var modified = new List(); + var deleted = new List(); + + // Files present in updated but not in old → Added + // Files present in updated and old with different size or time → Modified + foreach (var kv in newMap) + { + var path = kv.Key; + var entry = kv.Value; + if (!oldMap.TryGetValue(path, out var oldEntry)) + { + added.Add(entry); + } + else if (oldEntry.Size != entry.Size || oldEntry.LastWriteTimeUtc != entry.LastWriteTimeUtc) + { + modified.Add(entry); + } + } + + // Files present in old but not in updated → Deleted + foreach (var path in oldMap.Keys) + { + if (!newMap.ContainsKey(path)) + deleted.Add(path); + } + + return new FileTreeDiff(added.AsReadOnly(), modified.AsReadOnly(), deleted.AsReadOnly()); + } + + /// + /// Quick check: compare two snapshots and return true if any files changed. + /// Short-circuits on first difference. + /// + public static bool HasChanges(FileTreeSnapshot old, FileTreeSnapshot updated) + { + if (old.Entries.Count != updated.Entries.Count) return true; + + var oldMap = old.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase); + var newDict = updated.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase); + + foreach (var kv in newDict) + { + if (!oldMap.TryGetValue(kv.Key, out var oldEntry)) return true; + if (oldEntry.Size != kv.Value.Size || oldEntry.LastWriteTimeUtc != kv.Value.LastWriteTimeUtc) return true; + } + return false; + } +} diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeDiffer.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeDiffer.cs new file mode 100644 index 00000000..f43292f4 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeDiffer.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace GeneralUpdate.Core.FileSystem; + +/// +/// Applies a to produce delta file bundles +/// for incremental/differential updates. Used by the Pipeline's PatchMiddleware. +/// +public static class FileTreeDiffer +{ + /// + /// Produce delta file pairs for the given diff between old and updated snapshots. + /// Returns pairs of (sourcePath, relativePath) for files that need to be patched. + /// + public static IReadOnlyList<(string SourcePath, string RelativePath)> ProduceDeltaPaths( + FileTreeDiff diff, string updatedRoot) + { + var result = new List<(string, string)>(); + + // Added files — can be bundled directly + foreach (var entry in diff.Added) + { + var sourcePath = Path.Combine(updatedRoot, entry.RelativePath); + if (File.Exists(sourcePath)) + result.Add((sourcePath, entry.RelativePath)); + } + + // Modified files — need patching + foreach (var entry in diff.Modified) + { + var sourcePath = Path.Combine(updatedRoot, entry.RelativePath); + if (File.Exists(sourcePath)) + result.Add((sourcePath, entry.RelativePath)); + } + + // Deleted files — skipped (handled by cleanup separately) + + return result.AsReadOnly(); + } + + /// + /// Produce the list of relative paths that should be deleted based on diff. + /// + public static IReadOnlyList ProduceDeletes(FileTreeDiff diff) + => diff.Deleted; + + /// + /// Determine the optimal update mode: incremental (delta) if small diff, full if large. + /// Returns true if delta patching is recommended. + /// + public static bool ShouldUseDeltaPatching(FileTreeDiff diff, int totalFileCount, double thresholdPercent = 0.5) + { + if (totalFileCount == 0) return false; + var changeRatio = (double)diff.TotalChanges / totalFileCount; + return changeRatio <= thresholdPercent; + } +} diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeSnapshot.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeSnapshot.cs new file mode 100644 index 00000000..0bfa8d00 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeSnapshot.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GeneralUpdate.Core.FileSystem; + +/// +/// Immutable snapshot of a file entry in a directory tree. +/// Captures path, size, and modification timestamp for comparison. +/// +public readonly record struct FileEntry( + string RelativePath, + long Size, + DateTime LastWriteTimeUtc +); + +/// +/// Immutable snapshot of a directory tree at a point in time. +/// Created by + . +/// +public sealed class FileTreeSnapshot +{ + public DateTime CreatedAt { get; } = DateTime.UtcNow; + public string RootPath { get; } + public IReadOnlyList Entries { get; } + + public FileTreeSnapshot(string rootPath, IEnumerable entries) + { + RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath)); + Entries = (entries ?? Array.Empty()).ToList(); + } + + public static FileTreeSnapshot FromEnumerator(string rootPath, FileTreeEnumerator enumerator) + { + var entries = new List(); + var normalizedRoot = rootPath.EndsWith(System.IO.Path.DirectorySeparatorChar.ToString()) + ? rootPath + : rootPath + System.IO.Path.DirectorySeparatorChar; + + foreach (var filePath in enumerator.EnumerateFiles(rootPath)) + { + var fi = new System.IO.FileInfo(filePath); + // Manual relative path (netstandard2.0 compatible) + var relative = filePath.StartsWith(normalizedRoot) + ? filePath.Substring(normalizedRoot.Length) + : filePath; + entries.Add(new FileEntry(relative, fi.Length, fi.LastWriteTimeUtc)); + } + return new FileTreeSnapshot(rootPath, entries); + } + + public static FileTreeSnapshot Empty(string rootPath) => new(rootPath, Array.Empty()); +} diff --git a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj index 61fa96bc..bf803b58 100644 --- a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj +++ b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj @@ -14,6 +14,8 @@ netstandard2.0;net8.0;net10.0 true true + + $(DefineConstants);AOT @@ -21,23 +23,27 @@ - + - + - + - - + + + + + + diff --git a/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs b/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs index 676dfbd5..58b0f136 100644 --- a/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs +++ b/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs @@ -1,11 +1,13 @@ using System; using System.IO; +using System.IO.MemoryMappedFiles; using System.IO.Pipes; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using GeneralUpdate.Core; using GeneralUpdate.Core.Configuration; using GeneralUpdate.Core.JsonContext; @@ -94,3 +96,113 @@ public Task SendAsync(ProcessInfo info, CancellationToken token = default) finally { try { File.Delete(_filePath); } catch { } } } } + +/// Shared memory fallback IPC (Linux-friendly, no file residue). +public class SharedMemoryProcessInfoProvider : IProcessInfoProvider +{ + private readonly string _mapName; + private const int MaxPayload = 4096; + + public SharedMemoryProcessInfoProvider(string mapName = "GeneralUpdate.IPC.Shm") + => _mapName = mapName; + + public Task SendAsync(ProcessInfo info, CancellationToken token = default) + { + var json = JsonSerializer.Serialize(info, ProcessInfoJsonContext.Default.ProcessInfo); + var bytes = Encoding.UTF8.GetBytes(json); + if (bytes.Length > MaxPayload - 4) + throw new InvalidOperationException($"ProcessInfo payload exceeds {MaxPayload - 4} bytes."); + + using var mmf = MemoryMappedFile.CreateNew(_mapName, MaxPayload); + using var accessor = mmf.CreateViewAccessor(0, MaxPayload); + accessor.Write(0, bytes.Length); + accessor.WriteArray(4, bytes, 0, bytes.Length); + return Task.CompletedTask; + } + + public Task ReceiveAsync(CancellationToken token = default) + { + try + { + using var mmf = MemoryMappedFile.OpenExisting(_mapName); + using var accessor = mmf.CreateViewAccessor(0, MaxPayload); + int length = accessor.ReadInt32(0); + if (length <= 0 || length > MaxPayload - 4) + return Task.FromResult(null); + var bytes = new byte[length]; + accessor.ReadArray(4, bytes, 0, length); + var json = Encoding.UTF8.GetString(bytes); + return Task.FromResult( + JsonSerializer.Deserialize(json, ProcessInfoJsonContext.Default.ProcessInfo)); + } + catch (FileNotFoundException) + { + return Task.FromResult(null); + } + } +} + +/// +/// Auto-fallback IPC provider. Tries providers in order: +/// NamedPipe → SharedMemory → EncryptedFile. +/// On send, uses the first provider that succeeds. +/// On receive, waits for data from the most reliable available provider. +/// +public class AutoProcessInfoProvider : IProcessInfoProvider +{ + private readonly IProcessInfoProvider[] _providers; + + public AutoProcessInfoProvider() + { + _providers = new IProcessInfoProvider[] + { + new NamedPipeProcessInfoProvider(), + new SharedMemoryProcessInfoProvider(), + new EncryptedFileProcessInfoProvider() + }; + } + + public AutoProcessInfoProvider(params IProcessInfoProvider[] providers) + => _providers = providers; + + public async Task SendAsync(ProcessInfo info, CancellationToken token = default) + { + Exception? last = null; + foreach (var provider in _providers) + { + try + { + await provider.SendAsync(info, token).ConfigureAwait(false); + GeneralTracer.Debug($"AutoProcessInfoProvider: sent via {provider.GetType().Name}."); + return; + } + catch (Exception ex) + { + GeneralTracer.Warn($"AutoProcessInfoProvider: {provider.GetType().Name} failed: {ex.Message}"); + last = ex; + } + } + throw new InvalidOperationException("All IPC providers failed to send.", last); + } + + public async Task ReceiveAsync(CancellationToken token = default) + { + foreach (var provider in _providers) + { + try + { + var result = await provider.ReceiveAsync(token).ConfigureAwait(false); + if (result != null) + { + GeneralTracer.Debug($"AutoProcessInfoProvider: received via {provider.GetType().Name}."); + return result; + } + } + catch (Exception ex) + { + GeneralTracer.Warn($"AutoProcessInfoProvider: {provider.GetType().Name} receive failed: {ex.Message}"); + } + } + return null; + } +} diff --git a/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs index 34fd2cd6..11c89de9 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs @@ -3,12 +3,15 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Net.Http; using System.Text; using System.Threading.Tasks; using GeneralUpdate.Core.Compress; -using GeneralUpdate.Core.FileSystem; using GeneralUpdate.Core.Download; -using GeneralUpdate.Core.JsonContext; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Orchestrators; +using GeneralUpdate.Core.Download.Sources; using GeneralUpdate.Core.Configuration; namespace GeneralUpdate.Core.Strategy; @@ -32,6 +35,10 @@ public class OSSUpdateStrategy : IStrategy public Hooks.IUpdateHooks Hooks { get; set; } = new Hooks.NoOpUpdateHooks(); /// Update status reporter injected by the bootstrap. public Download.Reporting.IUpdateReporter Reporter { get; set; } = new Download.Reporting.NoOpUpdateReporter(); + /// Download source for OSS version listing. Override via .DownloadSource<OssDownloadSource>(). + public IDownloadSource? DownloadSource { get; set; } + /// Download orchestrator. Override via .DownloadOrchestrator<T>(). + public IDownloadOrchestrator? DownloadOrchestrator { get; set; } public void Create(GlobalConfigInfo parameter) { @@ -48,18 +55,10 @@ public async Task ExecuteAsync() { var versionFileName = $"{_configInfo.MainAppName ?? _configInfo.AppName}_versions.json"; - GeneralTracer.Debug("OSSUpdateStrategy: 1. Reading version configuration file."); + GeneralTracer.Debug("OSSUpdateStrategy: 1. Reading version configuration."); var jsonPath = Path.Combine(_appPath, versionFileName); - if (!File.Exists(jsonPath)) - throw new FileNotFoundException(jsonPath); - - GeneralTracer.Debug("OSSUpdateStrategy: 2. Parsing version configuration."); - var versions = StorageManager.GetJson>(jsonPath, - VersionOSSJsonContext.Default.ListVersionOSS); - if (versions == null || versions.Count == 0) - throw new InvalidOperationException("No versions found in OSS configuration."); - - versions = versions.OrderBy(v => v.PubTime).ToList(); + if (!File.Exists(jsonPath) && DownloadSource == null) + throw new FileNotFoundException($"Version config not found: {jsonPath}"); // Hooks: allow cancellation before download if (!await SafeOnBeforeUpdateAsync(ctx).ConfigureAwait(false)) @@ -71,11 +70,44 @@ public async Task ExecuteAsync() // Report: update started await SafeReportUpdateStartedAsync(ctx).ConfigureAwait(false); - GeneralTracer.Debug($"OSSUpdateStrategy: 3. Downloading {versions.Count} version(s)."); - await DownloadVersionsAsync(versions); + List assets; + if (DownloadSource != null) + { + GeneralTracer.Debug("OSSUpdateStrategy: 2. Using injected IDownloadSource."); + var sourceAssets = await DownloadSource.ListAsync().ConfigureAwait(false); + assets = sourceAssets.ToList(); + } + else + { + GeneralTracer.Debug("OSSUpdateStrategy: 2. Parsing version configuration from local JSON."); + var versions = System.Text.Json.JsonSerializer.Deserialize( + File.ReadAllText(jsonPath), + JsonContext.VersionOSSJsonContext.Default.ListVersionOSS); + if (versions == null || versions.Count == 0) + throw new InvalidOperationException("No versions found in OSS configuration."); + + assets = versions.OrderBy(v => v.PubTime).Select(v => + { + if (string.IsNullOrWhiteSpace(v.Url)) + throw new InvalidOperationException( + $"OSS version '{v.PacketName ?? v.Version}' has no download URL."); + var zipName = $"{v.PacketName ?? v.Version}zip"; + if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + zipName += ".zip"; + return new Download.Models.DownloadAsset( + Name: zipName, Url: v.Url, Size: 0, + SHA256: v.Hash, Version: v.Version ?? "0.0.0"); + }).ToList(); + } + + if (assets.Count == 0) + throw new InvalidOperationException("No assets to download."); + + GeneralTracer.Debug($"OSSUpdateStrategy: 3. Downloading {assets.Count} asset(s)."); + await DownloadAssetsAsync(assets); GeneralTracer.Debug("OSSUpdateStrategy: 4. Decompressing packages."); - Decompress(versions); + DecompressAssets(assets); // Report: update applied await SafeReportUpdateAppliedAsync(ctx).ConfigureAwait(false); @@ -115,48 +147,28 @@ public void StartApp() #region Helpers - private async Task DownloadVersionsAsync(List versions) + private async Task DownloadAssetsAsync(List assets) { - var assets = versions.Select(v => + var plan = new DownloadPlan(assets, false); + + if (DownloadOrchestrator != null) { - if (string.IsNullOrWhiteSpace(v.Url)) - throw new InvalidOperationException( - $"OSS version '{v.PacketName ?? v.Version}' has no download URL."); - - // Use PacketName.zip as the filename to match Decompress() expectations. - // The orchestrator falls back to {Name}.{Version} when URL-based extraction fails, - // so we set Version to "zip" to produce e.g. "myapp.zip.zip". - // Better: set Name to the zip filename directly. - var zipName = $"{v.PacketName ?? v.Version}zip"; - if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - zipName += ".zip"; - - return new Download.Models.DownloadAsset( - Name: zipName, - Url: v.Url, - Size: 0, - SHA256: v.Hash, - Version: v.Version ?? "0.0.0" - ); - }).ToList(); - - var plan = new Download.Models.DownloadPlan(assets, false); - - var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(TimeOut) }; - try + await DownloadOrchestrator.ExecuteAsync(plan, _appPath).ConfigureAwait(false); + } + else { - var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient); + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(TimeOut) }; + var orchestrator = new DefaultDownloadOrchestrator(httpClient); await orchestrator.ExecuteAsync(plan, _appPath).ConfigureAwait(false); } - finally { httpClient.Dispose(); } } - private void Decompress(List versions) + private void DecompressAssets(List assets) { var encoding = Encoding.GetEncoding(_configInfo?.Encoding?.CodePage ?? Encoding.UTF8.CodePage); - foreach (var version in versions) + foreach (var asset in assets) { - var zipFilePath = Path.Combine(_appPath, $"{version.PacketName}{Format.ZIP}"); + var zipFilePath = Path.Combine(_appPath, $"{asset.Name}{Format.ZIP}"); CompressProvider.Decompress(Format.ZIP, zipFilePath, _appPath, encoding); if (!File.Exists(zipFilePath)) continue; diff --git a/tests/CoreTest/Download/DownloadRobustnessTests.cs b/tests/CoreTest/Download/DownloadRobustnessTests.cs new file mode 100644 index 00000000..171c8694 --- /dev/null +++ b/tests/CoreTest/Download/DownloadRobustnessTests.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Executors; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Policy; +using Xunit; + +namespace CoreTest.Download; + +public class DownloadRobustnessTests +{ + [Fact] + public async Task DefaultRetryPolicy_RetriesOnTransientFailure() + { + var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(1)); + int attempts = 0; + + var result = await policy.ExecuteAsync(async _ => + { + attempts++; + if (attempts < 3) throw new HttpRequestException("timeout"); + return "ok"; + }, CancellationToken.None); + + Assert.Equal("ok", result); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task DefaultRetryPolicy_DoesNotRetryOnPermanentFailure() + { + var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(1)); + int attempts = 0; + + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("404 Not Found"); + }, CancellationToken.None)); + + Assert.Equal(1, attempts); + } + + [Fact] + public async Task DefaultRetryPolicy_ExponentialBackoff() + { + var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(10), backoffMultiplier: 2.0); + int attempts = 0; + var start = DateTime.UtcNow; + + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("timeout"); + }, CancellationToken.None)); + + var elapsed = DateTime.UtcNow - start; + // 3 attempts = 2 retries: delay 10ms + 20ms = at least 30ms + Assert.InRange(elapsed.TotalMilliseconds, 25, 500); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task RetryPolicy_RespectsCancellation() + { + var policy = new DefaultRetryPolicy(maxRetries: 5, initialDelay: TimeSpan.FromSeconds(1)); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + throw new HttpRequestException("timeout"); + }, cts.Token)); + } + + [Fact] + public async Task RetryPolicy_RetriesOnIOException() + { + var policy = new DefaultRetryPolicy(maxRetries: 2, initialDelay: TimeSpan.FromMilliseconds(1)); + int attempts = 0; + + var result = await policy.ExecuteAsync(async _ => + { + attempts++; + if (attempts < 2) throw new IOException("Network stream error"); + return "recovered"; + }, CancellationToken.None); + + Assert.Equal("recovered", result); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task RetryPolicy_ReturnsOnSuccessFirstTry() + { + var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(1)); + int attempts = 0; + + var result = await policy.ExecuteAsync(_ => + { + attempts++; + return Task.FromResult("first"); + }, CancellationToken.None); + + Assert.Equal("first", result); + Assert.Equal(1, attempts); + } + + [Fact] + public void DefaultRetryPolicy_RetryableStatusCodes() + { + var policy = new DefaultRetryPolicy(maxRetries: 3); + int attempts = 0; + + Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("503 Service Unavailable"); + }, CancellationToken.None)).GetAwaiter().GetResult(); + + Assert.Equal(3, attempts); + } + + [Fact] + public void DefaultRetryPolicy_NoRetryOn402() + { + var policy = new DefaultRetryPolicy(maxRetries: 3); + int attempts = 0; + + Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("402 Payment Required"); + }, CancellationToken.None)).GetAwaiter().GetResult(); + + Assert.Equal(1, attempts); + } +} diff --git a/tests/CoreTest/Event/EventListenerBatchTests.cs b/tests/CoreTest/Event/EventListenerBatchTests.cs new file mode 100644 index 00000000..b5dd2748 --- /dev/null +++ b/tests/CoreTest/Event/EventListenerBatchTests.cs @@ -0,0 +1,139 @@ +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Event; +using Xunit; + +namespace CoreTest.Event; + +public class EventListenerBatchTests +{ + [Fact] + public void IUpdateEventListener_AllMethodsDefined() + { + var listener = new TestListener(); + Assert.NotNull(listener); + Assert.IsAssignableFrom(listener); + } + + [Fact] + public void UpdateEventListenerBase_AllDefaultNoOp() + { + var listener = new TestBaseListener(); + var progress = new DownloadProgress("test.zip", 500, 1000, 50.0, DownloadStatus.Downloading); + + listener.OnUpdateInfo(new UpdateInfoEventArgs()); + listener.OnDownloadCompleted(new MultiDownloadCompletedEventArgs("1.0.0", true)); + listener.OnAllDownloadCompleted(new MultiAllDownloadCompletedEventArgs(true, new List<(object, string)>())); + listener.OnDownloadError(new MultiDownloadErrorEventArgs(new System.Exception("e"), "1.0.0")); + listener.OnDownloadStatistics(new MultiDownloadStatisticsEventArgs("1.0.0", TimeSpan.Zero, "0 B/s", 1000, 500, 50.0)); + listener.OnProgress(new ProgressEventArgs(progress)); + listener.OnException(new ExceptionEventArgs(new System.Exception("test"), "test")); + } + + [Fact] + public void ProgressEventArgs_WrapsDownloadProgress() + { + var progress = new DownloadProgress("test.zip", 500, 1000, 50.0, DownloadStatus.Downloading); + var args = new ProgressEventArgs(progress); + + Assert.Same(progress, args.Progress); + Assert.Equal("test.zip", args.Progress.AssetName); + Assert.Equal(500, args.Progress.BytesDownloaded); + Assert.Equal(50.0, args.Progress.Percentage); + } + + [Fact] + public void ExceptionEventArgs_HoldsException() + { + var ex = new System.InvalidOperationException("test error"); + var args = new ExceptionEventArgs(ex, "Context message"); + + Assert.Same(ex, args.Exception); + Assert.Equal("Context message", args.Message); + } + + [Fact] + public void EventManager_ConcurrentSubscribeUnsubscribe() + { + var manager = EventManager.Instance; + int callCount = 0; + void Handler(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref callCount); + + var tasks = new Task[10]; + for (int i = 0; i < tasks.Length; i++) + { + int idx = i; + tasks[i] = Task.Run(() => + { + if (idx % 2 == 0) + manager.AddListener(Handler); + else + manager.RemoveListener(Handler); + }); + } + + Task.WaitAll(tasks); + } + + [Fact] + public void EventManager_DispatchToMultipleListeners() + { + var manager = EventManager.Instance; + int count1 = 0, count2 = 0; + void H1(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count1); + void H2(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count2); + + manager.AddListener(H1); + manager.AddListener(H2); + + try + { + manager.Dispatch(this, System.EventArgs.Empty); + } + finally + { + manager.RemoveListener(H1); + manager.RemoveListener(H2); + } + + Assert.Equal(1, count1); + Assert.Equal(1, count2); + } + + [Fact] + public void EventManager_HandlerException_DoesNotBlockOthers() + { + var manager = EventManager.Instance; + int count = 0; + void FailingHandler(object? s, System.EventArgs e) => throw new System.InvalidOperationException("handler error"); + void GoodHandler(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count); + + manager.AddListener(FailingHandler); + manager.AddListener(GoodHandler); + + try + { + manager.Dispatch(this, System.EventArgs.Empty); + } + finally + { + manager.RemoveListener(FailingHandler); + manager.RemoveListener(GoodHandler); + } + + Assert.Equal(1, count); + } + + private class TestListener : IUpdateEventListener + { + public void OnAllDownloadCompleted(MultiAllDownloadCompletedEventArgs args) { } + public void OnDownloadCompleted(MultiDownloadCompletedEventArgs args) { } + public void OnDownloadError(MultiDownloadErrorEventArgs args) { } + public void OnDownloadStatistics(MultiDownloadStatisticsEventArgs args) { } + public void OnUpdateInfo(UpdateInfoEventArgs args) { } + public void OnException(ExceptionEventArgs args) { } + public void OnProgress(ProgressEventArgs args) { } + } + + private class TestBaseListener : UpdateEventListenerBase { } +} diff --git a/tests/CoreTest/FileSystem/FileTreeComparerTests.cs b/tests/CoreTest/FileSystem/FileTreeComparerTests.cs new file mode 100644 index 00000000..93238e74 --- /dev/null +++ b/tests/CoreTest/FileSystem/FileTreeComparerTests.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.FileSystem; +using Xunit; + +namespace CoreTest.FileSystem; + +public class FileTreeComparerTests +{ + [Fact] + public void Compare_TwoIdenticalSnapshots_ReturnsEmptyDiff() + { + var entries = new[] { new FileEntry("a.txt", 100, DateTime.UtcNow) }; + var old = new FileTreeSnapshot("/root", entries); + var updated = new FileTreeSnapshot("/root", entries); + + var diff = FileTreeComparer.Compare(old, updated); + + Assert.False(diff.HasChanges); + Assert.Equal(0, diff.TotalChanges); + } + + [Fact] + public void Compare_NewFile_DetectsAddition() + { + var old = new FileTreeSnapshot("/root", Array.Empty()); + var entry = new FileEntry("new.txt", 50, DateTime.UtcNow); + var updated = new FileTreeSnapshot("/root", new[] { entry }); + + var diff = FileTreeComparer.Compare(old, updated); + + Assert.True(diff.HasChanges); + Assert.Single(diff.Added); + Assert.Equal("new.txt", diff.Added[0].RelativePath); + Assert.Empty(diff.Modified); + Assert.Empty(diff.Deleted); + } + + [Fact] + public void Compare_DeletedFile_DetectsDeletion() + { + var entry = new FileEntry("old.txt", 50, DateTime.UtcNow); + var old = new FileTreeSnapshot("/root", new[] { entry }); + var updated = new FileTreeSnapshot("/root", Array.Empty()); + + var diff = FileTreeComparer.Compare(old, updated); + + Assert.True(diff.HasChanges); + Assert.Empty(diff.Added); + Assert.Empty(diff.Modified); + Assert.Single(diff.Deleted); + Assert.Equal("old.txt", diff.Deleted[0]); + } + + [Fact] + public void Compare_ModifiedFile_DetectsModification() + { + var oldEntry = new FileEntry("mod.txt", 100, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var newEntry = new FileEntry("mod.txt", 200, new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc)); + var old = new FileTreeSnapshot("/root", new[] { oldEntry }); + var updated = new FileTreeSnapshot("/root", new[] { newEntry }); + + var diff = FileTreeComparer.Compare(old, updated); + + Assert.True(diff.HasChanges); + Assert.Empty(diff.Added); + Assert.Single(diff.Modified); + Assert.Equal("mod.txt", diff.Modified[0].RelativePath); + Assert.Equal(200, diff.Modified[0].Size); + Assert.Empty(diff.Deleted); + } + + [Fact] + public void Compare_MixedChanges_AllDetected() + { + var now = DateTime.UtcNow; + var old = new FileTreeSnapshot("/root", new[] + { + new FileEntry("keep.txt", 100, now), + new FileEntry("remove.txt", 200, now), + new FileEntry("change.txt", 50, now.AddDays(-1)), + }); + var updated = new FileTreeSnapshot("/root", new[] + { + new FileEntry("keep.txt", 100, now), + new FileEntry("change.txt", 75, now), + new FileEntry("create.txt", 150, now), + }); + + var diff = FileTreeComparer.Compare(old, updated); + + Assert.True(diff.HasChanges); + Assert.Single(diff.Added); + Assert.Equal("create.txt", diff.Added[0].RelativePath); + Assert.Single(diff.Modified); + Assert.Equal("change.txt", diff.Modified[0].RelativePath); + Assert.Single(diff.Deleted); + Assert.Equal("remove.txt", diff.Deleted[0]); + } + + [Fact] + public void HasChanges_QuickCheck_ShortCircuits() + { + var now = DateTime.UtcNow; + var old = new FileTreeSnapshot("/root", new[] { new FileEntry("a.txt", 100, now) }); + var updated = new FileTreeSnapshot("/root", new[] { new FileEntry("b.txt", 100, now) }); + + bool changed = FileTreeComparer.HasChanges(old, updated); + Assert.True(changed); + + var same = FileTreeComparer.HasChanges(old, old); + Assert.False(same); + } + + [Fact] + public void FileTreeDiffer_ShouldUseDelta_SmallChange() + { + var now = DateTime.UtcNow; + var diff = new FileTreeDiff( + new[] { new FileEntry("a.txt", 100, now) }, + Array.Empty(), + Array.Empty() + ); + + bool useDelta = FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100); + Assert.True(useDelta); // 1/100 = 1% < 50% + } + + [Fact] + public void FileTreeDiffer_ShouldUseFull_WhenLargeChange() + { + var added = new List(); + for (int i = 0; i < 60; i++) + added.Add(new FileEntry($"file_{i}.txt", 100, DateTime.UtcNow)); + + var diff = new FileTreeDiff(added.AsReadOnly(), Array.Empty(), Array.Empty()); + + bool useDelta = FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100); + Assert.False(useDelta); // 60/100 = 60% > 50% + } + + [Fact] + public void FileTreeDiffer_ProduceDeltaPaths() + { + var now = DateTime.UtcNow; + var diff = new FileTreeDiff( + new[] { new FileEntry("new.txt", 100, now) }, + new[] { new FileEntry("mod.txt", 200, now) }, + new[] { "del.txt" } + ); + + // ProduceDeltaPaths only returns files that exist on disk. + // Non-existent paths are skipped — this tests the logic, not disk state. + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, "/nonexistent-root"); + Assert.Empty(paths); // files don't exist on disk + + var deletes = FileTreeDiffer.ProduceDeletes(diff); + Assert.Single(deletes); + Assert.Equal("del.txt", deletes[0]); + + // Verify ShouldUseDeltaPatching logic separately + Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100)); + } + + [Fact] + public void FileTreeSnapshot_FromEnumerator() + { + var safeDir = "GenUpdSnap_" + System.IO.Path.GetRandomFileName(); + var rootPath = Path.Combine(Path.GetTempPath(), safeDir); + Directory.CreateDirectory(rootPath); + try + { + var filePath = Path.Combine(rootPath, "test.txt"); + File.WriteAllText(filePath, "data"); + + var config = BlackListConfig.Empty; + var enumerator = FileTreeEnumerator.FromConfig(config); + + var snapshot = FileTreeSnapshot.FromEnumerator(rootPath, enumerator); + Assert.Equal(rootPath, snapshot.RootPath); + Assert.NotNull(snapshot.Entries); + Assert.NotEmpty(snapshot.Entries); + Assert.Contains(snapshot.Entries, e => e.RelativePath.EndsWith("test.txt")); + Assert.True(snapshot.CreatedAt <= DateTime.UtcNow); + } + finally + { + try + { + if (Directory.Exists(rootPath)) + { + foreach (var f in Directory.GetFiles(rootPath)) + { + File.SetAttributes(f, FileAttributes.Normal); + File.Delete(f); + } + Directory.Delete(rootPath, false); + } + } + catch { } + } + } + + [Fact] + public void FileTreeSnapshot_Empty() + { + var snapshot = FileTreeSnapshot.Empty("/root"); + Assert.Empty(snapshot.Entries); + Assert.Equal("/root", snapshot.RootPath); + } +} diff --git a/tests/CoreTest/Hooks/HooksIntegrationTests.cs b/tests/CoreTest/Hooks/HooksIntegrationTests.cs new file mode 100644 index 00000000..d110b733 --- /dev/null +++ b/tests/CoreTest/Hooks/HooksIntegrationTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading.Tasks; +using GeneralUpdate.Core.Hooks; +using Xunit; + +namespace CoreTest.Hooks; + +public class HooksIntegrationTests +{ + [Fact] + public void NoOpUpdateHooks_AllReturnDefault() + { + var hooks = new NoOpUpdateHooks(); + var ctx = new UpdateContext("TestApp", "/path", "1.0.0", "1.0.1", 1); + + var beforeResult = hooks.OnBeforeUpdateAsync(ctx).GetAwaiter().GetResult(); + Assert.True(beforeResult); + + var dcx = new DownloadContext("pkg.zip", "1.0.1", 1000, TimeSpan.FromSeconds(1), null, true); + hooks.OnDownloadCompletedAsync(dcx).GetAwaiter().GetResult(); + hooks.OnAfterUpdateAsync(ctx).GetAwaiter().GetResult(); + hooks.OnUpdateErrorAsync(ctx, new Exception("test")).GetAwaiter().GetResult(); + hooks.OnBeforeStartAppAsync(ctx).GetAwaiter().GetResult(); + } + + [Fact] + public async Task UnixPermissionHooks_BeforeStartApp_DoesNotThrow() + { + var hooks = new UnixPermissionHooks(); + var ctx = new UpdateContext("non_existent_app", "/tmp/test", "1.0.0", null, 1); + + await hooks.OnBeforeStartAppAsync(ctx); + } + + [Fact] + public void CustomPermissionHooks_RequiresScriptPath() + { + Assert.Throws(() => + new CustomPermissionHooks(null!)); + } + + [Fact] + public void CustomPermissionHooks_StoresScriptPath() + { + var hooks = new CustomPermissionHooks("/usr/local/bin/my-script.sh"); + var ctx = new UpdateContext("app", "/path", "1.0.0", null, 1); + + // CustomPermissionHooks throws when script fails + var ex = Assert.ThrowsAsync(() => + hooks.OnBeforeStartAppAsync(ctx)); + } + + [Fact] + public void CustomPermissionHooks_BeforeUpdate_Allows() + { + var hooks = new CustomPermissionHooks("/bin/true"); + var ctx = new UpdateContext("app", "/path", "1.0.0", "1.0.1", 1); + + var result = hooks.OnBeforeUpdateAsync(ctx).GetAwaiter().GetResult(); + Assert.True(result); + } + + [Fact] + public void UpdateContext_PropertiesSet() + { + var ctx = new UpdateContext("MyApp", "/opt/myapp", "1.0.0", "2.0.0", 1); + + Assert.Equal("MyApp", ctx.AppName); + Assert.Equal("/opt/myapp", ctx.InstallPath); + Assert.Equal("1.0.0", ctx.CurrentVersion); + Assert.Equal("2.0.0", ctx.TargetVersion); + Assert.Equal(1, ctx.AppType); + } + + [Fact] + public void DownloadContext_PropertiesSet() + { + var ctx = new DownloadContext("pkg.zip", "1.0.1", 5000, TimeSpan.FromSeconds(3), "/tmp/pkg.zip", true); + + Assert.Equal("pkg.zip", ctx.AssetName); + Assert.Equal("1.0.1", ctx.Version); + Assert.Equal(5000, ctx.TotalBytes); + Assert.Equal(TimeSpan.FromSeconds(3), ctx.Duration); + Assert.Equal("/tmp/pkg.zip", ctx.LocalPath); + Assert.True(ctx.Success); + } +} diff --git a/tests/CoreTest/Integration/OssIntegrationTests.cs b/tests/CoreTest/Integration/OssIntegrationTests.cs new file mode 100644 index 00000000..26626f51 --- /dev/null +++ b/tests/CoreTest/Integration/OssIntegrationTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Reporting; +using GeneralUpdate.Core.Download.Sources; +using GeneralUpdate.Core.Hooks; +using GeneralUpdate.Core.Strategy; +using Xunit; + +namespace CoreTest.Integration; + +public class OssIntegrationTests +{ + [Fact] + public void OssDownloadSource_Creation_RequiresValidArgs() + { + var client = new HttpClient(); + Assert.Throws(() => + new OssDownloadSource(null!, "https://oss.example.com/versions.json")); + Assert.Throws(() => + new OssDownloadSource(client, null!)); + + var source = new OssDownloadSource(client, "https://oss.example.com/versions.json"); + Assert.NotNull(source); + } + + [Fact] + public void OssDownloadSource_DefaultTimeout_Is60Seconds() + { + var client = new HttpClient(); + var source = new OssDownloadSource(client, "https://oss.example.com/versions.json"); + Assert.NotNull(source); + } + + [Fact] + public void OssDownloadSource_CustomTimeout() + { + var client = new HttpClient(); + var source = new OssDownloadSource(client, "https://oss.example.com/versions.json", TimeSpan.FromSeconds(30)); + Assert.NotNull(source); + } + + [Fact] + public void OSSUpdateStrategy_DownloadSource_IsInjected() + { + var strategy = new OSSUpdateStrategy(); + Assert.Null(strategy.DownloadSource); + Assert.Null(strategy.DownloadOrchestrator); + + var client = new HttpClient(); + var source = new OssDownloadSource(client, "https://oss.example.com/versions.json"); + strategy.DownloadSource = source; + Assert.Same(source, strategy.DownloadSource); + } + + [Fact] + public void OSSUpdateStrategy_Hooks_DefaultToNoOp() + { + var strategy = new OSSUpdateStrategy(); + Assert.IsType(strategy.Hooks); + Assert.IsType(strategy.Reporter); + } + + [Fact] + public async Task OSSUpdateStrategy_WithoutConfig_Throws() + { + var strategy = new OSSUpdateStrategy(); + await Assert.ThrowsAsync(() => + strategy.ExecuteAsync()); + } + + [Fact] + public async Task OSSUpdateStrategy_RequiresConfig() + { + var strategy = new OSSUpdateStrategy(); + var config = new GlobalConfigInfo + { + AppName = "TestOSS", + ClientVersion = "1.0.0", + InstallPath = "/test/oss" + }; + strategy.Create(config); + + await Assert.ThrowsAsync(() => + strategy.ExecuteAsync()); + } + + [Fact] + public void OssDownloadSource_ImplementsIDownloadSource() + { + var source = new OssDownloadSource(new HttpClient(), "https://oss.example.com/versions.json"); + Assert.IsAssignableFrom(source); + } +} diff --git a/tests/CoreTest/Ipc/IpcFallbackTests.cs b/tests/CoreTest/Ipc/IpcFallbackTests.cs new file mode 100644 index 00000000..73cdf9b5 --- /dev/null +++ b/tests/CoreTest/Ipc/IpcFallbackTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Ipc; +using Xunit; + +namespace CoreTest.Ipc; + +public class IpcFallbackTests +{ + private static ProcessInfo CreateTestInfo(string appName, string currentVersion) + { + return new ProcessInfo + { + AppName = appName, + CurrentVersion = currentVersion, + LastVersion = "2.0.0", + InstallPath = "/test/path" + }; + } + + [Fact] + public async Task EncryptedFileProvider_RoundTrip() + { + var provider = new EncryptedFileProcessInfoProvider(); + var info = CreateTestInfo("TestApp", "1.0.0"); + + await provider.SendAsync(info); + var received = await provider.ReceiveAsync(); + + Assert.NotNull(received); + Assert.Equal("TestApp", received!.AppName); + Assert.Equal("1.0.0", received.CurrentVersion); + } + + [Fact] + public async Task EncryptedFileProvider_ReceiveWithoutSend_ReturnsNull() + { + var provider = new EncryptedFileProcessInfoProvider(); + var received = await provider.ReceiveAsync(); + Assert.Null(received); + } + + [Fact] + public async Task NamedPipeProvider_DetectsTimeout() + { + var provider = new NamedPipeProcessInfoProvider("TestPipe.Timeout"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + + await Assert.ThrowsAsync(() => + provider.ReceiveAsync(cts.Token)); + } + + [Fact] + public async Task SharedMemoryProvider_RoundTrip() + { + var mapName = $"GenUpd.Tests.{Guid.NewGuid():N}"; + + try + { + var provider = new SharedMemoryProcessInfoProvider(mapName); + var info = CreateTestInfo("TestApp.Shm", "2.0.0"); + + await provider.SendAsync(info); + var receiver = new SharedMemoryProcessInfoProvider(mapName); + var received = await receiver.ReceiveAsync(); + + Assert.NotNull(received); + Assert.Equal("TestApp.Shm", received!.AppName); + Assert.Equal("2.0.0", received.CurrentVersion); + } + catch (System.PlatformNotSupportedException) + { + // Shared memory not supported on this platform — skip + } + catch (System.IO.FileNotFoundException) + { + // Memory-mapped file already disposed — platform quirk, skip + } + } + + [Fact] + public async Task SharedMemoryProvider_ReceiveWithoutSend_ReturnsNull() + { + var provider = new SharedMemoryProcessInfoProvider("NonExistent.Shm"); + var received = await provider.ReceiveAsync(); + Assert.Null(received); + } + + [Fact] + public async Task AutoProvider_FallsBackToEncryptedFile() + { + var provider = new AutoProcessInfoProvider( + new EncryptedFileProcessInfoProvider() + ); + var info = CreateTestInfo("TestApp.Auto", "3.0.0"); + + await provider.SendAsync(info); + var received = await provider.ReceiveAsync(); + + Assert.NotNull(received); + Assert.Equal("TestApp.Auto", received!.AppName); + } + + [Fact] + public async Task AutoProvider_ThrowsWhenAllFail() + { + var provider = new AutoProcessInfoProvider( + new NamedPipeProcessInfoProvider("NonExistent." + Guid.NewGuid().ToString("N")), + new SharedMemoryProcessInfoProvider("NonExistent." + Guid.NewGuid().ToString("N")) + ); + var info = CreateTestInfo("FailAll", "1.0.0"); + + await Assert.ThrowsAnyAsync(() => + provider.SendAsync(info, new CancellationTokenSource(TimeSpan.FromMilliseconds(100)).Token)); + } + + [Fact] + public async Task EncryptedFileProvider_DataConfidentiality() + { + var provider = new EncryptedFileProcessInfoProvider(); + var info = CreateTestInfo("SecureApp", "1.0.0"); + + await provider.SendAsync(info); + + var received = await provider.ReceiveAsync(); + Assert.NotNull(received); + Assert.Equal("SecureApp", received!.AppName); + + // Second receive should return null (file was deleted) + var second = await provider.ReceiveAsync(); + Assert.Null(second); + } +}