From d6f0b7cffcb950fb120a2ebbe43d047c540a60f6 Mon Sep 17 00:00:00 2001 From: laurentiu021 Date: Mon, 8 Jun 2026 13:48:36 +0300 Subject: [PATCH] feat: confirm + reversible DNS and hosts changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2b of the audit — network mutations now confirm and are reversible. - HostsFileService.SaveHosts no longer overwrites the backup on every save. Previously File.Copy(..., overwrite: true) ran each save, so after the first write hosts.bak already held SysManager's own output and the pristine original was gone. Now the backup is created only when none exists, preserving the true pre-SysManager file. Added HasBackup + RestoreBackup, and a path-injection constructor so backup/restore is unit-testable without admin or System32. - DnsHostsViewModel: ApplyDns and SaveHosts now require DialogService.Confirm, stating what changes and how to revert; declining makes no change. Added a RestoreHosts command + 'Restore original' button (with AutomationProperties.Name). Tests: HostsFileServiceTests covers pristine-backup preservation across saves, restore-to-original, and no-backup handling. --- ARCHITECTURE.md | 3 +- CHANGELOG.md | 12 +++ .../SysManager.Tests/HostsFileServiceTests.cs | 96 +++++++++++++++++++ .../SysManager/Services/HostsFileService.cs | 44 +++++++-- SysManager/SysManager/SysManager.csproj | 6 +- .../ViewModels/DnsHostsViewModel.cs | 70 +++++++++++++- SysManager/SysManager/Views/DnsHostsView.xaml | 3 + 7 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 SysManager/SysManager.Tests/HostsFileServiceTests.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3736ab9..7ae2c12 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -172,7 +172,8 @@ Key services: - `DnsService` — manages DNS server configuration via PowerShell `Set-DnsClientServerAddress` with preset support (Google, Cloudflare, etc.). - `HostsFileService` — parses and edits the Windows hosts file with - add/remove/toggle operations. + add/remove/toggle operations; keeps a one-time pristine backup and can + restore it (`HasBackup` / `RestoreBackup`). - `ContextMenuService` — scans and toggles Explorer context menu shell extensions via registry enumeration. - `SystemReportService` — generates comprehensive system info reports diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fdabbf..108dccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.20.0] - 2026-06-08 + +### Added +- **Restore original hosts file.** A new "Restore original" button on the DNS & Hosts tab reverts the system hosts file to the pristine backup taken before SysManager first modified it. + +### Fixed +- **Hosts file backup no longer destroys the pristine original.** `SaveHosts` previously copied the current hosts file over `hosts.bak` on **every** save with `overwrite: true`, so after the first save the backup already held SysManager's own output — the real original was lost and restore was impossible. The backup is now written only once (when none exists), preserving the true pre-SysManager file. +- **DNS and hosts changes now require confirmation.** Applying a DNS preset and overwriting the system hosts file each prompt with `DialogService.Confirm` first, stating exactly what will change and how to revert. Declining makes no system change. + +### Changed +- `HostsFileService` gained a path-injection constructor (used only for testing) and `HasBackup` / `RestoreBackup` members backing the new restore flow. + ## [1.19.4] - 2026-06-08 ### Fixed diff --git a/SysManager/SysManager.Tests/HostsFileServiceTests.cs b/SysManager/SysManager.Tests/HostsFileServiceTests.cs new file mode 100644 index 0000000..bd6a05f --- /dev/null +++ b/SysManager/SysManager.Tests/HostsFileServiceTests.cs @@ -0,0 +1,96 @@ +// SysManager · HostsFileServiceTests +// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager +// License: MIT + +using System.IO; +using SysManager.Models; +using SysManager.Services; + +namespace SysManager.Tests; + +/// +/// Backup / restore tests for . Uses the path-injection +/// constructor so the real System32 hosts file is never touched and no admin is needed. +/// +public class HostsFileServiceTests +{ + private static (HostsFileService svc, string hosts, string dir) NewServiceWithTempHosts(string initialContent) + { + var dir = Path.Combine(Path.GetTempPath(), "smtest_hosts_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var hosts = Path.Combine(dir, "hosts"); + File.WriteAllText(hosts, initialContent); + return (new HostsFileService(hosts), hosts, dir); + } + + [Fact] + public void SaveHosts_PreservesPristineOriginal_AcrossMultipleSaves() + { + var (svc, hosts, dir) = NewServiceWithTempHosts("# ORIGINAL pristine hosts\n127.0.0.1 original\n"); + var backup = hosts + ".bak"; + try + { + // First save backs up the pristine original. + svc.SaveHosts(new List + { + new() { IpAddress = "1.1.1.1", Hostname = "first", IsEnabled = true } + }); + Assert.True(File.Exists(backup)); + var backupAfterFirst = File.ReadAllText(backup); + Assert.Contains("ORIGINAL pristine hosts", backupAfterFirst); + + // Second save must NOT overwrite the backup with SysManager's own output. + svc.SaveHosts(new List + { + new() { IpAddress = "2.2.2.2", Hostname = "second", IsEnabled = true } + }); + var backupAfterSecond = File.ReadAllText(backup); + Assert.Equal(backupAfterFirst, backupAfterSecond); + Assert.Contains("ORIGINAL pristine hosts", backupAfterSecond); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + [Fact] + public void RestoreBackup_RestoresPristineOriginal() + { + const string original = "# ORIGINAL pristine hosts\n127.0.0.1 original\n"; + var (svc, hosts, dir) = NewServiceWithTempHosts(original); + try + { + svc.SaveHosts(new List + { + new() { IpAddress = "9.9.9.9", Hostname = "managed", IsEnabled = true } + }); + Assert.DoesNotContain("ORIGINAL pristine hosts", File.ReadAllText(hosts)); + + Assert.True(svc.HasBackup); + Assert.True(svc.RestoreBackup()); + + // After restore the file content matches the pristine original byte-for-byte. + Assert.Equal(original, File.ReadAllText(hosts)); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + [Fact] + public void RestoreBackup_NoBackup_ReturnsFalse() + { + var (svc, _, dir) = NewServiceWithTempHosts("127.0.0.1 localhost\n"); + try + { + Assert.False(svc.HasBackup); + Assert.False(svc.RestoreBackup()); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } +} diff --git a/SysManager/SysManager/Services/HostsFileService.cs b/SysManager/SysManager/Services/HostsFileService.cs index 2bbb826..1eabfdb 100644 --- a/SysManager/SysManager/Services/HostsFileService.cs +++ b/SysManager/SysManager/Services/HostsFileService.cs @@ -14,11 +14,23 @@ namespace SysManager.Services; /// public sealed partial class HostsFileService { - private static readonly string HostsPath = Path.Combine( + private static readonly string DefaultHostsPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.System), "drivers", "etc", "hosts"); - private static readonly string BackupPath = HostsPath + ".bak"; + private readonly string HostsPath; + private readonly string BackupPath; + + /// + /// Creates the service against the real system hosts file. The optional + /// override exists for testing so the backup / + /// restore logic can be exercised without touching System32 or needing admin. + /// + public HostsFileService(string? hostsPath = null) + { + HostsPath = hostsPath ?? DefaultHostsPath; + BackupPath = HostsPath + ".bak"; + } [GeneratedRegex(@"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")] private static partial Regex HostnameRegex(); @@ -75,15 +87,24 @@ public async Task> ReadHostsAsync(CancellationToken ct = defaul return entries; } + /// True if a pristine pre-SysManager backup of the hosts file exists. + public bool HasBackup => File.Exists(BackupPath); + /// /// Saves entries back to the hosts file. Disabled entries are written as commented lines. - /// Creates a backup before writing. /// + /// + /// The backup is created ONLY the first time (when no backup yet exists), so it + /// preserves the original pre-SysManager hosts file. Previously the backup was + /// overwritten on every save, which meant that after the first save the ".bak" + /// already contained SysManager's own output — losing the pristine original and + /// defeating . + /// public void SaveHosts(List entries) { - // Backup existing file - if (File.Exists(HostsPath)) - File.Copy(HostsPath, BackupPath, overwrite: true); + // Preserve the pristine original: back up only if we have never backed up before. + if (File.Exists(HostsPath) && !File.Exists(BackupPath)) + File.Copy(HostsPath, BackupPath, overwrite: false); var lines = new List { @@ -104,6 +125,17 @@ public void SaveHosts(List entries) File.WriteAllLines(HostsPath, lines); } + /// + /// Restores the hosts file from the pristine backup created before SysManager + /// first modified it. Returns false if there is no backup to restore from. + /// + public bool RestoreBackup() + { + if (!File.Exists(BackupPath)) return false; + File.Copy(BackupPath, HostsPath, overwrite: true); + return true; + } + /// /// Validates and adds a new entry. Throws on invalid input. /// diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index ba67621..40ee42e 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -10,9 +10,9 @@ SysManager true NU1603;NU1701 - 1.19.4 - 1.19.4.0 - 1.19.4.0 + 1.20.0 + 1.20.0.0 + 1.20.0.0 SysManager SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup. https://github.com/laurentiu021/SystemManager diff --git a/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs b/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs index 19d0112..cb5c05d 100644 --- a/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs +++ b/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs @@ -120,6 +120,16 @@ private async Task ApplyDnsAsync() return; } + if (!DialogService.Instance.Confirm( + $"Change this PC's DNS servers to {SelectedPreset.Name} " + + $"({SelectedPreset.Primary}, {SelectedPreset.Secondary})?\n\n" + + "You can revert any time with \"Reset to automatic (DHCP)\".", + "Confirm DNS Change")) + { + StatusMessage = "DNS change cancelled."; + return; + } + IsDnsApplying = true; StatusMessage = $"Applying {SelectedPreset.Name} DNS..."; try @@ -214,10 +224,20 @@ private void SaveHosts() return; } + if (!DialogService.Instance.Confirm( + $"Overwrite the system hosts file with these {HostEntries.Count} entries?\n\n" + + "The original hosts file is preserved as hosts.bak (only the first time) " + + "and can be restored with \"Restore original\".", + "Confirm Hosts File Change")) + { + HostsStatus = "Save cancelled."; + return; + } + try { _hostsService.SaveHosts(HostEntries.ToList()); - HostsStatus = $"Saved {HostEntries.Count} entries. Backup created at hosts.bak."; + HostsStatus = $"Saved {HostEntries.Count} entries. Original preserved at hosts.bak."; Log.Information("Hosts file saved with {Count} entries", HostEntries.Count); } catch (UnauthorizedAccessException) @@ -231,6 +251,54 @@ private void SaveHosts() } } + [RelayCommand] + private async Task RestoreHostsAsync() + { + if (!IsElevated) + { + HostsStatus = "Restoring the hosts file requires administrator privileges."; + return; + } + + if (!_hostsService.HasBackup) + { + HostsStatus = "No backup found — nothing to restore."; + return; + } + + if (!DialogService.Instance.Confirm( + "Restore the original hosts file from backup? Your current SysManager " + + "changes to the hosts file will be discarded.", + "Confirm Restore Hosts File")) + { + HostsStatus = "Restore cancelled."; + return; + } + + try + { + if (_hostsService.RestoreBackup()) + { + await LoadHostsAsync(); + HostsStatus = "Original hosts file restored from backup."; + Log.Information("Hosts file restored from backup"); + } + else + { + HostsStatus = "No backup found — nothing to restore."; + } + } + catch (UnauthorizedAccessException) + { + HostsStatus = "Access denied — run as administrator to restore hosts file."; + } + catch (IOException ex) + { + HostsStatus = $"Error restoring hosts file: {ex.Message}"; + Log.Error(ex, "Failed to restore hosts file"); + } + } + [RelayCommand] private Task RefreshHostsAsync() => LoadHostsAsync(); diff --git a/SysManager/SysManager/Views/DnsHostsView.xaml b/SysManager/SysManager/Views/DnsHostsView.xaml index 2cac617..161ab11 100644 --- a/SysManager/SysManager/Views/DnsHostsView.xaml +++ b/SysManager/SysManager/Views/DnsHostsView.xaml @@ -100,6 +100,9 @@