diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7177a68..4e37bc7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,10 +49,24 @@ jobs: # Analyse source only. obj/ and bin/ hold generated and compiled # output — e.g. the xUnit auto-generated entry point — so findings # there are noise against code no human maintains. + # + # query-filters excludes the two audit queries that fire on every + # P/Invoke declaration and call site (cs/unmanaged-code, + # cs/call-to-unmanaged-code). Native-backend packages (Keychain, + # libsecret, DPAPI) exist to call unmanaged code, so these are pure + # noise there and non-native repos have no P/Invoke for them to hit. + # This excludes ONLY those two queries — every other + # security-and-quality query still runs on the interop files, so no + # real finding is lost (STANDARD.md 4.4). config: | paths-ignore: - "**/obj/**" - "**/bin/**" + query-filters: + - exclude: + id: cs/unmanaged-code + - exclude: + id: cs/call-to-unmanaged-code # Explicit build rather than autobuild: these repos multi-target, and # autobuild has picked a single TFM in the past, silently analysing half diff --git a/CHANGELOG.md b/CHANGELOG.md index 61689e1..e81c289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Adopted the revised canonical `.editorconfig` and enabled `EnforceCodeStyleInBuild`** (NextIteration.Standards §5.2, §1.2.1 — the latter now a `MUST`). The canonical file is a deliberate allow-list of gated style rules rather than a blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a future SDK ships never auto-gates the build. With the flag on, the gated rules fail the build under `TreatWarningsAsErrors` instead of merely showing in the IDE. Bringing the code green was a mechanical, behaviour-preserving reformat of 92 sites — braces on all single-statement `if`s (IDE0011, 64 of them), collection expressions (IDE0300/IDE0301/IDE0028), `var` usage, two expression-bodied members, one simplified null check, and five unnecessary usings — applied with `dotnet format` plus the collection-expression sites it cannot fix automatically. All 392 tests (196 × `net8.0`/`net10.0`) pass unchanged, and the build stays at zero warnings. +- **`Path.Combine` → `Path.Join` repo-wide** (134 call sites, `src` and `tests`). `Path.Combine` returns its *last rooted argument* and silently discards everything before it, so a rooted second segment escapes the directory the first argument names. `Path.Join` always concatenates. Nothing here was reachable with a rooted segment — `ValidateAssetName` already rejects rooted and separator-bearing asset names before any path is built — so this is defence in depth on the install-directory path construction rather than a fix for a live defect. Verified behaviour-preserving: all 392 tests pass unchanged. +- **Idiomatic LINQ in place of filter-style loops.** `DefaultAssetResolver`'s five matcher loops become `FirstOrDefault`/`Any` predicates (the RID candidate walk stays lazy — `Select` plus `FirstOrDefault` still resolves a later candidate only when the earlier ones miss), `Sha256SumsManifest.Parse` maps its lines with `Select`, and `UpdateInstaller.IsPreserved` filters with `Where`/`Any` behind a named `MatchesTopLevelSegment` helper. +- **Test consoles are disposed.** Ten `TestConsole` instances that were test-locals now sit behind `using`. Three others are deliberately left undisposed and now say so in a comment: they escape their harness method via its return value, and the calling test reads `console.Output` after that method has returned, so disposing at the creation site closes the writer before the command under test writes to it. + +- **CodeQL now excludes the two P/Invoke audit queries at the config level** (NextIteration.Standards §4.4, §3.0.1). A `query-filters` block in `codeql.yml` excludes exactly `cs/unmanaged-code` and `cs/call-to-unmanaged-code`. This package contains no P/Invoke, so the block matches nothing here — it is carried because §3.0.1 requires the workflow to equal the canonical template in non-comment content, and a repo that omits it has forked the template rather than customised it. Closes the `3.0.1 workflow content` audit row. + - **`IDE0005` suppressed in the non-shipping projects.** It only runs in-build when `GenerateDocumentationFile` is `true`, which both the test and demo projects set to `false` — sample and fixture code carries no XML docs, and `TreatWarningsAsErrors` would fail the build over every missing one. With `EnforceCodeStyleInBuild` on it hard-errors demanding the doc file be enabled instead. NextIteration.Standards §2.7 mandates this for the test project; the demo is the same shape (not shipped, so not public surface) and needs the same opt-out. `IDE0005` still gates the shipping project, where the doc file is on — and it caught five real unnecessary usings there. - **`release.yml` folded into `ci.yml`.** Publishing now happens in the same workflow run as the build, so the `publish` job pushes the artifact this run's `build` job produced — the exact bytes the gate tested. The old tag-triggered `release.yml` rebuilt from the tag and published an artifact no gate had ever seen. It also globbed `*.nupkg` when uploading, so the `.snupkg` was built and then silently never published; the glob is now `*nupkg` and symbols ship. Repointing the nuget.org Trusted Publishing policy from `release.yml` to `ci.yml` was part of the same change, because the policy is bound to a workflow filename. diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs index 7491f0c..01e528f 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs @@ -145,7 +145,7 @@ internal string ResolveCacheFilePath() var dir = !string.IsNullOrWhiteSpace(_options.CacheDirectory) ? _options.CacheDirectory! : DefaultCacheDirectory(_options.AppName); - return Path.Combine(dir, ".update-check.json"); + return Path.Join(dir, ".update-check.json"); } internal bool IsCacheFresh(UpdateCacheEntry? entry) => @@ -322,7 +322,7 @@ internal static string DefaultCacheDirectory(string appName) else if (OperatingSystem.IsMacOS()) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - root = Path.Combine(home, "Library", "Caches"); + root = Path.Join(home, "Library", "Caches"); } else { @@ -330,11 +330,11 @@ internal static string DefaultCacheDirectory(string appName) if (string.IsNullOrWhiteSpace(xdg)) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - xdg = Path.Combine(home, ".cache"); + xdg = Path.Join(home, ".cache"); } root = xdg; } - return Path.Combine(root, appName); + return Path.Join(root, appName); } // ---------- Helpers ---------- diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs index ab6fc51..2e0e78c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -61,8 +61,8 @@ public bool HasPendingCleanup get { var installDir = InstallDirectory; - return Directory.Exists(Path.Combine(installDir, OldDirName)) - || Directory.Exists(Path.Combine(installDir, StagingDirName)); + return Directory.Exists(Path.Join(installDir, OldDirName)) + || Directory.Exists(Path.Join(installDir, StagingDirName)); } } @@ -84,9 +84,9 @@ public async Task InstallAsync( ValidateAssetName(asset.Name); var installDir = InstallDirectory; - var stagingDir = Path.Combine(installDir, StagingDirName, SanitizeTag(release.Tag)); - var oldDir = Path.Combine(installDir, OldDirName); - var lockFile = Path.Combine(installDir, LockFileName); + var stagingDir = Path.Join(installDir, StagingDirName, SanitizeTag(release.Tag)); + var oldDir = Path.Join(installDir, OldDirName); + var lockFile = Path.Join(installDir, LockFileName); // Acquire the lock BEFORE touching the staging tree. Otherwise a // second installer can wipe a first installer's in-flight staging @@ -99,7 +99,7 @@ public async Task InstallAsync( try { - var archivePath = Path.Combine(stagingDir, asset.Name); + var archivePath = Path.Join(stagingDir, asset.Name); using (var dlCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) { @@ -117,7 +117,7 @@ public async Task InstallAsync( progress?.Report(new UpdateProgressEvent(UpdateStage.Verifying, 1)); progress?.Report(new UpdateProgressEvent(UpdateStage.Extracting, 0)); - var extractedDir = Path.Combine(stagingDir, "extracted"); + var extractedDir = Path.Join(stagingDir, "extracted"); await ArchiveExtractor.ExtractAsync(archivePath, extractedDir, ct).ConfigureAwait(false); var sourceDir = ResolveSourceDirectory(extractedDir); progress?.Report(new UpdateProgressEvent(UpdateStage.Extracting, 1)); @@ -130,7 +130,7 @@ public async Task InstallAsync( { // Drop the whole .update/ tree, not just the leaf /, so // we don't leave an empty parent behind on the install dir. - TryDeleteDirectory(Path.Combine(installDir, StagingDirName)); + TryDeleteDirectory(Path.Join(installDir, StagingDirName)); } } @@ -143,8 +143,8 @@ public void CleanupOldInstall() // for the OneDrive / antivirus / Windows Search case where // handles held at install time defeated the immediate cleanup; // by next startup those handles have had time to release. - TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, OldDirName))); - TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, StagingDirName))); + TrySwallow(() => DeleteDirectoryRobustly(Path.Join(installDir, OldDirName))); + TrySwallow(() => DeleteDirectoryRobustly(Path.Join(installDir, StagingDirName))); } private static void TrySwallow(Action action) @@ -232,7 +232,7 @@ internal static async Task SwapAsync( continue; } - var dest = Path.Combine(oldDirectory, name); + var dest = Path.Join(oldDirectory, name); if (File.Exists(entry)) { File.Move(entry, dest); @@ -262,7 +262,7 @@ internal static async Task SwapAsync( { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(entry); - var dest = Path.Combine(installDirectory, name); + var dest = Path.Join(installDirectory, name); // Conflict path: source ships an entry whose top-level // name matches a preserved pattern. @@ -281,7 +281,7 @@ internal static async Task SwapAsync( // moved in Phase 1; do it inline now so the // copy below has somewhere clean to land and // rollback restores the right thing on failure. - var oldDest = Path.Combine(oldDirectory, name); + var oldDest = Path.Join(oldDirectory, name); TryDeleteEntry(oldDest); if (File.Exists(dest)) { @@ -314,7 +314,7 @@ internal static async Task SwapAsync( { foreach (var name in copiedNames) { - TryDeleteEntry(Path.Combine(installDirectory, name)); + TryDeleteEntry(Path.Join(installDirectory, name)); } RestoreFromOld(oldDirectory, installDirectory, movedNames); throw; @@ -346,29 +346,20 @@ internal static bool IsPreserved(string name, IReadOnlyList preservePath return false; } - foreach (var pattern in preservePaths) - { - if (string.IsNullOrWhiteSpace(pattern)) - { - continue; - } - // Take the part of the pattern before the first slash — - // this lets `data/**`, `data/seed.json`, and bare `data` - // all match the top-level entry `data`. Nested-only - // preservation (e.g. preserve only `data/seed.json` but - // not the rest of `data/`) is out of scope for v0.1.x. - var head = TopLevelSegment(pattern); - if (head.Length == 0) - { - continue; - } + return preservePaths + .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) + .Any(pattern => MatchesTopLevelSegment(pattern, name)); + } - if (System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true)) - { - return true; - } - } - return false; + // Take the part of the pattern before the first slash — this lets + // `data/**`, `data/seed.json`, and bare `data` all match the top-level + // entry `data`. Nested-only preservation (e.g. preserve only + // `data/seed.json` but not the rest of `data/`) is out of scope. + private static bool MatchesTopLevelSegment(string pattern, string name) + { + var head = TopLevelSegment(pattern); + return head.Length != 0 + && System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true); } private static ReadOnlySpan TopLevelSegment(string pattern) @@ -393,8 +384,8 @@ internal static void RestoreFromOld(string oldDirectory, string installDirectory { foreach (var name in names) { - var src = Path.Combine(oldDirectory, name); - var dest = Path.Combine(installDirectory, name); + var src = Path.Join(oldDirectory, name); + var dest = Path.Join(installDirectory, name); try { TryDeleteEntry(dest); @@ -516,7 +507,7 @@ internal static void CopyDirectory(string source, string destination) foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(source, file); - var target = Path.Combine(destination, relative); + var target = Path.Join(destination, relative); Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Copy(file, target, overwrite: true); } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs index ca7eb5c..e9900a4 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs @@ -43,15 +43,11 @@ public DefaultAssetResolver(string appName) ArgumentNullException.ThrowIfNull(release); ArgumentException.ThrowIfNullOrWhiteSpace(runtimeIdentifier); - foreach (var rid in CandidateRids(runtimeIdentifier)) - { - var match = ResolveForRid(release, rid); - if (match is not null) - { - return match; - } - } - return null; + // Select is lazy and FirstOrDefault short-circuits, so a later + // candidate RID is only resolved when the earlier ones miss. + return CandidateRids(runtimeIdentifier) + .Select(rid => ResolveForRid(release, rid)) + .FirstOrDefault(match => match is not null); } private ReleaseAsset? ResolveForRid(RemoteRelease release, string rid) @@ -108,60 +104,27 @@ private static IEnumerable CandidateRids(string runtimeIdentifier) private static ReleaseAsset? MatchExact(RemoteRelease release, string stem) { - foreach (var asset in release.Assets) - { - foreach (var ext in ArchiveExtensions) - { - if (NameEquals(asset.Name, stem + ext)) - { - return asset; - } - } - } - return null; + return release.Assets.FirstOrDefault( + asset => ArchiveExtensions.Any(ext => NameEquals(asset.Name, stem + ext))); } private static ReleaseAsset? MatchPrefixSuffix(RemoteRelease release, string prefix, string ridSuffix) { - foreach (var asset in release.Assets) - { - if (!asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!EndsWithRidAndArchive(asset.Name, ridSuffix)) - { - continue; - } - - return asset; - } - return null; + return release.Assets.FirstOrDefault( + asset => asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && EndsWithRidAndArchive(asset.Name, ridSuffix)); } private static ReleaseAsset? MatchSuffixOnly(RemoteRelease release, string ridSuffix) { - foreach (var asset in release.Assets) - { - if (EndsWithRidAndArchive(asset.Name, ridSuffix)) - { - return asset; - } - } - return null; + return release.Assets.FirstOrDefault( + asset => EndsWithRidAndArchive(asset.Name, ridSuffix)); } private static bool EndsWithRidAndArchive(string name, string ridSuffix) { - foreach (var ext in ArchiveExtensions) - { - if (name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; + return ArchiveExtensions.Any( + ext => name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase)); } private static bool NameEquals(string actual, string expected) => diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs index bd12edd..ff2d7f3 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs @@ -159,7 +159,7 @@ public async Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IPr $"GhCliReleaseSource cannot download '{asset.Name}': release tag is not present in asset metadata."); } - var tempFile = Path.Combine(Path.GetTempPath(), $"selfupdate-gh-{Guid.NewGuid():N}-{asset.Name}"); + var tempFile = Path.Join(Path.GetTempPath(), $"selfupdate-gh-{Guid.NewGuid():N}-{asset.Name}"); try { progress?.Report(new DownloadProgress(0, asset.SizeBytes)); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs index c691da6..28d0c7b 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs @@ -16,9 +16,8 @@ public static IReadOnlyDictionary Parse(string content) ArgumentNullException.ThrowIfNull(content); var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var rawLine in content.Split('\n')) + foreach (var line in content.Split('\n').Select(rawLine => rawLine.Trim())) { - var line = rawLine.Trim(); if (line.Length == 0 || line.StartsWith('#')) { continue; diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs index 9afddfa..b9b3d66 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs @@ -86,6 +86,9 @@ public void AddUpdateBranch_rejects_blank_name() private static (CommandApp App, TestConsole Console) BuildApp(Action configure) { + // Deliberately not `using`: this console escapes via the return + // value and its lifetime belongs to the caller, which reads + // console.Output after the harness method has returned. var console = new TestConsole(); var registrar = new TestRegistrar(s => { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs index 72793d4..48a45ca 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs @@ -127,6 +127,9 @@ private static (Runner Run, TestConsole Console, StubUpdateChecker Checker) Buil { var checker = new StubUpdateChecker(); configChecker(checker); + // Deliberately not `using`: this console escapes via the return + // value and its lifetime belongs to the caller, which reads + // console.Output after the harness method has returned. var console = new TestConsole(); var registrar = new TestRegistrar(s => diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs index 458bbd4..714c1a9 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs @@ -257,6 +257,9 @@ private static (Runner Run, TestConsole Console, StubSelfUpdater Updater) BuildH configUpdater?.Invoke(updater); var installer = new StubUpdateInstaller(); configInstaller?.Invoke(installer); + // Deliberately not `using`: this console escapes via the return + // value and its lifetime belongs to the caller, which reads + // console.Output after the harness method has returned. var console = new TestConsole(); var registrar = new TestRegistrar(s => diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs index ada1106..aeceade 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs @@ -15,11 +15,11 @@ internal sealed class TempDir : IDisposable public TempDir(string? prefix = null) { var name = (prefix ?? "selfupdate") + "-" + Guid.NewGuid().ToString("N"); - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), name); + Path = System.IO.Path.Join(System.IO.Path.GetTempPath(), name); Directory.CreateDirectory(Path); } - public string Combine(params string[] parts) => System.IO.Path.Combine([Path, .. parts]); + public string Combine(params string[] parts) => System.IO.Path.Join([Path, .. parts]); public void Dispose() { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs index 3994ce0..123ada1 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs @@ -20,8 +20,8 @@ public async Task ExtractAsync_extracts_zip_archive() await ArchiveExtractor.ExtractAsync(zipPath, dest, CancellationToken.None); - Assert.Equal("hello", await File.ReadAllTextAsync(Path.Combine(dest, "file1.txt"), TestContext.Current.CancellationToken)); - Assert.Equal("world", await File.ReadAllTextAsync(Path.Combine(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("hello", await File.ReadAllTextAsync(Path.Join(dest, "file1.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("world", await File.ReadAllTextAsync(Path.Join(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); } [Fact] @@ -34,8 +34,8 @@ public async Task ExtractAsync_extracts_tar_gz_archive() await ArchiveExtractor.ExtractAsync(tarGzPath, dest, CancellationToken.None); - Assert.Equal("hello", await File.ReadAllTextAsync(Path.Combine(dest, "file1.txt"), TestContext.Current.CancellationToken)); - Assert.Equal("world", await File.ReadAllTextAsync(Path.Combine(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("hello", await File.ReadAllTextAsync(Path.Join(dest, "file1.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("world", await File.ReadAllTextAsync(Path.Join(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); } [Fact] @@ -68,7 +68,7 @@ private static void CreateTarGz(string path, params (string Name, string Content using var staging = new TempDir("tar-stage"); foreach (var (name, content) in entries) { - var full = Path.Combine(staging.Path, name); + var full = Path.Join(staging.Path, name); Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllText(full, content); } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs index 8587031..068d7df 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs @@ -11,7 +11,7 @@ public sealed class InstallLockTests public void Acquire_on_fresh_path_returns_open_stream() { using var dir = new TempDir(); - var lockPath = Path.Combine(dir.Path, ".update.lock"); + var lockPath = Path.Join(dir.Path, ".update.lock"); using var stream = InstallLock.Acquire(lockPath, dir.Path); @@ -23,7 +23,7 @@ public void Acquire_on_fresh_path_returns_open_stream() public void Acquire_when_lock_already_held_throws_in_progress() { using var dir = new TempDir(); - var lockPath = Path.Combine(dir.Path, ".update.lock"); + var lockPath = Path.Join(dir.Path, ".update.lock"); using var first = InstallLock.Acquire(lockPath, dir.Path); @@ -35,9 +35,9 @@ public void Acquire_when_lock_already_held_throws_in_progress() public void Disposing_first_lock_removes_file_and_allows_re_acquisition() { using var dir = new TempDir(); - var lockPath = Path.Combine(dir.Path, ".update.lock"); + var lockPath = Path.Join(dir.Path, ".update.lock"); - using (var first = InstallLock.Acquire(lockPath, dir.Path)) + using (InstallLock.Acquire(lockPath, dir.Path)) { Assert.True(File.Exists(lockPath)); } @@ -71,14 +71,14 @@ public void Acquire_when_directory_not_writable_throws_not_writable() } using var dir = new TempDir(); - var sub = Path.Combine(dir.Path, "ro"); + var sub = Path.Join(dir.Path, "ro"); Directory.CreateDirectory(sub); try { File.SetUnixFileMode(sub, UnixFileMode.UserRead | UnixFileMode.UserExecute); var ex = Assert.Throws(() => - InstallLock.Acquire(Path.Combine(sub, ".update.lock"), sub)); + InstallLock.Acquire(Path.Join(sub, ".update.lock"), sub)); Assert.Contains("not writable", ex.Message, StringComparison.OrdinalIgnoreCase); } finally diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs index 7f476b4..cac2a64 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs @@ -21,9 +21,9 @@ public sealed class UpdateInstallerTests public async Task InstallAsync_swaps_files_into_install_directory() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); - File.WriteAllText(Path.Combine(installDir, "old-file.txt"), "old content"); + File.WriteAllText(Path.Join(installDir, "old-file.txt"), "old content"); var release = BuildReleaseWithSingleAssetZip( tag: "v1.4.2", @@ -43,22 +43,22 @@ public async Task InstallAsync_swaps_files_into_install_directory() await installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None); // New files in place - Assert.Equal("new binary", await File.ReadAllTextAsync(Path.Combine(installDir, "myapp.exe"), TestContext.Current.CancellationToken)); - Assert.Equal("{}", await File.ReadAllTextAsync(Path.Combine(installDir, "settings.json"), TestContext.Current.CancellationToken)); + Assert.Equal("new binary", await File.ReadAllTextAsync(Path.Join(installDir, "myapp.exe"), TestContext.Current.CancellationToken)); + Assert.Equal("{}", await File.ReadAllTextAsync(Path.Join(installDir, "settings.json"), TestContext.Current.CancellationToken)); // Old file moved to .old/ - Assert.True(Directory.Exists(Path.Combine(installDir, ".old"))); - Assert.Equal("old content", await File.ReadAllTextAsync(Path.Combine(installDir, ".old", "old-file.txt"), TestContext.Current.CancellationToken)); + Assert.True(Directory.Exists(Path.Join(installDir, ".old"))); + Assert.Equal("old content", await File.ReadAllTextAsync(Path.Join(installDir, ".old", "old-file.txt"), TestContext.Current.CancellationToken)); // Staging removed - Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); } [Fact] public async Task InstallAsync_throws_when_no_asset_matches_rid() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var release = new RemoteRelease( @@ -83,7 +83,7 @@ public async Task InstallAsync_throws_when_no_asset_matches_rid() public async Task InstallAsync_reports_progress_for_each_stage() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var release = BuildReleaseWithSingleAssetZip("v1.0.0", "myapp-v1.0.0-linux-x64.zip", ("a.txt", "x"), ("b.txt", "y")); @@ -112,14 +112,14 @@ public async Task InstallAsync_reports_progress_for_each_stage() public void CleanupOldInstall_when_old_directory_exists_deletes_it() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); - File.WriteAllText(Path.Combine(installDir, ".old", "garbage.txt"), "stale"); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".old")); + File.WriteAllText(Path.Join(installDir, ".old", "garbage.txt"), "stale"); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); installer.CleanupOldInstall(); - Assert.False(Directory.Exists(Path.Combine(installDir, ".old"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".old"))); } [Fact] @@ -131,39 +131,39 @@ public void CleanupOldInstall_when_update_staging_exists_deletes_it() // this CleanupOldInstall would only touch .old/ and .update/ // would accumulate across sessions. using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - var staging = Path.Combine(installDir, ".update", "v1.4.2"); + var installDir = Path.Join(work.Path, "install"); + var staging = Path.Join(installDir, ".update", "v1.4.2"); Directory.CreateDirectory(staging); - File.WriteAllText(Path.Combine(staging, "leftover.txt"), "stale"); + File.WriteAllText(Path.Join(staging, "leftover.txt"), "stale"); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); installer.CleanupOldInstall(); - Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); } [Fact] public void CleanupOldInstall_cleans_both_old_and_update_directories() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); - File.WriteAllText(Path.Combine(installDir, ".old", "garbage.txt"), "stale"); - Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2")); - File.WriteAllText(Path.Combine(installDir, ".update", "v1.4.2", "archive.zip"), "stale"); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".old")); + File.WriteAllText(Path.Join(installDir, ".old", "garbage.txt"), "stale"); + Directory.CreateDirectory(Path.Join(installDir, ".update", "v1.4.2")); + File.WriteAllText(Path.Join(installDir, ".update", "v1.4.2", "archive.zip"), "stale"); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); installer.CleanupOldInstall(); - Assert.False(Directory.Exists(Path.Combine(installDir, ".old"))); - Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".old"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); } [Fact] public void CleanupOldInstall_when_old_directory_missing_is_noop() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -174,7 +174,7 @@ public void CleanupOldInstall_when_old_directory_missing_is_noop() public void HasPendingCleanup_when_neither_directory_exists_false() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -186,8 +186,8 @@ public void HasPendingCleanup_when_neither_directory_exists_false() public void HasPendingCleanup_when_old_directory_exists_true() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".old")); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -198,8 +198,8 @@ public void HasPendingCleanup_when_old_directory_exists_true() public void HasPendingCleanup_when_update_directory_exists_true() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2")); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".update", "v1.4.2")); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -210,7 +210,7 @@ public void HasPendingCleanup_when_update_directory_exists_true() public async Task InstallAsync_when_lock_file_held_throws() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var release = BuildReleaseWithSingleAssetZip("v1.0.0", "myapp-v1.0.0-linux-x64.zip", ("a.txt", "x")); @@ -220,7 +220,7 @@ public async Task InstallAsync_when_lock_file_held_throws() }; // Hold the lock externally. - var lockPath = Path.Combine(installDir, ".update.lock"); + var lockPath = Path.Join(installDir, ".update.lock"); using var foreignLock = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); var installer = NewInstaller(installDir, source, "linux-x64"); @@ -235,17 +235,17 @@ public async Task InstallAsync_when_lock_held_leaves_existing_staging_intact() // the lock, so a second installer would wipe a first installer's // in-flight staging directory on its way to losing the lock race. using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); // Pre-populate a staging dir as if another installer were mid-download. - var stagingDir = Path.Combine(installDir, ".update", "v1.0.0"); + var stagingDir = Path.Join(installDir, ".update", "v1.0.0"); Directory.CreateDirectory(stagingDir); - var sentinel = Path.Combine(stagingDir, "in-flight-asset.zip"); + var sentinel = Path.Join(stagingDir, "in-flight-asset.zip"); File.WriteAllBytes(sentinel, [1, 2, 3]); // Hold the lock externally to simulate that other installer. - var lockPath = Path.Combine(installDir, ".update.lock"); + var lockPath = Path.Join(installDir, ".update.lock"); using var foreignLock = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); var release = BuildReleaseWithSingleAssetZip("v1.0.0", "myapp-v1.0.0-linux-x64.zip", ("a.txt", "x")); @@ -266,7 +266,7 @@ await Assert.ThrowsAsync(() => public async Task InstallAsync_when_resolver_returns_malicious_name_throws() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var malicious = new ReleaseAsset( @@ -345,16 +345,16 @@ public void RestoreFromOld_moves_named_entries_back_into_install() Directory.CreateDirectory(installDir); Directory.CreateDirectory(oldDir); - File.WriteAllText(Path.Combine(oldDir, "a.txt"), "alpha"); - Directory.CreateDirectory(Path.Combine(oldDir, "subdir")); - File.WriteAllText(Path.Combine(oldDir, "subdir", "nested.txt"), "nested"); + File.WriteAllText(Path.Join(oldDir, "a.txt"), "alpha"); + Directory.CreateDirectory(Path.Join(oldDir, "subdir")); + File.WriteAllText(Path.Join(oldDir, "subdir", "nested.txt"), "nested"); UpdateInstaller.RestoreFromOld(oldDir, installDir, TwoEntryNames); - Assert.Equal("alpha", File.ReadAllText(Path.Combine(installDir, "a.txt"))); - Assert.True(Directory.Exists(Path.Combine(installDir, "subdir"))); - Assert.Equal("nested", File.ReadAllText(Path.Combine(installDir, "subdir", "nested.txt"))); - Assert.False(File.Exists(Path.Combine(oldDir, "a.txt"))); + Assert.Equal("alpha", File.ReadAllText(Path.Join(installDir, "a.txt"))); + Assert.True(Directory.Exists(Path.Join(installDir, "subdir"))); + Assert.Equal("nested", File.ReadAllText(Path.Join(installDir, "subdir", "nested.txt"))); + Assert.False(File.Exists(Path.Join(oldDir, "a.txt"))); } [Fact] @@ -366,12 +366,12 @@ public void RestoreFromOld_overwrites_existing_destination_entries() Directory.CreateDirectory(installDir); Directory.CreateDirectory(oldDir); - File.WriteAllText(Path.Combine(installDir, "a.txt"), "garbage from a partial copy"); - File.WriteAllText(Path.Combine(oldDir, "a.txt"), "original"); + File.WriteAllText(Path.Join(installDir, "a.txt"), "garbage from a partial copy"); + File.WriteAllText(Path.Join(oldDir, "a.txt"), "original"); UpdateInstaller.RestoreFromOld(oldDir, installDir, OneEntryName); - Assert.Equal("original", File.ReadAllText(Path.Combine(installDir, "a.txt"))); + Assert.Equal("original", File.ReadAllText(Path.Join(installDir, "a.txt"))); } [Fact] @@ -386,27 +386,27 @@ public void Swap_when_phase2_copy_fails_restores_install_from_old() using var work = new TempDir(); var installDir = work.Combine("install"); Directory.CreateDirectory(installDir); - File.WriteAllText(Path.Combine(installDir, "current.txt"), "original"); - Directory.CreateDirectory(Path.Combine(installDir, "vendor")); - File.WriteAllText(Path.Combine(installDir, "vendor", "lib.txt"), "lib v1"); + File.WriteAllText(Path.Join(installDir, "current.txt"), "original"); + Directory.CreateDirectory(Path.Join(installDir, "vendor")); + File.WriteAllText(Path.Join(installDir, "vendor", "lib.txt"), "lib v1"); var sourceDir = work.Combine("src"); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "newfile.txt"), "new content"); - File.WriteAllText(Path.Combine(sourceDir, ".old"), "should clash with the .old/ dir"); + File.WriteAllText(Path.Join(sourceDir, "newfile.txt"), "new content"); + File.WriteAllText(Path.Join(sourceDir, ".old"), "should clash with the .old/ dir"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Assert.ThrowsAny(() => UpdateInstaller.Swap(sourceDir, installDir, oldDir)); // Original install state restored. - Assert.True(File.Exists(Path.Combine(installDir, "current.txt"))); - Assert.Equal("original", File.ReadAllText(Path.Combine(installDir, "current.txt"))); - Assert.True(Directory.Exists(Path.Combine(installDir, "vendor"))); - Assert.Equal("lib v1", File.ReadAllText(Path.Combine(installDir, "vendor", "lib.txt"))); + Assert.True(File.Exists(Path.Join(installDir, "current.txt"))); + Assert.Equal("original", File.ReadAllText(Path.Join(installDir, "current.txt"))); + Assert.True(Directory.Exists(Path.Join(installDir, "vendor"))); + Assert.Equal("lib v1", File.ReadAllText(Path.Join(installDir, "vendor", "lib.txt"))); // Anything Phase 2 managed to place before the failure is gone. - Assert.False(File.Exists(Path.Combine(installDir, "newfile.txt"))); + Assert.False(File.Exists(Path.Join(installDir, "newfile.txt"))); } [Theory] @@ -434,12 +434,12 @@ public async Task SwapAsync_preserved_entry_is_not_moved_to_old() using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "appsettings.Development.json"), "user-config"); - File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary"); - File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary"); + File.WriteAllText(Path.Join(installDir, "appsettings.Development.json"), "user-config"); + File.WriteAllText(Path.Join(installDir, "binary.exe"), "old-binary"); + File.WriteAllText(Path.Join(sourceDir, "binary.exe"), "new-binary"); await UpdateInstaller.SwapAsync( sourceDir, installDir, oldDir, @@ -448,11 +448,11 @@ await UpdateInstaller.SwapAsync( CancellationToken.None); // Preserved file untouched. - Assert.Equal("user-config", File.ReadAllText(Path.Combine(installDir, "appsettings.Development.json"))); - Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.Development.json"))); + Assert.Equal("user-config", File.ReadAllText(Path.Join(installDir, "appsettings.Development.json"))); + Assert.False(File.Exists(Path.Join(oldDir, "appsettings.Development.json"))); // Non-preserved file replaced. - Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe"))); - Assert.Equal("old-binary", File.ReadAllText(Path.Combine(oldDir, "binary.exe"))); + Assert.Equal("new-binary", File.ReadAllText(Path.Join(installDir, "binary.exe"))); + Assert.Equal("old-binary", File.ReadAllText(Path.Join(oldDir, "binary.exe"))); } [Fact] @@ -461,11 +461,11 @@ public async Task SwapAsync_when_release_conflicts_default_keeps_existing() using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited"); - File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default"); + File.WriteAllText(Path.Join(installDir, "appsettings.json"), "user-edited"); + File.WriteAllText(Path.Join(sourceDir, "appsettings.json"), "release-default"); await UpdateInstaller.SwapAsync( sourceDir, installDir, oldDir, @@ -473,8 +473,8 @@ await UpdateInstaller.SwapAsync( onConflict: null, // null → keep existing CancellationToken.None); - Assert.Equal("user-edited", File.ReadAllText(Path.Combine(installDir, "appsettings.json"))); - Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.json"))); + Assert.Equal("user-edited", File.ReadAllText(Path.Join(installDir, "appsettings.json"))); + Assert.False(File.Exists(Path.Join(oldDir, "appsettings.json"))); } [Fact] @@ -483,11 +483,11 @@ public async Task SwapAsync_when_resolver_returns_use_new_replaces_existing() using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited"); - File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default"); + File.WriteAllText(Path.Join(installDir, "appsettings.json"), "user-edited"); + File.WriteAllText(Path.Join(sourceDir, "appsettings.json"), "release-default"); UpdateConflict? sawConflict = null; Func> resolver = @@ -501,9 +501,9 @@ await UpdateInstaller.SwapAsync( Assert.NotNull(sawConflict); Assert.Equal("appsettings.json", sawConflict!.RelativePath); - Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.json"))); + Assert.Equal("release-default", File.ReadAllText(Path.Join(installDir, "appsettings.json"))); // Previous user copy moved to .old/ (so the next-startup cleanup sweep removes it). - Assert.Equal("user-edited", File.ReadAllText(Path.Combine(oldDir, "appsettings.json"))); + Assert.Equal("user-edited", File.ReadAllText(Path.Join(oldDir, "appsettings.json"))); } [Fact] @@ -512,12 +512,12 @@ public async Task SwapAsync_preserved_glob_does_not_block_unrelated_release_file using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "myapp.db"), "user-db"); - File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary"); - File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary"); + File.WriteAllText(Path.Join(installDir, "myapp.db"), "user-db"); + File.WriteAllText(Path.Join(installDir, "binary.exe"), "old-binary"); + File.WriteAllText(Path.Join(sourceDir, "binary.exe"), "new-binary"); // Note: source does NOT ship myapp.db. await UpdateInstaller.SwapAsync( @@ -526,8 +526,8 @@ await UpdateInstaller.SwapAsync( onConflict: null, CancellationToken.None); - Assert.Equal("user-db", File.ReadAllText(Path.Combine(installDir, "myapp.db"))); - Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe"))); + Assert.Equal("user-db", File.ReadAllText(Path.Join(installDir, "myapp.db"))); + Assert.Equal("new-binary", File.ReadAllText(Path.Join(installDir, "binary.exe"))); } [Fact] @@ -536,10 +536,10 @@ public async Task SwapAsync_preserved_path_introduced_by_new_release_when_user_h using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "appsettings.Production.json"), "release-default"); + File.WriteAllText(Path.Join(sourceDir, "appsettings.Production.json"), "release-default"); // The path is preservable but the user doesn't have a copy yet — // installer should just place the new file with no resolver call. @@ -554,7 +554,7 @@ await UpdateInstaller.SwapAsync( CancellationToken.None); Assert.False(resolverCalled); - Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.Production.json"))); + Assert.Equal("release-default", File.ReadAllText(Path.Join(installDir, "appsettings.Production.json"))); } [Fact] @@ -563,21 +563,21 @@ public void Swap_preserves_maintenance_entries() using var work = new TempDir(); var installDir = work.Combine("install"); Directory.CreateDirectory(installDir); - Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1")); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); - File.WriteAllText(Path.Combine(installDir, ".update.lock"), ""); - File.WriteAllText(Path.Combine(installDir, "real-file.txt"), "live"); + Directory.CreateDirectory(Path.Join(installDir, ".update", "v1")); + Directory.CreateDirectory(Path.Join(installDir, ".old")); + File.WriteAllText(Path.Join(installDir, ".update.lock"), ""); + File.WriteAllText(Path.Join(installDir, "real-file.txt"), "live"); var sourceDir = work.Combine("src"); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "real-file.txt"), "updated"); + File.WriteAllText(Path.Join(sourceDir, "real-file.txt"), "updated"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); UpdateInstaller.Swap(sourceDir, installDir, oldDir); - Assert.Equal("updated", File.ReadAllText(Path.Combine(installDir, "real-file.txt"))); - Assert.True(Directory.Exists(Path.Combine(installDir, ".update", "v1")), ".update should not be moved into .old"); - Assert.True(File.Exists(Path.Combine(installDir, ".update.lock")), "lock file should survive the swap"); + Assert.Equal("updated", File.ReadAllText(Path.Join(installDir, "real-file.txt"))); + Assert.True(Directory.Exists(Path.Join(installDir, ".update", "v1")), ".update should not be moved into .old"); + Assert.True(File.Exists(Path.Join(installDir, ".update.lock")), "lock file should survive the swap"); } // ---------- Helpers ---------- @@ -621,7 +621,7 @@ private static RemoteRelease BuildReleaseWithSingleAssetZip( public void DeleteDirectoryRobustly_when_path_missing_is_noop() { using var work = new TempDir(); - var missing = Path.Combine(work.Path, "does-not-exist"); + var missing = Path.Join(work.Path, "does-not-exist"); var deleterCalls = 0; var sleeperCalls = 0; @@ -638,7 +638,7 @@ public void DeleteDirectoryRobustly_when_path_missing_is_noop() public void DeleteDirectoryRobustly_succeeds_first_try_calls_deleter_once_no_sleeps() { using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); var deleterCalls = 0; @@ -663,7 +663,7 @@ public void DeleteDirectoryRobustly_retries_with_backoff_on_transient_io_failure // Simulates OneDrive / antivirus / Windows Search holding a // sharing-violation handle that releases within a few hundred ms. using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); var deleterCalls = 0; @@ -690,7 +690,7 @@ public void DeleteDirectoryRobustly_retries_with_backoff_on_transient_io_failure public void DeleteDirectoryRobustly_throws_after_exhausting_retries() { using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); var deleterCalls = 0; @@ -721,9 +721,9 @@ public void DeleteDirectoryRobustly_clears_readonly_attribute_on_descendant_file // delegating, otherwise extracted-archive content (which often // arrives ReadOnly on Windows) breaks the recursive delete. using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); - var readOnlyFile = Path.Combine(dir, "readonly.txt"); + var readOnlyFile = Path.Join(dir, "readonly.txt"); File.WriteAllText(readOnlyFile, "ro"); File.SetAttributes(readOnlyFile, File.GetAttributes(readOnlyFile) | FileAttributes.ReadOnly); diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs index f1d9fee..5955017 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs @@ -30,7 +30,7 @@ public void RenderIfAvailable_when_update_present_renders_banner() { var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: new Uri("https://example.com/r")); var task = Task.FromResult(info); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -45,7 +45,7 @@ public void RenderIfAvailable_when_no_release_url_omits_link() { var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); var task = Task.FromResult(info); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -57,7 +57,7 @@ public void RenderIfAvailable_when_no_release_url_omits_link() public void RenderIfAvailable_when_info_is_null_writes_nothing() { var task = Task.FromResult(null); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -69,7 +69,7 @@ public void RenderIfAvailable_when_up_to_date_writes_nothing() { var info = new UpdateInfo("1.4.2", "v1.4.2", IsUpdateAvailable: false, ReleaseUrl: null); var task = Task.FromResult(info); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -80,7 +80,7 @@ public void RenderIfAvailable_when_up_to_date_writes_nothing() public void RenderIfAvailable_when_task_does_not_complete_in_time_writes_nothing() { var tcs = new TaskCompletionSource(); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(tcs.Task, waitFor: TimeSpan.FromMilliseconds(50), console: console); @@ -95,7 +95,7 @@ public void RenderIfAvailable_when_task_does_not_complete_in_time_writes_nothing public void RenderIfAvailable_when_task_faulted_swallows_and_writes_nothing() { var task = Task.FromException(new InvalidOperationException("boom")); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs index 6380459..7326f9e 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs @@ -15,7 +15,7 @@ public sealed class UpdateCleanupTests public void Run_when_no_pending_cleanup_writes_nothing_and_cleans() { var installer = new StubUpdateInstaller { HasPendingCleanup = false }; - var console = new TestConsole(); + using var console = new TestConsole(); UpdateCleanup.Run(installer, console); @@ -27,7 +27,7 @@ public void Run_when_no_pending_cleanup_writes_nothing_and_cleans() public void Run_when_pending_cleanup_renders_message_and_cleans() { var installer = new StubUpdateInstaller { HasPendingCleanup = true }; - var console = new TestConsole(); + using var console = new TestConsole(); UpdateCleanup.Run(installer, console); @@ -39,7 +39,7 @@ public void Run_when_pending_cleanup_renders_message_and_cleans() public void Run_via_service_provider_resolves_installer_and_console() { var installer = new StubUpdateInstaller { HasPendingCleanup = true }; - var console = new TestConsole(); + using var console = new TestConsole(); var services = new ServiceCollection(); services.AddSingleton(installer); services.AddSingleton(console); @@ -55,7 +55,11 @@ public void Run_via_service_provider_resolves_installer_and_console() public void Run_with_null_services_throws() => Assert.Throws(() => UpdateCleanup.Run((IServiceProvider)null!)); [Fact] - public void Run_with_null_installer_throws() => Assert.Throws(() => UpdateCleanup.Run((IUpdateInstaller)null!, new TestConsole())); + public void Run_with_null_installer_throws() + { + using var console = new TestConsole(); + Assert.Throws(() => UpdateCleanup.Run((IUpdateInstaller)null!, console)); + } [Fact] public void Run_with_null_console_throws() => Assert.Throws(() => UpdateCleanup.Run(new StubUpdateInstaller(), null!));