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 @@