diff --git a/.editorconfig b/.editorconfig
index 8342e30..f8b4feb 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -215,5 +215,5 @@ dotnet_diagnostic.CA1707.severity = none
# Tests: Method_Scenario_Expected naming and public [TestClass] types are the
# established convention.
-[HostsFileEditor.Core.Tests/*.cs]
+[HostsFileEditor.{Core,WinForm}.Tests/**.cs]
dotnet_diagnostic.CA1707.severity = none
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2d5d51b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,102 @@
+name: CI
+
+# Gates every pull request (and pushes to master) on a clean build and a green test run on BOTH
+# target architectures — x64 and ARM64 — since the app ships for both.
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+# Cancel superseded runs on the same ref so a force-push doesn't pile up queued jobs.
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build-test:
+ name: build & test (${{ matrix.arch }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ os: windows-latest
+ rid: win-x64
+ - arch: arm64
+ os: windows-11-arm
+ rid: win-arm64
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Installs the exact SDK pinned in global.json (10.0.301, rollForward latestFeature).
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ # Build the shared core, the headless CLI launcher, and the elevation helper. These are
+ # architecture-portable (a library / RID-agnostic builds), so no RID is needed here.
+ # TreatWarningsAsErrors is on repo-wide, so this also fails on any new analyzer warning.
+ - name: Build core, CLI and helper
+ run: |
+ dotnet build HostsFileEditor.Core/HostsFileEditor.Core.csproj -c Release
+ dotnet build HostsFileEditor.Cli/HostsFileEditor.Cli.csproj -c Release
+ dotnet build HostsFileEditor.Elevate/HostsFileEditor.Elevate.csproj -c Release
+
+ # Build the classic (WinForms) edition for THIS architecture. Both app projects default their
+ # RuntimeIdentifier to win-x64, so the RID must be passed explicitly or the arm64 leg would
+ # silently build x64 and never validate arm64.
+ - name: Build classic UI (WinForms)
+ run: dotnet build HostsFileEditor.WinForm/HostsFileEditor.WinForm.csproj -c Release -p:RuntimeIdentifier=${{ matrix.rid }}
+
+ # Build the modern (WinUI 3) edition for this architecture. A plain build (not publish) skips
+ # the AOT/trim/MSIX steps, so it needs no C++ toolchain — just the Windows App SDK NuGet.
+ - name: Build modern UI (WinUI)
+ run: dotnet build HostsFileEditor.WinUI/HostsFileEditor.WinUI.csproj -c Release -p:Platform=${{ matrix.arch }} -p:RuntimeIdentifier=${{ matrix.rid }}
+
+ # Runs the full Core/CLI test suite with OpenCover coverage (configured in coverage.runsettings).
+ - name: Test Core/CLI with coverage
+ run: >
+ dotnet test HostsFileEditor.Core.Tests/HostsFileEditor.Core.Tests.csproj
+ -c Release
+ --logger "trx;LogFileName=core-test-results.trx"
+ --results-directory ${{ github.workspace }}/TestResults
+
+ # Classic-edition (WinForms) UI-logic tests. The project pins RID win-x64, so it runs on the x64
+ # leg only; the modern edition is covered by its build gate above and shared logic lives in Core.
+ - name: Test classic UI (x64 only)
+ if: matrix.arch == 'x64'
+ run: >
+ dotnet test HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj
+ -c Release
+ --logger "trx;LogFileName=winform-test-results.trx"
+ --results-directory ${{ github.workspace }}/TestResults
+
+ - name: Coverage summary
+ if: always()
+ continue-on-error: true
+ shell: pwsh
+ run: |
+ dotnet tool install --global dotnet-reportgenerator-globaltool 2>$null
+ $report = Get-ChildItem -Path "${{ github.workspace }}/TestResults" -Recurse -Filter coverage.opencover.xml | Select-Object -First 1
+ if (-not $report) { Write-Host "No coverage report found."; exit 0 }
+ reportgenerator -reports:"$($report.FullName)" -targetdir:"${{ github.workspace }}/CoverageReport" -reporttypes:"TextSummary;MarkdownSummaryGithub"
+ $md = "${{ github.workspace }}/CoverageReport/SummaryGithub.md"
+ if (Test-Path $md) { Get-Content $md | Add-Content -Path $env:GITHUB_STEP_SUMMARY }
+ Get-Content "${{ github.workspace }}/CoverageReport/Summary.txt" | Write-Host
+
+ - name: Upload test results and coverage
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-${{ matrix.arch }}
+ path: |
+ ${{ github.workspace }}/TestResults/**/*.trx
+ ${{ github.workspace }}/TestResults/**/coverage.opencover.xml
+ if-no-files-found: warn
diff --git a/HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs b/HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs
new file mode 100644
index 0000000..6cafcbc
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs
@@ -0,0 +1,90 @@
+using HostsFileEditor.Elevation;
+using HostsFileEditor.Properties;
+
+namespace HostsFileEditor.Core.Tests;
+
+[TestClass]
+public sealed class CoreExceptionsAndResourcesTests
+{
+ [TestMethod]
+ public void ElevationCancelledException_DefaultMessage()
+ {
+ var ex = new ElevationCancelledException();
+ ex.Message.ShouldContain("administrator permission");
+ }
+
+ [TestMethod]
+ public void ElevationCancelledException_CustomMessage()
+ {
+ var ex = new ElevationCancelledException("custom");
+ ex.Message.ShouldBe("custom");
+ }
+
+ [TestMethod]
+ public void ElevationCancelledException_MessageAndInner()
+ {
+ var inner = new InvalidOperationException("inner");
+ var ex = new ElevationCancelledException("outer", inner);
+ ex.Message.ShouldBe("outer");
+ ex.InnerException.ShouldBe(inner);
+ }
+
+ [TestMethod]
+ public void HostsFileConflictException_DefaultConstructs()
+ {
+ var ex = new HostsFileConflictException();
+ ex.ShouldNotBeNull();
+ }
+
+ [TestMethod]
+ public void HostsFileConflictException_CustomMessage()
+ {
+ var ex = new HostsFileConflictException("conflict");
+ ex.Message.ShouldBe("conflict");
+ }
+
+ [TestMethod]
+ public void HostsFileConflictException_MessageAndInner()
+ {
+ var inner = new IOException("io");
+ var ex = new HostsFileConflictException("outer", inner);
+ ex.Message.ShouldBe("outer");
+ ex.InnerException.ShouldBe(inner);
+ }
+
+ // Touch every strongly-typed resource accessor so the generated getters are exercised. The three
+ // messages the app actually depends on for its validation/UX carry real text; the rest are read
+ // for coverage without asserting a value (some are placeholder/empty in the .resx).
+ [TestMethod]
+ public void Resources_AllAccessorsExecute()
+ {
+ _ = Resources.ArchiveExists;
+ _ = Resources.ErrorCaption;
+ _ = Resources.InputArchivePrompt;
+ _ = Resources.LoseChangesDialogCaption;
+ _ = Resources.LoseChangesQuestion;
+ _ = Resources.UnknownException;
+
+ Resources.hosts.ShouldNotBeNullOrEmpty();
+ Resources.InvalidHostEntries.ShouldNotBeNullOrEmpty();
+ Resources.InvalidHostnames.ShouldNotBeNullOrEmpty();
+ Resources.InvalidIPAddress.ShouldNotBeNullOrEmpty();
+ Resources.PingFailed.ShouldNotBeNullOrEmpty();
+ }
+
+ [TestMethod]
+ public void Resources_CultureRoundTrips()
+ {
+ var original = Resources.Culture;
+ try
+ {
+ Resources.Culture = System.Globalization.CultureInfo.InvariantCulture;
+ Resources.Culture.ShouldBe(System.Globalization.CultureInfo.InvariantCulture);
+ Resources.ArchiveExists.ShouldNotBeNull();
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs b/HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs
new file mode 100644
index 0000000..23cf14d
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs
@@ -0,0 +1,95 @@
+using HostsFileEditor.Elevation;
+
+namespace HostsFileEditor.Core.Tests;
+
+///
+/// Coverage for 's helper-resolution branches.
+/// is process-global, so every test restores it.
+///
+[TestClass]
+public sealed class ElevationHelperSelectionTests
+{
+ private IPrivilegedFileOperations _originalCurrent = null!;
+
+ [TestInitialize]
+ public void Init() => _originalCurrent = PrivilegedFileOperations.Current;
+
+ [TestCleanup]
+ public void Cleanup() => PrivilegedFileOperations.Current = _originalCurrent;
+
+ [TestMethod]
+ public void UseElevationHelper_ExplicitExistingPath_SelectsHelper()
+ {
+ var helper = Path.Combine(Path.GetTempPath(), "HfeHelper_" + Guid.NewGuid().ToString("N") + ".exe");
+ File.WriteAllText(helper, "not a real exe");
+ try
+ {
+ PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
+ PrivilegedFileOperations.UseElevationHelper(helper);
+ PrivilegedFileOperations.Current.ShouldBeOfType();
+ }
+ finally
+ {
+ File.Delete(helper);
+ }
+ }
+
+ [TestMethod]
+ public void UseElevationHelper_ExplicitMissingPath_LeavesCurrentUnchanged()
+ {
+ var inProcess = new InProcessPrivilegedFileOperations();
+ PrivilegedFileOperations.Current = inProcess;
+
+ PrivilegedFileOperations.UseElevationHelper(Path.Combine(Path.GetTempPath(), "no-such-helper.exe"));
+
+ PrivilegedFileOperations.Current.ShouldBeSameAs(inProcess);
+ }
+
+ [TestMethod]
+ public void UseElevationHelper_NoHelperBesideApp_StaysInProcess()
+ {
+ // No HostsFileEditor.Elevate.exe ships in the test bin. Defensively clear any stub a prior
+ // (interrupted) run of UseElevationHelper_HelperBesideApp_SelectsHelper may have left behind,
+ // so the default probe genuinely finds nothing regardless of test order.
+ foreach (var stray in HelperCandidatePaths())
+ {
+ if (File.Exists(stray))
+ {
+ File.Delete(stray);
+ }
+ }
+
+ var inProcess = new InProcessPrivilegedFileOperations();
+ PrivilegedFileOperations.Current = inProcess;
+
+ PrivilegedFileOperations.UseElevationHelper();
+
+ PrivilegedFileOperations.Current.ShouldBeSameAs(inProcess);
+ }
+
+ [TestMethod]
+ public void UseElevationHelper_HelperBesideApp_SelectsHelper()
+ {
+ // Drop a stand-in helper next to the test assembly so the default candidate probe finds it.
+ // The test bin never contains a real helper, so the stub is always removed afterwards.
+ var candidate = Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperExecutableName);
+ File.WriteAllText(candidate, "stub");
+
+ try
+ {
+ PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
+ PrivilegedFileOperations.UseElevationHelper();
+ PrivilegedFileOperations.Current.ShouldBeOfType();
+ }
+ finally
+ {
+ File.Delete(candidate);
+ }
+ }
+
+ private static IEnumerable HelperCandidatePaths() =>
+ [
+ Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperSubdirectory, PrivilegedFileOperations.HelperExecutableName),
+ Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperExecutableName),
+ ];
+}
diff --git a/HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs b/HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs
new file mode 100644
index 0000000..d5aacc3
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs
@@ -0,0 +1,48 @@
+namespace HostsFileEditor.Core.Tests;
+
+[TestClass]
+public sealed class HostsArchiveGapTests
+{
+ [TestMethod]
+ public void Validate_InvalidPath_ReturnsFalseWithMessage()
+ {
+ // An embedded null makes new FileInfo(...) throw, exercising Validate's catch path.
+ HostsArchive.Validate("bad\0name", out var error).ShouldBeFalse();
+ error.ShouldNotBeNullOrEmpty();
+ }
+
+ [TestMethod]
+ public void Constructor_WithName_UsesEffectiveArchiveDirectory()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(dir);
+ try
+ {
+ HostsArchiveList.TestArchiveDirectoryOverride = dir;
+ var archive = new HostsArchive("preset.txt");
+ archive.FilePath.ShouldBe(Path.Combine(dir, "preset.txt"));
+ archive.FileName.ShouldBe("preset.txt");
+ }
+ finally
+ {
+ HostsArchiveList.TestArchiveDirectoryOverride = null;
+ }
+ }
+
+ [TestMethod]
+ public void Constructor_NullName_Throws() =>
+ Should.Throw(() => new HostsArchive(null!));
+
+ [TestMethod]
+ public void FilePath_NullAssignment_Throws() =>
+ Should.Throw(() => new HostsArchive { FilePath = null! });
+
+ [TestMethod]
+ public void FileNameComparer_OrdersCaseInsensitively()
+ {
+ var a = new HostsArchive { FilePath = @"x\alpha.txt" };
+ var b = new HostsArchive { FilePath = @"x\BETA.txt" };
+ HostsArchive.FileNameComparer.Compare(a, b).ShouldBeLessThan(0);
+ HostsArchive.FileNameComparer.Compare(b, a).ShouldBeGreaterThan(0);
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/HostsCliExecuteTests.cs b/HostsFileEditor.Core.Tests/HostsCliExecuteTests.cs
new file mode 100644
index 0000000..b433e56
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/HostsCliExecuteTests.cs
@@ -0,0 +1,294 @@
+using HostsFileEditor.CommandLine;
+using HostsFileEditor.Elevation;
+
+namespace HostsFileEditor.Core.Tests;
+
+///
+/// End-to-end coverage of 's execute paths and top-level error handling.
+/// These run against the temp-file-bound established by
+/// , so they exercise the real list/apply/enable/disable/import/merge
+/// flow without touching the machine hosts file.
+///
+[TestClass]
+public sealed class HostsCliExecuteTests
+{
+ private string _archiveDir = null!;
+
+ [TestInitialize]
+ public void Init()
+ {
+ // A prior test may have swapped in a throwing elevation fake; restore the default first so the
+ // re-enable below (which moves the file) can succeed.
+ PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
+
+ // A prior test may have left the file disabled (renamed aside). Put it back before reseeding.
+ if (!HostsFile.IsEnabled)
+ {
+ HostsFile.Instance.EnableHostsFile();
+ }
+
+ File.WriteAllLines(HostsFile.DefaultHostFilePath, TestAssemblyInit.SeedLines);
+ HostsFile.Instance.Refresh();
+
+ _archiveDir = Path.Combine(Path.GetTempPath(), "HfeCliArchives_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_archiveDir);
+ HostsArchiveList.TestArchiveDirectoryOverride = _archiveDir;
+ HostsArchiveList.Instance.Refresh();
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ HostsArchiveList.TestArchiveDirectoryOverride = null;
+ HostsArchiveList.Instance.Refresh();
+ PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
+
+ // Leave the file enabled for the next test / class.
+ if (!HostsFile.IsEnabled)
+ {
+ HostsFile.Instance.EnableHostsFile();
+ }
+
+ if (Directory.Exists(_archiveDir))
+ {
+ Directory.Delete(_archiveDir, true);
+ }
+ }
+
+ private static (int code, string outText, string errText) RunCli(params string[] args)
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var code = HostsCli.Run(args, output, error);
+ return (code, output.ToString(), error.ToString());
+ }
+
+ [TestMethod]
+ public void List_NoPresets_ReportsNone()
+ {
+ var (code, outText, _) = RunCli("list");
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("No presets found.");
+ }
+
+ [TestMethod]
+ public void List_WithPresets_ListsThemSorted()
+ {
+ File.WriteAllText(Path.Combine(_archiveDir, "Zeta.txt"), "10.0.0.9 z");
+ File.WriteAllText(Path.Combine(_archiveDir, "Alpha.txt"), "10.0.0.8 a");
+ HostsArchiveList.Instance.Refresh();
+
+ var (code, outText, _) = RunCli("list");
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("Presets:");
+ outText.IndexOf("Alpha.txt", StringComparison.Ordinal)
+ .ShouldBeLessThan(outText.IndexOf("Zeta.txt", StringComparison.Ordinal));
+ }
+
+ [TestMethod]
+ public void Apply_ExistingPreset_ReplacesHostsAndReports()
+ {
+ File.WriteAllLines(Path.Combine(_archiveDir, "Custom.txt"), ["192.168.1.1 router.test"]);
+ HostsArchiveList.Instance.Refresh();
+
+ var (code, outText, _) = RunCli("apply", "Custom.txt");
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("Applied preset 'Custom.txt'");
+ File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("router.test");
+ HostsFile.Instance.Entries.ShouldContain(e => e.HostNames == "router.test");
+ }
+
+ [TestMethod]
+ public void Apply_StemResolvesPreset()
+ {
+ File.WriteAllLines(Path.Combine(_archiveDir, "MyHosts1.txt"), ["192.168.1.2 stem.test"]);
+ HostsArchiveList.Instance.Refresh();
+
+ var (code, _, _) = RunCli("-s", "MyHosts1");
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("stem.test");
+ }
+
+ [TestMethod]
+ public void Apply_MissingPreset_ReturnsError()
+ {
+ var (code, _, errText) = RunCli("apply", "does-not-exist");
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("not found");
+ }
+
+ [TestMethod]
+ public void Apply_AmbiguousStem_ReturnsError()
+ {
+ File.WriteAllText(Path.Combine(_archiveDir, "Dup.txt"), "a");
+ File.WriteAllText(Path.Combine(_archiveDir, "Dup.bak"), "b");
+ HostsArchiveList.Instance.Refresh();
+
+ var (code, _, errText) = RunCli("apply", "Dup");
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("ambiguous");
+ }
+
+ [TestMethod]
+ public void Enable_AlreadyEnabled_ReportsNoOp()
+ {
+ var (code, outText, _) = RunCli("enable");
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("already enabled");
+ }
+
+ [TestMethod]
+ public void Disable_ThenEnable_TogglesFile()
+ {
+ var (disableCode, disableOut, _) = RunCli("disable");
+ disableCode.ShouldBe(HostsCli.ExitSuccess);
+ disableOut.ShouldContain("Disabled the hosts file.");
+ HostsFile.IsEnabled.ShouldBeFalse();
+
+ // A second disable is a no-op.
+ var (code2, out2, _) = RunCli("disable");
+ code2.ShouldBe(HostsCli.ExitSuccess);
+ out2.ShouldContain("already disabled");
+
+ var (enableCode, enableOut, _) = RunCli("enable");
+ enableCode.ShouldBe(HostsCli.ExitSuccess);
+ enableOut.ShouldContain("Enabled the hosts file.");
+ HostsFile.IsEnabled.ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void Import_ExistingFile_ReplacesAndSaves()
+ {
+ var importFile = Path.Combine(_archiveDir, "import.txt");
+ File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]);
+
+ var (code, outText, _) = RunCli("import", importFile);
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("Imported");
+ File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("dns.test");
+ }
+
+ [TestMethod]
+ public void Import_MissingFile_ReturnsError()
+ {
+ var (code, _, errText) = RunCli("import", Path.Combine(_archiveDir, "nope.txt"));
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("File not found");
+ }
+
+ [TestMethod]
+ public void Import_WhenDisabled_NotesDisabledState()
+ {
+ RunCli("disable");
+ var importFile = Path.Combine(_archiveDir, "import.txt");
+ File.WriteAllLines(importFile, ["8.8.4.4 dns2.test"]);
+
+ var (code, outText, _) = RunCli("import", importFile);
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("currently DISABLED");
+ }
+
+ [TestMethod]
+ public void Merge_AddsNewEntries()
+ {
+ var mergeFile = Path.Combine(_archiveDir, "merge.txt");
+ File.WriteAllLines(mergeFile, ["4.4.4.4 brandnew.test"]);
+
+ var (code, outText, _) = RunCli("merge", mergeFile);
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("Merged 1 new entry");
+ File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("brandnew.test");
+ }
+
+ [TestMethod]
+ public void Merge_AllDuplicates_ReportsNothingAdded()
+ {
+ var mergeFile = Path.Combine(_archiveDir, "merge.txt");
+ // Same entries as the seed → nothing new to add.
+ File.WriteAllLines(mergeFile, TestAssemblyInit.SeedLines);
+
+ var (code, outText, _) = RunCli("merge", mergeFile);
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("No new entries");
+ }
+
+ [TestMethod]
+ public void Merge_MultipleEntries_ReportsPluralCount()
+ {
+ var mergeFile = Path.Combine(_archiveDir, "merge.txt");
+ File.WriteAllLines(mergeFile, ["4.4.4.4 one.test", "5.5.5.5 two.test"]);
+
+ var (code, outText, _) = RunCli("merge", mergeFile);
+
+ code.ShouldBe(HostsCli.ExitSuccess);
+ outText.ShouldContain("Merged 2 new entries");
+ }
+
+ [TestMethod]
+ public void Merge_MissingFile_ReturnsError()
+ {
+ var (code, _, errText) = RunCli("merge", Path.Combine(_archiveDir, "nope.txt"));
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("File not found");
+ }
+
+ [TestMethod]
+ public void Run_ElevationDeclined_ReportsCancelled()
+ {
+ PrivilegedFileOperations.Current = new ThrowingPrivilegedFileOperations(new ElevationCancelledException());
+ var importFile = Path.Combine(_archiveDir, "import.txt");
+ File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]);
+
+ var (code, _, errText) = RunCli("import", importFile);
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("administrator permission is required");
+ }
+
+ [TestMethod]
+ public void Run_AccessDenied_ReportsFriendlyMessage()
+ {
+ PrivilegedFileOperations.Current = new ThrowingPrivilegedFileOperations(new UnauthorizedAccessException());
+ var importFile = Path.Combine(_archiveDir, "import.txt");
+ File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]);
+
+ var (code, _, errText) = RunCli("import", importFile);
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("Access denied");
+ }
+
+ [TestMethod]
+ public void Run_UnexpectedError_ReportsGenericMessage()
+ {
+ PrivilegedFileOperations.Current = new ThrowingPrivilegedFileOperations(new InvalidOperationException("boom"));
+ var importFile = Path.Combine(_archiveDir, "import.txt");
+ File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]);
+
+ var (code, _, errText) = RunCli("import", importFile);
+
+ code.ShouldBe(HostsCli.ExitError);
+ errText.ShouldContain("Error: boom");
+ }
+
+ /// An elevation stub whose privileged ops always throw a chosen exception.
+ private sealed class ThrowingPrivilegedFileOperations(Exception toThrow) : IPrivilegedFileOperations
+ {
+ public void WriteAllLines(string path, IEnumerable lines) => throw toThrow;
+
+ public void Move(string sourcePath, string destinationPath) => throw toThrow;
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/HostsEntryGapTests.cs b/HostsFileEditor.Core.Tests/HostsEntryGapTests.cs
new file mode 100644
index 0000000..295dd9f
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/HostsEntryGapTests.cs
@@ -0,0 +1,393 @@
+using HostsFileEditor.Properties;
+using HostsFileEditor.Utilities;
+using System.Reflection;
+
+namespace HostsFileEditor.Core.Tests;
+
+/// Targeted coverage for branches not reached by the existing suite.
+[TestClass]
+public sealed class HostsEntryGapTests
+{
+ [TestInitialize]
+ public void Init()
+ {
+ UndoManager.Instance.ClearHistory();
+ HostsEntry.AutoPingIPAddress = false;
+ HostsEntry.UiSynchronizationContext = null;
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ // Stop new auto-pings, then wait for any fire-and-forget ping this test started to finish
+ // before yielding to the next test. HostsEntry.Ping() is fire-and-forget and mutates the
+ // process-global ping counter (BeginPing/EndPing) plus raises the static PingActivityChanged
+ // on a thread-pool continuation; leaving one in flight would flake the ping-activity tests
+ // here and the pre-existing PingActivity_RaisesOnZeroBoundaryOnly.
+ HostsEntry.AutoPingIPAddress = false;
+ SpinUntil(() => !HostsEntry.IsPingInProgress, TimeSpan.FromSeconds(10));
+ HostsEntry.UiSynchronizationContext = null;
+ }
+
+ // ---- GetComparer: the remaining columns and the descending string wrapper ----
+
+ [TestMethod]
+ public void GetComparer_HostNames_Descending()
+ {
+ var list = new List { new("1.1.1.1 a"), new("2.2.2.2 c"), new("3.3.3.3 b") };
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.HostNames, descending: true));
+ list.Select(e => e.HostNames).ShouldBe(["c", "b", "a"]);
+ }
+
+ [TestMethod]
+ public void GetComparer_Comment_BothDirections()
+ {
+ var list = new List
+ {
+ new("1.1.1.1 h # bbb"),
+ new("2.2.2.2 h # aaa"),
+ new("3.3.3.3 h # ccc"),
+ };
+
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Comment, descending: false));
+ list.Select(e => e.Comment).ShouldBe(["aaa", "bbb", "ccc"]);
+
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Comment, descending: true));
+ list.Select(e => e.Comment).ShouldBe(["ccc", "bbb", "aaa"]);
+ }
+
+ [TestMethod]
+ public void GetComparer_Enabled_Sorts()
+ {
+ var enabled = new HostsEntry("1.1.1.1 on");
+ var disabled = new HostsEntry("# 2.2.2.2 off");
+ var list = new List { enabled, disabled };
+
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Enabled, descending: false));
+ list[0].Enabled.ShouldBeFalse(); // false sorts before true ascending
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Enabled, descending: true));
+ list[0].Enabled.ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void GetComparer_Valid_Sorts()
+ {
+ var valid = new HostsEntry("1.1.1.1 host");
+ var invalid = new HostsEntry("# just a comment");
+ var list = new List { valid, invalid };
+
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Valid, descending: false));
+ list[0].Valid.ShouldBeFalse();
+ list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Valid, descending: true));
+ list[0].Valid.ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void GetComparer_UnknownColumn_Throws() =>
+ Should.Throw(() => HostsEntry.GetComparer((HostsEntry.SortColumn)999, false));
+
+ [TestMethod]
+ public void IpSortKey_CompareTo_OrdersByRankThenValue()
+ {
+ var v4Low = new HostsEntry("1.1.1.1 a").GetIpSortKey();
+ var v4High = new HostsEntry("2.2.2.2 b").GetIpSortKey();
+ var v6 = new HostsEntry("::1 c").GetIpSortKey();
+ var noIp = new HostsEntry("# note").GetIpSortKey();
+
+ v4Low.CompareTo(v4High).ShouldBeLessThan(0);
+ v4High.CompareTo(v4Low).ShouldBeGreaterThan(0);
+ v4High.CompareTo(v6).ShouldBeLessThan(0); // IPv4 rank before IPv6
+ v6.CompareTo(noIp).ShouldBeLessThan(0); // real IP before no-IP
+ v4Low.CompareTo(v4Low).ShouldBe(0);
+ }
+
+ // ---- Property setters / undo ----
+
+ [TestMethod]
+ public void Comment_Change_RecordsUndo()
+ {
+ var entry = new HostsEntry("1.1.1.1 host # original");
+ UndoManager.Instance.ClearHistory();
+
+ entry.Comment = "changed";
+ entry.Comment.ShouldBe("changed");
+ UndoManager.Instance.CanUndo.ShouldBeTrue();
+
+ UndoManager.Instance.Undo();
+ entry.Comment.ShouldBe("original");
+ }
+
+ [TestMethod]
+ public void HostNames_Change_RecordsUndo_AndTrims()
+ {
+ var entry = new HostsEntry("1.1.1.1 host");
+ UndoManager.Instance.ClearHistory();
+
+ entry.HostNames = " newhost ";
+ entry.HostNames.ShouldBe("newhost");
+ UndoManager.Instance.CanUndo.ShouldBeTrue();
+
+ UndoManager.Instance.Undo();
+ entry.HostNames.ShouldBe("host");
+ }
+
+ [TestMethod]
+ public void Comment_NullValue_Throws() =>
+ Should.Throw(() => new HostsEntry("1.1.1.1 h").Comment = null!);
+
+ [TestMethod]
+ public void HostNames_NullValue_Throws() =>
+ Should.Throw(() => new HostsEntry("1.1.1.1 h").HostNames = null!);
+
+ [TestMethod]
+ public void IpAddress_NullValue_Throws() =>
+ Should.Throw(() => new HostsEntry("1.1.1.1 h").IpAddress = null!);
+
+ [TestMethod]
+ public void UnparsedText_NullValue_Throws() =>
+ Should.Throw(() => new HostsEntry("1.1.1.1 h").UnparsedText = null!);
+
+ // ---- UnparsedText re-serialization ----
+
+ [TestMethod]
+ public void UnparsedText_InvalidEntry_PrefixedWithHash()
+ {
+ var entry = new HostsEntry("127.0.0.1 host");
+ entry.HostNames = "bad host!!"; // invalid hostname -> entry becomes invalid
+
+ entry.Valid.ShouldBeFalse();
+ entry.UnparsedText.ShouldStartWith("#");
+ }
+
+ [TestMethod]
+ public void UnparsedText_CommentOnly_ReserializesFromComment()
+ {
+ var entry = new HostsEntry("# hello");
+ entry.Comment = "world";
+
+ // Re-serialization takes the HasCommentOnly branch; an invalid (comment) line is hash-prefixed.
+ var text = entry.UnparsedText;
+ text.ShouldStartWith("#");
+ text.ShouldContain("world");
+ }
+
+ [TestMethod]
+ public void UnparsedText_Setter_InvalidatesAndRebuildsFromFields()
+ {
+ var entry = new HostsEntry("127.0.0.1 host");
+
+ // The setter marks the serialized text stale, so the next read rebuilds it from the fields
+ // rather than returning the assigned string verbatim.
+ entry.UnparsedText = "literally anything";
+
+ entry.UnparsedText.ShouldBe("127.0.0.1 host");
+ }
+
+ [TestMethod]
+ public void Valid_Setter_UpdatesProperty()
+ {
+ var entry = new HostsEntry("# comment");
+ entry.Valid.ShouldBeFalse();
+ entry.Valid = true;
+ entry.Valid.ShouldBeTrue();
+ }
+
+ // ---- IDataErrorInfo indexer ----
+
+ [TestMethod]
+ public void Indexer_ReturnsErrorForInvalidIp_EmptyOtherwise()
+ {
+ var entry = new HostsEntry("127.0.0.1 host");
+ entry.IpAddress = "not-an-ip";
+
+ entry["IpAddress"].ShouldBe(Resources.InvalidIPAddress);
+ entry["SomethingElse"].ShouldBeEmpty();
+ }
+
+ // ---- Validation edge branches ----
+
+ [TestMethod]
+ public void ValidateHostnames_Invalid_SurfacesNonEmptyErrorMessage()
+ {
+ // Regression: Resources.InvalidHostnames previously had no matching .resx key and resolved to
+ // null, so an invalid hostname produced a blank IDataErrorInfo message in both editions.
+ var entry = new HostsEntry("127.0.0.1 host");
+ entry.HostNames = "bad host!!"; // spaces/'!' are not valid hostname characters
+
+ entry.Valid.ShouldBeFalse();
+ entry["HostNames"].ShouldBe(Resources.InvalidHostnames);
+ entry["HostNames"].ShouldNotBeNullOrEmpty();
+ }
+
+ [TestMethod]
+ public void ValidateHostnames_BlankOnDisabledEntry_IsNotAnError()
+ {
+ var entry = new HostsEntry("# 1.2.3.4 host"); // disabled entry
+ entry.Enabled.ShouldBeFalse();
+
+ entry.HostNames = string.Empty; // blank host on a disabled row is allowed
+ entry["HostNames"].ShouldBeEmpty();
+ }
+
+ [TestMethod]
+ public void ValidateIpAddress_Invalid_SetsErrorAndClearsWhenFixed()
+ {
+ var entry = new HostsEntry("127.0.0.1 host");
+
+ entry.IpAddress = "999.999.999.999";
+ entry.Valid.ShouldBeFalse();
+ entry["IpAddress"].ShouldBe(Resources.InvalidIPAddress);
+
+ // Fixing the address must clear the error (SetError removal path).
+ entry.IpAddress = "10.0.0.1";
+ entry["IpAddress"].ShouldBeEmpty();
+ entry.Valid.ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void ValidateIpAddress_BlankOnDisabledEntry_IsNotAnError()
+ {
+ var entry = new HostsEntry("# 1.2.3.4 host");
+ entry.IpAddress = string.Empty;
+ entry["IpAddress"].ShouldBeEmpty();
+ }
+
+ // ---- Auto-ping at parse time ----
+
+ [TestMethod]
+ public void Construction_WithAutoPingOn_DoesNotThrow()
+ {
+ HostsEntry.AutoPingIPAddress = true;
+ var entry = new HostsEntry("127.0.0.1 localhost"); // triggers the parse-time ping branch
+ entry.IpAddress.ShouldBe("127.0.0.1");
+ }
+
+ [TestMethod]
+ public void SuspendAutoPing_SuppressesParseTimePing_AndDisposeIsIdempotent()
+ {
+ HostsEntry.AutoPingIPAddress = true;
+ var scope = HostsEntry.SuspendAutoPing();
+ try
+ {
+ var entry = new HostsEntry("127.0.0.1 localhost");
+ entry.IpAddress.ShouldBe("127.0.0.1");
+ }
+ finally
+ {
+ scope.Dispose();
+ scope.Dispose(); // idempotent
+ }
+ }
+
+ // ---- Ping ----
+
+ [TestMethod]
+ public void Ping_InvalidIp_IsNoOp()
+ {
+ var entry = new HostsEntry("# just a comment"); // no parseable IP
+ Should.NotThrow(entry.Ping);
+ entry.PingFailed.ShouldBeFalse();
+ }
+
+ [TestMethod]
+ public void Ping_ValidLoopback_DoesNotThrow()
+ {
+ var entry = new HostsEntry("127.0.0.1 localhost");
+ Should.NotThrow(entry.Ping);
+ }
+
+ [TestMethod]
+ public void SetIpAddress_WithAutoPingOn_Pings()
+ {
+ HostsEntry.AutoPingIPAddress = true;
+ var entry = new HostsEntry("# comment");
+ entry.IpAddress = "127.0.0.1"; // valid -> triggers auto-ping branch in ValidateIpAddress
+ entry.IpAddress.ShouldBe("127.0.0.1");
+ }
+
+ // Opportunistic coverage of the ping-FAILURE reporting path. Pings a non-routable RFC5737
+ // TEST-NET address (no external host is contacted). When ICMP is available the ping fails and the
+ // failure state is asserted; where the environment blocks ICMP entirely the ping throws internally
+ // and is swallowed, so we don't hard-fail — the deterministic paths above still cover the rest.
+ [TestMethod]
+ public void Ping_UnreachableAddress_MarksPingFailed_WhenIcmpAvailable()
+ {
+ var entry = new HostsEntry("192.0.2.1 unreachable.test");
+ entry.Ping();
+
+ var flipped = SpinUntil(() => entry.PingFailed, TimeSpan.FromSeconds(12));
+ if (!flipped)
+ {
+ return; // ICMP unavailable in this environment; nothing to assert.
+ }
+
+ entry.PingFailed.ShouldBeTrue();
+ entry["IpAddress"].ShouldNotBeEmpty();
+
+ // Editing the IP clears the stale ping-failure (SetPingFailed false-direction change).
+ entry.IpAddress = "127.0.0.1";
+ entry.PingFailed.ShouldBeFalse();
+ }
+
+ // ---- Ping-activity marshalling to a UI SynchronizationContext ----
+
+ [TestMethod]
+ public void PingActivity_MarshalsThroughUiSynchronizationContext()
+ {
+ var context = new RecordingSynchronizationContext();
+ HostsEntry.UiSynchronizationContext = context;
+ var fired = 0;
+ void Handler(object? s, EventArgs e) => fired++;
+ HostsEntry.PingActivityChanged += Handler;
+ try
+ {
+ HostsEntry.BeginPing(); // 0 -> 1 posts "started" through the context
+ HostsEntry.EndPing(); // 1 -> 0 posts "stopped" through the context
+ context.PostCount.ShouldBe(2);
+ fired.ShouldBe(2);
+ }
+ finally
+ {
+ HostsEntry.PingActivityChanged -= Handler;
+ HostsEntry.UiSynchronizationContext = null;
+ }
+ }
+
+ [TestMethod]
+ public void PingActivity_NoSubscriber_IsNoOp()
+ {
+ // No PingActivityChanged handler attached: RaisePingActivityChanged returns early.
+ HostsEntry.UiSynchronizationContext = null;
+ HostsEntry.BeginPing();
+ HostsEntry.EndPing();
+ HostsEntry.IsPingInProgress.ShouldBeFalse();
+ }
+
+ private static bool SpinUntil(Func condition, TimeSpan timeout)
+ {
+ var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds;
+ while (Environment.TickCount64 < deadline)
+ {
+ if (condition())
+ {
+ return true;
+ }
+
+ Thread.Sleep(50);
+ }
+
+ return condition();
+ }
+
+ /// A synchronization context that runs posted callbacks inline and counts them.
+ private sealed class RecordingSynchronizationContext : SynchronizationContext
+ {
+ public int PostCount { get; private set; }
+
+ public override void Post(SendOrPostCallback d, object? state)
+ {
+ PostCount++;
+ d(state);
+ }
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/HostsEntryListGapTests.cs b/HostsFileEditor.Core.Tests/HostsEntryListGapTests.cs
new file mode 100644
index 0000000..bf07f58
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/HostsEntryListGapTests.cs
@@ -0,0 +1,138 @@
+using HostsFileEditor.Properties;
+using HostsFileEditor.Utilities;
+using System.ComponentModel;
+
+namespace HostsFileEditor.Core.Tests;
+
+/// Targeted coverage for paths not exercised elsewhere.
+[TestClass]
+public sealed class HostsEntryListGapTests
+{
+ [TestInitialize]
+ public void Init() => UndoManager.Instance.ClearHistory();
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ // PingAll / MergeLines(auto-ping) start fire-and-forget pings that mutate the process-global
+ // ping counter on a thread-pool continuation. Drain them before yielding so they can't leak
+ // into (and flake) the ping-activity tests in other classes. Bounded so a stuck ping can't hang.
+ var deadline = Environment.TickCount64 + 10_000;
+ while (HostsEntry.IsPingInProgress && Environment.TickCount64 < deadline)
+ {
+ Thread.Sleep(50);
+ }
+ }
+
+ [TestMethod]
+ public void Error_ReportsInvalidEntries_WhenAnyEntryInvalid()
+ {
+ var list = new HostsEntryList(["127.0.0.1 localhost", "999.999.999.999 bogus"], filterDefault: false);
+ list.Error.ShouldBe(Resources.InvalidHostEntries);
+ }
+
+ [TestMethod]
+ public void Error_Empty_WhenAllValid()
+ {
+ var list = new HostsEntryList(["127.0.0.1 localhost", "10.0.0.1 host.test"], filterDefault: false);
+ list.Error.ShouldBeEmpty();
+ }
+
+ [TestMethod]
+ public void PingAll_DoesNotThrow()
+ {
+ var list = new HostsEntryList(["127.0.0.1 localhost", "# comment"], filterDefault: false);
+ Should.NotThrow(list.PingAll);
+ }
+
+ [TestMethod]
+ public void MergeLines_WithAutoPingOn_PingsAddedEntries()
+ {
+ var original = HostsEntry.AutoPingIPAddress;
+ HostsEntry.AutoPingIPAddress = true;
+ try
+ {
+ var list = new HostsEntryList(["127.0.0.1 localhost"], filterDefault: false);
+ var added = list.MergeLines(["127.0.0.2 added.test"]);
+ added.ShouldBe(1);
+ }
+ finally
+ {
+ HostsEntry.AutoPingIPAddress = original;
+ }
+ }
+
+ [TestMethod]
+ public void MoveBefore_AnchorNotInList_IsNoOp()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a", "10.0.0.1 b"], filterDefault: false);
+ var stranger = new HostsEntry("8.8.8.8 stranger");
+
+ list.MoveBefore([list[0]], stranger);
+
+ list[0].HostNames.ShouldBe("a"); // unchanged
+ list[1].HostNames.ShouldBe("b");
+ }
+
+ [TestMethod]
+ public void MoveBefore_AnchorInsideMovingSet_IsNoOp()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a", "10.0.0.1 b"], filterDefault: false);
+
+ list.MoveBefore([list[0], list[1]], list[0]);
+
+ list[0].HostNames.ShouldBe("a");
+ list[1].HostNames.ShouldBe("b");
+ }
+
+ [TestMethod]
+ public void Duplicate_EmptyCollection_IsNoOp()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false);
+ list.Duplicate([]);
+ list.Count.ShouldBe(1);
+ }
+
+ [TestMethod]
+ public void Duplicate_EntriesNotInList_IsNoOp()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false);
+ var stranger = new HostsEntry("8.8.8.8 stranger");
+
+ list.Duplicate([stranger]);
+
+ list.Count.ShouldBe(1);
+ }
+
+ [TestMethod]
+ public void AddNew_UsesAddNewCore()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false);
+ var added = ((IBindingList)list).AddNew();
+ added.ShouldBeOfType();
+ }
+
+ [TestMethod]
+ public void RemoveAt_RegistersUndo_AndRemoves()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a", "10.0.0.1 b"], filterDefault: false);
+ UndoManager.Instance.ClearHistory();
+
+ list.RemoveAt(0);
+
+ list.Count.ShouldBe(1);
+ list[0].HostNames.ShouldBe("b");
+ UndoManager.Instance.CanUndo.ShouldBeTrue();
+
+ UndoManager.Instance.Undo();
+ list.Count.ShouldBe(2);
+ }
+
+ [TestMethod]
+ public void Add_Parameterless_AppendsEmptyEntry()
+ {
+ var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false);
+ list.Add();
+ list.Count.ShouldBe(2);
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/HostsFileInstanceGapTests.cs b/HostsFileEditor.Core.Tests/HostsFileInstanceGapTests.cs
new file mode 100644
index 0000000..7c370d7
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/HostsFileInstanceGapTests.cs
@@ -0,0 +1,146 @@
+using System.Reflection;
+
+namespace HostsFileEditor.Core.Tests;
+
+///
+/// Coverage for instance behavior that can be driven through the private
+/// constructor (no singleton / no live hosts file involved).
+///
+[TestClass]
+public sealed class HostsFileInstanceGapTests
+{
+ private string _tempDir = null!;
+
+ [TestInitialize]
+ public void Init()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(_tempDir);
+ HostsFile.TestBackupHostFilePathOverride = Path.Combine(_tempDir, "hosts.bak");
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ HostsFile.TestBackupHostFilePathOverride = null;
+ if (Directory.Exists(_tempDir))
+ {
+ foreach (var file in Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories))
+ {
+ File.SetAttributes(file, FileAttributes.Normal);
+ }
+
+ Directory.Delete(_tempDir, true);
+ }
+ }
+
+ private static HostsFile Create(string path) => (HostsFile)typeof(HostsFile)
+ .GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, [typeof(string)], null)!
+ .Invoke([path]);
+
+ [TestMethod]
+ public void Constructor_MissingFile_StartsWithEmptyEntries()
+ {
+ var hf = Create(Path.Combine(_tempDir, "does-not-exist"));
+ hf.Entries.Count.ShouldBe(0);
+ hf.LineCount.ShouldBe(0);
+ hf.EnabledCount.ShouldBe(0);
+ }
+
+ [TestMethod]
+ public void SaveAs_DriveRoot_ThrowsArgumentException()
+ {
+ var file = Path.Combine(_tempDir, "hosts");
+ File.WriteAllLines(file, ["127.0.0.1 localhost"]);
+ var hf = Create(file);
+
+ var root = Directory.GetDirectoryRoot(_tempDir); // e.g. "C:\" — DirectoryName is null
+
+ Should.Throw(() => hf.SaveAs(root));
+ }
+
+ [TestMethod]
+ public void SaveAs_CreatesMissingDirectory()
+ {
+ var file = Path.Combine(_tempDir, "hosts");
+ File.WriteAllLines(file, ["127.0.0.1 localhost"]);
+ var hf = Create(file);
+
+ var nested = Path.Combine(_tempDir, "new", "sub", "out.txt");
+ hf.SaveAs(nested);
+
+ File.Exists(nested).ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void Refresh_RemoveDefaultFalse_KeepsDefaultLines()
+ {
+ var file = Path.Combine(_tempDir, "hosts");
+ File.WriteAllLines(file, HostsEntryList.DefaultLines);
+ var hf = Create(file);
+
+ hf.Refresh(removeDefault: false);
+
+ hf.Entries.Count.ShouldBe(HostsEntryList.DefaultLines.Length);
+ }
+
+ [TestMethod]
+ public void RestoreDefault_LoadsBundledDefault_AndMarksModified()
+ {
+ var file = Path.Combine(_tempDir, "hosts");
+ File.WriteAllLines(file, ["127.0.0.1 localhost"]);
+ var hf = Create(file);
+
+ hf.RestoreDefault();
+
+ hf.Entries.Count.ShouldBeGreaterThan(0);
+ hf.IsModified.ShouldBeTrue(); // RestoreDefault deliberately leaves the model "needs save"
+ }
+
+ [TestMethod]
+ public void Merge_AddsNonDuplicateEntries()
+ {
+ var file = Path.Combine(_tempDir, "hosts");
+ File.WriteAllLines(file, ["127.0.0.1 localhost"]);
+ var hf = Create(file);
+
+ var mergeFile = Path.Combine(_tempDir, "merge.txt");
+ File.WriteAllLines(mergeFile, ["127.0.0.1 localhost", "9.9.9.9 quad9.test"]);
+
+ var added = hf.Merge(mergeFile);
+
+ added.ShouldBe(1);
+ hf.Entries.ShouldContain(e => e.HostNames == "quad9.test");
+ }
+
+ [TestMethod]
+ public void DisableWouldOverwriteDifferentFile_UnreadableFile_TreatedAsConflict()
+ {
+ var live = Path.Combine(_tempDir, "hosts");
+ var disabled = Path.Combine(_tempDir, "hosts.disabled");
+ File.WriteAllText(live, "aaaa");
+ File.WriteAllText(disabled, "bbbb"); // same length so the compare opens the stream
+
+ // Lock the live file so the content compare throws IOException, which the guard treats as a
+ // conflict (err on the side of not destroying data).
+ using var _ = new FileStream(live, FileMode.Open, FileAccess.Read, FileShare.None);
+
+ HostsFile.DisableWouldOverwriteDifferentFile(live, disabled).ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void IsModified_FlipsWithEditsAndSave()
+ {
+ var file = Path.Combine(_tempDir, "hosts");
+ File.WriteAllLines(file, ["127.0.0.1 localhost"]);
+ var hf = Create(file);
+
+ hf.IsModified.ShouldBeFalse();
+
+ hf.Entries.Add(new HostsEntry("10.0.0.1 added.test"));
+ hf.IsModified.ShouldBeTrue();
+
+ hf.Save();
+ hf.IsModified.ShouldBeFalse();
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/HostsFileSingletonGapTests.cs b/HostsFileEditor.Core.Tests/HostsFileSingletonGapTests.cs
new file mode 100644
index 0000000..91c108a
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/HostsFileSingletonGapTests.cs
@@ -0,0 +1,93 @@
+using HostsFileEditor.Elevation;
+
+namespace HostsFileEditor.Core.Tests;
+
+///
+/// Coverage for statics and behavior that only make sense against the process
+/// singleton (bound to a temp file by ): the override plumbing,
+/// preloading, and the disable-conflict guard.
+///
+[TestClass]
+public sealed class HostsFileSingletonGapTests
+{
+ [TestInitialize]
+ public void Init()
+ {
+ PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
+
+ // Re-enable first (moves the disabled copy back to live), THEN clear any leftover disabled file.
+ if (!HostsFile.IsEnabled)
+ {
+ HostsFile.Instance.EnableHostsFile();
+ }
+
+ if (File.Exists(HostsFile.DefaultDisabledHostFilePath))
+ {
+ File.Delete(HostsFile.DefaultDisabledHostFilePath);
+ }
+
+ File.WriteAllLines(HostsFile.DefaultHostFilePath, TestAssemblyInit.SeedLines);
+ HostsFile.Instance.Refresh();
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ // Re-enable first so the move has a source, then remove any stray disabled copy.
+ if (!HostsFile.IsEnabled)
+ {
+ HostsFile.Instance.EnableHostsFile();
+ }
+
+ if (File.Exists(HostsFile.DefaultDisabledHostFilePath))
+ {
+ File.Delete(HostsFile.DefaultDisabledHostFilePath);
+ }
+ }
+
+ [TestMethod]
+ public void OverridePath_IsActiveDuringTests()
+ {
+ HostsFile.OverridePath.ShouldNotBeNull();
+ HostsFile.DefaultHostFilePath.ShouldBe(HostsFile.OverridePath);
+ HostsFile.DefaultDisabledHostFilePath.ShouldEndWith(".disabled");
+ }
+
+ [TestMethod]
+ public void IsEnabled_TrueWhenLiveFilePresent()
+ {
+ HostsFile.IsEnabled.ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public async Task PreloadAsync_Completes()
+ {
+ await HostsFile.PreloadAsync();
+ HostsFile.Instance.Entries.Count.ShouldBeGreaterThan(0);
+ }
+
+ [TestMethod]
+ public void DisableHostsFile_DifferentDisabledCopyExists_ThrowsConflict()
+ {
+ // A pre-existing, DIFFERENT hosts.disabled must not be silently overwritten (issue #99).
+ File.WriteAllLines(
+ HostsFile.DefaultDisabledHostFilePath,
+ ["10.9.9.9 curated.test", "10.9.9.10 more.test"]);
+
+ Should.Throw(() => HostsFile.Instance.DisableHostsFile());
+
+ // The guard fired before moving anything: the live file is intact and still enabled.
+ HostsFile.IsEnabled.ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void DisableHostsFile_IdenticalDisabledCopy_Succeeds()
+ {
+ // A byte-identical hosts.disabled is safe to overwrite, so disabling proceeds.
+ File.WriteAllLines(HostsFile.DefaultDisabledHostFilePath, TestAssemblyInit.SeedLines);
+
+ HostsFile.Instance.DisableHostsFile();
+
+ HostsFile.IsEnabled.ShouldBeFalse();
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/MiscCoverageTests.cs b/HostsFileEditor.Core.Tests/MiscCoverageTests.cs
new file mode 100644
index 0000000..1f38852
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/MiscCoverageTests.cs
@@ -0,0 +1,58 @@
+using HostsFileEditor.CommandLine;
+using System.Reflection;
+
+namespace HostsFileEditor.Core.Tests;
+
+[TestClass]
+public sealed class MiscCoverageTests
+{
+ [TestMethod]
+ public void ProgramSingleInstance_WmShowFirstInstance_IsRegistered()
+ {
+ // Touches the static initializer, which registers the window message via NativeMethods.
+ ProgramSingleInstance.WmShowFirstInstance.ShouldBeGreaterThan(0);
+ }
+
+ [TestMethod]
+ public void ConsoleAttach_AttachToParentConsole_DoesNotThrow()
+ {
+ // Under `dotnet test` stdout is redirected, so this takes the "already have a real stdout"
+ // early-return path. Guard against the rare runner where stdout is NOT a real handle: there
+ // the method attaches to the parent console and rebinds Console.Out/Error (a process-global,
+ // irreversible side effect). Snapshot and restore them so this test can never corrupt another
+ // test's output/logger capture. It must never throw regardless of console state.
+ var savedOut = Console.Out;
+ var savedError = Console.Error;
+ try
+ {
+ Should.NotThrow(ConsoleAttach.AttachToParentConsole);
+ }
+ finally
+ {
+ Console.SetOut(savedOut);
+ Console.SetError(savedError);
+ }
+ }
+
+ [TestMethod]
+ public void HostsArchiveList_MigrateLegacyArchives_IsSafeNoOp()
+ {
+ // With no test override, the private migration runs its real guard checks. It is best-effort
+ // and must never throw; on a machine whose archive directory already exists (or has no legacy
+ // directory) it returns immediately without side effects.
+ var previousOverride = HostsArchiveList.TestArchiveDirectoryOverride;
+ HostsArchiveList.TestArchiveDirectoryOverride = null;
+ try
+ {
+ var method = typeof(HostsArchiveList).GetMethod(
+ "MigrateLegacyArchives",
+ BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ Should.NotThrow(() => method.Invoke(null, null));
+ }
+ finally
+ {
+ HostsArchiveList.TestArchiveDirectoryOverride = previousOverride;
+ }
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/SmallCoreCoverageTests.cs b/HostsFileEditor.Core.Tests/SmallCoreCoverageTests.cs
new file mode 100644
index 0000000..cf8f20f
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/SmallCoreCoverageTests.cs
@@ -0,0 +1,60 @@
+using HostsFileEditor.Utilities;
+using HostsFileEditor.Win32;
+using Microsoft.Win32;
+using System.Reflection;
+
+namespace HostsFileEditor.Core.Tests;
+
+[TestClass]
+public sealed class SmallCoreCoverageTests
+{
+ [TestMethod]
+ public void FileOpener_TryGetRegisteredApplication_ResolvesRegisteredHandler()
+ {
+ // Register a throwaway file association under HKCU\Software\Classes (no admin needed; visible
+ // through the merged Registry.ClassesRoot view the resolver reads) so the success path — both
+ // GetClassesRootKeyDefaultValue lookups plus the command cleanup — runs deterministically.
+ const string ext = ".hfetest";
+ const string progId = "hfetestfile";
+ using (var classes = Registry.CurrentUser.CreateSubKey(@"Software\Classes"))
+ {
+ using var extKey = classes.CreateSubKey(ext);
+ extKey.SetValue(null, progId);
+ using var cmdKey = classes.CreateSubKey($@"{progId}\shell\open\command");
+ cmdKey.SetValue(null, "\"C:\\tools\\editor.exe\" \"%1\"");
+ }
+
+ try
+ {
+ var method = typeof(FileOpener).GetMethod("TryGetRegisteredApplication", BindingFlags.NonPublic | BindingFlags.Static)!;
+ var parameters = new object?[] { ext, null };
+
+ var result = (bool)method.Invoke(null, parameters)!;
+
+ result.ShouldBeTrue();
+ var app = (string?)parameters[1];
+ app.ShouldNotBeNullOrWhiteSpace();
+ app!.ShouldContain("editor.exe");
+ }
+ finally
+ {
+ using var classes = Registry.CurrentUser.OpenSubKey(@"Software\Classes", writable: true);
+ classes?.DeleteSubKeyTree(ext, throwOnMissingSubKey: false);
+ classes?.DeleteSubKeyTree(progId, throwOnMissingSubKey: false);
+ }
+ }
+
+ [TestMethod]
+ public void NativeMethods_IsRunningPackaged_ReturnsFalseForTestHost()
+ {
+ // The test host is a loose (unpackaged) process, so this must report false and never throw.
+ NativeMethods.IsRunningPackaged().ShouldBeFalse();
+ }
+
+ [TestMethod]
+ public void NativeMethods_RegisterWindowMessage_PlainString_ReturnsId()
+ {
+ var id = NativeMethods.RegisterWindowMessage("HFE_TEST_PLAIN_" + Guid.NewGuid().ToString("N"));
+ id.ShouldBeGreaterThan(0);
+ }
+}
diff --git a/HostsFileEditor.Core.Tests/TestAssemblyInit.cs b/HostsFileEditor.Core.Tests/TestAssemblyInit.cs
new file mode 100644
index 0000000..6df6a9f
--- /dev/null
+++ b/HostsFileEditor.Core.Tests/TestAssemblyInit.cs
@@ -0,0 +1,73 @@
+namespace HostsFileEditor.Core.Tests;
+
+///
+/// Assembly-wide setup that redirects the singleton at a throwaway temp file
+/// via the sanctioned HFE_HOSTS_PATH override, so tests can exercise the full headless-CLI and
+/// enable/disable/save code paths (which go through ) without ever
+/// touching the real system hosts file.
+///
+///
+///
+/// The override is captured in a static readonly field the first time anything touches
+/// . is the very first code MSTest runs, before
+/// any test or its initializers, so setting the environment variable and creating the file here — then
+/// forcing the singleton to construct — guarantees the singleton binds to our temp file.
+///
+///
+/// No test in this assembly touched before this harness existed, so
+/// nothing races the capture. Instance-level tests that build their own copy via
+/// the private constructor pass an explicit path and are unaffected by the override.
+///
+///
+[TestClass]
+public static class TestAssemblyInit
+{
+ /// Directory holding the throwaway hosts file the singleton is bound to for this run.
+ public static string HostsDirectory { get; private set; } = null!;
+
+ /// The initial content written to the temp hosts file at assembly start.
+ public static readonly string[] SeedLines =
+ [
+ "127.0.0.1 localhost",
+ "10.0.0.1 example.test",
+ "# a plain comment",
+ ];
+
+ [AssemblyInitialize]
+ public static void AssemblyInitialize(TestContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ HostsDirectory = Path.Combine(Path.GetTempPath(), "HfeTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(HostsDirectory);
+
+ var hostsPath = Path.Combine(HostsDirectory, "hosts");
+ File.WriteAllLines(hostsPath, SeedLines);
+
+ // Bind the singleton to the temp file (env var is read once, on first HostsFile access below).
+ Environment.SetEnvironmentVariable("HFE_HOSTS_PATH", hostsPath);
+ HostsFile.TestBackupHostFilePathOverride = Path.Combine(HostsDirectory, "hosts.bak");
+
+ // Force construction now, under known conditions, so the binding is deterministic.
+ _ = HostsFile.Instance.Entries.Count;
+ }
+
+ [AssemblyCleanup]
+ public static void AssemblyCleanup()
+ {
+ Environment.SetEnvironmentVariable("HFE_HOSTS_PATH", null);
+ HostsFile.TestBackupHostFilePathOverride = null;
+
+ try
+ {
+ if (Directory.Exists(HostsDirectory))
+ {
+ Directory.Delete(HostsDirectory, true);
+ }
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup of a temp directory; a leftover here never fails the run.
+ }
+ }
+}
diff --git a/HostsFileEditor.Core/Properties/Resources.resx b/HostsFileEditor.Core/Properties/Resources.resx
index 039c016..d5114da 100644
--- a/HostsFileEditor.Core/Properties/Resources.resx
+++ b/HostsFileEditor.Core/Properties/Resources.resx
@@ -68,6 +68,9 @@
Invalid Host Entries
+
+ Invalid host names
+
Hosts File Editor Question
diff --git a/HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj b/HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj
new file mode 100644
index 0000000..15da4df
--- /dev/null
+++ b/HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0-windows
+ true
+ false
+ false
+
+ win-x64
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HostsFileEditor.WinForm.Tests/HostsFilterTests.cs b/HostsFileEditor.WinForm.Tests/HostsFilterTests.cs
new file mode 100644
index 0000000..c27fd0e
--- /dev/null
+++ b/HostsFileEditor.WinForm.Tests/HostsFilterTests.cs
@@ -0,0 +1,74 @@
+using Shouldly;
+
+namespace HostsFileEditor.WinForm.Tests;
+
+///
+/// Tests the classic edition's Equin view filter, which must delegate the three filter rules to the
+/// single canonical predicate (issue #75) rather than restate
+/// them — so its Include result stays identical to the modern edition's for the same inputs.
+///
+[TestClass]
+public sealed class HostsFilterTests
+{
+ private static readonly HostsEntry Enabled = new("127.0.0.1 localhost");
+ private static readonly HostsEntry CommentOnly = new("# just a comment");
+ private static readonly HostsEntry Disabled = new("# 10.0.0.1 disabled.test"); // disabled entry, valid IP
+
+ [TestMethod]
+ public void Include_NoFilters_KeepsEverything()
+ {
+ var filter = new HostsFilter(() => string.Empty);
+
+ filter.Include(Enabled).ShouldBeTrue();
+ filter.Include(CommentOnly).ShouldBeTrue();
+ filter.Include(Disabled).ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void Include_HideComments_DropsCommentOnlyRows()
+ {
+ var filter = new HostsFilter(() => string.Empty) { Comments = true };
+
+ filter.Include(CommentOnly).ShouldBeFalse();
+ filter.Include(Enabled).ShouldBeTrue();
+ filter.Include(Disabled).ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void Include_HideDisabled_DropsDisabledEntriesButNotComments()
+ {
+ var filter = new HostsFilter(() => string.Empty) { Disabled = true };
+
+ filter.Include(Disabled).ShouldBeFalse();
+ filter.Include(CommentOnly).ShouldBeTrue();
+ filter.Include(Enabled).ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void Include_TextFilter_IsReadPerCall_AndMatchesCanonicalPredicate()
+ {
+ var text = "localhost";
+ var filter = new HostsFilter(() => text);
+
+ filter.Include(Enabled).ShouldBeTrue();
+ filter.Include(Disabled).ShouldBeFalse();
+
+ // The provider is read on each Include call, so a changed filter takes effect without re-wiring.
+ text = "disabled";
+ filter.Include(Enabled).ShouldBeFalse();
+ filter.Include(Disabled).ShouldBeTrue();
+ }
+
+ [TestMethod]
+ public void Include_HideCommentsAndDisabled_CombineWithConcreteExpectations()
+ {
+ // Both flags on: comment-only and disabled rows drop; only enabled entries matching the text
+ // survive. Concrete expected values (not a mirror of the predicate) so a wiring bug — e.g.
+ // swapping the Comments/Disabled flags — is actually caught.
+ var filter = new HostsFilter(() => "localhost") { Comments = true, Disabled = true };
+
+ filter.Include(Enabled).ShouldBeTrue(); // enabled + matches "localhost"
+ filter.Include(CommentOnly).ShouldBeFalse(); // hidden by Comments
+ filter.Include(Disabled).ShouldBeFalse(); // hidden by Disabled (and wouldn't match text anyway)
+ }
+}
diff --git a/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs b/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs
index dc7fbb2..7123e74 100644
--- a/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs
+++ b/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs
@@ -19,6 +19,9 @@
using System.Reflection;
using System.Resources;
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("HostsFileEditor.WinForm.Tests")]
[assembly: AssemblyDescription(
"Hosts File Editor makes it easy to change your hosts file as well as " +
diff --git a/HostsFileEditor.slnx b/HostsFileEditor.slnx
index 71afe19..c05f62c 100644
--- a/HostsFileEditor.slnx
+++ b/HostsFileEditor.slnx
@@ -22,6 +22,9 @@
+
+
+