Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions SysManager/SysManager.Tests/HostsFileServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Backup / restore tests for <see cref="HostsFileService"/>. Uses the path-injection
/// constructor so the real System32 hosts file is never touched and no admin is needed.
/// </summary>
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<HostsEntry>
{
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<HostsEntry>
{
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<HostsEntry>
{
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 { }
}
}
}
44 changes: 38 additions & 6 deletions SysManager/SysManager/Services/HostsFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,23 @@
/// </summary>
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");

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.

private static readonly string BackupPath = HostsPath + ".bak";
private readonly string HostsPath;
private readonly string BackupPath;

/// <summary>
/// Creates the service against the real system hosts file. The optional
/// <paramref name="hostsPath"/> override exists for testing so the backup /
/// restore logic can be exercised without touching System32 or needing admin.
/// </summary>
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();
Expand Down Expand Up @@ -75,15 +87,24 @@
return entries;
}

/// <summary>True if a pristine pre-SysManager backup of the hosts file exists.</summary>
public bool HasBackup => File.Exists(BackupPath);

/// <summary>
/// Saves entries back to the hosts file. Disabled entries are written as commented lines.
/// Creates a backup before writing.
/// </summary>
/// <remarks>
/// 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 <see cref="RestoreBackup"/>.
/// </remarks>
public void SaveHosts(List<HostsEntry> 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<string>
{
Expand All @@ -104,6 +125,17 @@
File.WriteAllLines(HostsPath, lines);
}

/// <summary>
/// Restores the hosts file from the pristine backup created before SysManager
/// first modified it. Returns false if there is no backup to restore from.
/// </summary>
public bool RestoreBackup()
{
if (!File.Exists(BackupPath)) return false;
File.Copy(BackupPath, HostsPath, overwrite: true);
return true;
}

/// <summary>
/// Validates and adds a new entry. Throws <see cref="ArgumentException"/> on invalid input.
/// </summary>
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.19.4</Version>
<FileVersion>1.19.4.0</FileVersion>
<AssemblyVersion>1.19.4.0</AssemblyVersion>
<Version>1.20.0</Version>
<FileVersion>1.20.0.0</FileVersion>
<AssemblyVersion>1.20.0.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down
70 changes: 69 additions & 1 deletion SysManager/SysManager/ViewModels/DnsHostsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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();

Expand Down
3 changes: 3 additions & 0 deletions SysManager/SysManager/Views/DnsHostsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
<Button Content="Refresh" Command="{Binding RefreshHostsCommand}"
Style="{StaticResource SecondaryButton}" Padding="12,6" Margin="0,0,8,0"/>
<Button Content="Restore original" Command="{Binding RestoreHostsCommand}"
Style="{StaticResource SecondaryButton}" Padding="12,6" Margin="0,0,8,0"
AutomationProperties.Name="Restore original hosts file from backup"/>
<Button Content="Save" Command="{Binding SaveHostsCommand}"
Style="{StaticResource PrimaryButton}" Padding="18,6"/>
</StackPanel>
Expand Down
Loading