diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 3535ffa..66e940a 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,29 +1,179 @@ # dns-helper Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-03-03 +## Project Overview -## Active Technologies +dns-helper — a Go CLI tool that resolves hostnames against a specified DNS server and writes the resulting IP-to-hostname mappings into the local hosts file. It manages a clearly delimited block within the hosts file so it can cleanly add and remove only its own entries. Built as a single static binary with no runtime dependencies. -- Go 1.20 (maximum; currently go.mod says 1.18, must be updated to 1.20) + Go standard library, `golang.org/x/net/dns/dnsmessage` (replacing `github.com/miekg/dns`) (001-safety-reliability-refactor) +## Go Toolchain — CRITICAL + +**Always use `go1.20` instead of `go` for ALL Go toolchain operations.** + +The system default `go` command may be a newer version that is incompatible with this project. + +```powershell +# CORRECT — use these commands: +go1.20 build . +go1.20 test ./... +go1.20 vet ./... +go1.20 fmt ./... + +# WRONG — do NOT use bare 'go': +go build . # May use incompatible Go version +go test ./... # May use incompatible Go version +``` + +**Go 1.21+ features that MUST NOT be used:** +- `min`, `max`, `clear` builtins +- Range-over-int (`for i := range 10`) +- `slices`, `maps`, `cmp` packages +- `log/slog` package +- Any other Go 1.21+ standard library additions + +## Building + +```powershell +# Standard debug build for Windows +.\build.ps1 + +# Optimized release build for all platforms +.\build.ps1 -Release + +# Clean build +.\build.ps1 -Clean + +# Cross-compile for all platforms +.\build.ps1 -CrossCompile + +# Create release packages (requires binaries already built) +.\build.ps1 -Release -Package +``` + +Output: `build\dns-helper.exe` (default), `build\dns-helper--[.exe]` (cross-compile). +Release packages: `releases\dns-helper---.zip` / `.tar.gz` + +## Running dns-helper + +dns-helper has two commands: `add` and `delete` (with short aliases `a` and `d`/`del`). + +```powershell +# Add one or more hostnames (resolves via the given DNS server, writes to hosts file) +.\build\dns-helper.exe add -host hostname.example.com -server dns.example.com +.\build\dns-helper.exe a -host hostname.example.com -server dns.example.com + +# Add multiple hostnames at once (comma-separated) +.\build\dns-helper.exe add -host "host1.example.com,host2.example.com" -server dns.example.com + +# Delete a specific hostname from the managed block +.\build\dns-helper.exe delete -host hostname.example.com +.\build\dns-helper.exe del -host hostname.example.com +.\build\dns-helper.exe d -host hostname.example.com + +# Delete multiple hostnames (comma-separated) +.\build\dns-helper.exe delete -host "host1.example.com,host2.example.com" + +# Delete ALL entries added by dns-helper +.\build\dns-helper.exe delete all +.\build\dns-helper.exe delete a +``` + +### Flag Reference + +| Command | Flag | Required | Description | +|---------|------|----------|-------------| +| `add` | `-host` | Yes | Comma-separated list of hostnames to resolve and add | +| `add` | `-server` | Yes | DNS resolver to query (e.g. `dns.example.com`) | +| `delete` | `-host` | Yes (unless `all`) | Comma-separated list of hostnames to remove | +| `delete all` | — | — | Removes every entry dns-helper has written | + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Error or partial failure (e.g. one hostname failed to resolve) | + +### Hosts File Management + +dns-helper wraps its entries between two marker lines: + +``` +# DNSHelper <<-> START CONFIG +192.0.2.1 hostname.example.com +# DNSHelper <->> END CONFIG +``` + +Only lines within this managed block are ever modified or removed. The rest of the hosts file is preserved exactly. A backup of the hosts file is written to the executable's directory before every write. + +## Testing + +```powershell +# Run all tests +go1.20 test ./... + +# Run with coverage +go1.20 test -cover ./... + +# Run a specific package +go1.20 test ./hostfile/... +go1.20 test ./resolver/... +go1.20 test ./lockfile/... +go1.20 test ./platform/... + +# Vet all code +go1.20 vet ./... +``` ## Project Structure -```text -src/ -tests/ ``` - -## Commands - -# Add commands for Go 1.20 (maximum; currently go.mod says 1.18, must be updated to 1.20) +main.go CLI entry point (add / delete commands, flag parsing) +hostfile/ + hostfile.go Hosts file parsing, DNSEntry type, add/remove helpers + managed.go Managed block read/write logic + filesystem.go FileSystem interface + OSFileSystem implementation + hostfile_test.go + managed_test.go +lockfile/ + lockfile.go File-based mutex to prevent concurrent modifications + lockfile_test.go +platform/ + platform.go Platform interface — GetHostsFilePath() + platform_windows.go Windows implementation + platform_darwin.go macOS implementation + platform_linux.go Linux implementation + platform_test.go +resolver/ + resolver.go DNS resolution via golang.org/x/net/dns/dnsmessage + resolver_test.go +``` ## Code Style -Go 1.20 (maximum; currently go.mod says 1.18, must be updated to 1.20): Follow standard conventions +- **Go 1.20 only** — no Go 1.21+ features +- **Standard library first** — only `golang.org/x/*` dependencies allowed; no `github.com/*` third-party packages +- **Error wrapping**: `fmt.Errorf("context: %w", err)` +- **CGO disabled**: `CGO_ENABLED=0` for static binaries +- **Table-driven tests** preferred -## Recent Changes +## Dependencies -- 001-safety-reliability-refactor: Added Go 1.20 (maximum; currently go.mod says 1.18, must be updated to 1.20) + Go standard library, `golang.org/x/net/dns/dnsmessage` (replacing `github.com/miekg/dns`) +From `go.mod`: +- `golang.org/x/net` — DNS message parsing (`golang.org/x/net/dns/dnsmessage`) - - +## After Every Implementation + +After completing any code change, validate in this order: + +1. Run tests for changed packages: + ```powershell + go1.20 test ./hostfile/... ./resolver/... # example + ``` +2. Run the full suite: + ```powershell + go1.20 test ./... + ``` +3. Build the project: + ```powershell + .\build.ps1 + ``` +4. Report test pass/fail counts and build outcome before considering the task complete. diff --git a/.gitignore b/.gitignore index 0c97a15..59c2fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,23 @@ # Build outputs +build/ bin/ +*.exe +*.out +*.test + +# Dependencies +vendor/ # IDE files -.idea/ \ No newline at end of file +.idea/ +.vscode/ + +# OS files +.DS_Store +Thumbs.db + +# Temp and logs +*.tmp +*.swp +*.log +.env* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index d454aa6..8330e4c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,8 @@ }, "chat.tools.terminal.autoApprove": { ".specify/scripts/bash/": true, - ".specify/scripts/powershell/": true + ".specify/scripts/powershell/": true, + "&": true } } diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8e426fb --- /dev/null +++ b/build.ps1 @@ -0,0 +1,303 @@ +# Build script for dns-helper +# Builds the executables using the go1.20 toolchain +# +# USAGE: +# .\build.ps1 # Standard build for current platform (Windows amd64) +# .\build.ps1 -Release # Build for all platforms (no debug symbols) +# .\build.ps1 -Clean # Clean build (removes old files first) +# .\build.ps1 -CrossCompile # Build for all platforms +# .\build.ps1 -Package # Create release packages (ZIP / tar.gz) +# .\build.ps1 -Release -Package # Full release: optimized build + packages +# +# OUTPUT: +# - Default build: build\dns-helper.exe +# - Cross-compiled: build\dns-helper--[.exe] +# - Release packages: releases\dns-helper---.zip / .tar.gz + +param( + [string]$OutputDir = "build", + [string]$OutputName = "dns-helper", + [switch]$Release, + [switch]$Clean, + [switch]$CrossCompile, + [switch]$Package +) + +$ErrorActionPreference = "Stop" + +# Go binary — use go1.20 toolchain +$GoCmd = "go1.20" + +# Get version information from git or use defaults +$gitHash = "unknown" +$gitTag = "dev" + +$prevErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" + +try { + $gitHashResult = git rev-parse --short HEAD 2>&1 + if ($LASTEXITCODE -eq 0 -and $gitHashResult -and $gitHashResult -notmatch "fatal|error") { + $gitHash = $gitHashResult.Trim() + } + + $gitTagResult = git describe --tags --always --dirty 2>&1 + if ($LASTEXITCODE -eq 0 -and $gitTagResult -and $gitTagResult -notmatch "fatal|error") { + $gitTag = $gitTagResult.Trim() + } +} catch { + # Git not available or not a git repository — use defaults +} + +$ErrorActionPreference = $prevErrorActionPreference + +$buildTime = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ" + +Write-Host "============================================" -ForegroundColor Cyan +Write-Host " dns-helper Build" -ForegroundColor Cyan +Write-Host "============================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Version: $gitTag" -ForegroundColor Gray +Write-Host " Commit: $gitHash" -ForegroundColor Gray +Write-Host " Time: $buildTime" -ForegroundColor Gray +Write-Host "" + +# Clean output directory if requested +if ($Clean) { + Write-Host "Cleaning output directory..." -ForegroundColor Yellow + if (Test-Path $OutputDir) { + Remove-Item -Path $OutputDir -Recurse -Force + } + Write-Host "Cleaning Go caches..." -ForegroundColor Yellow + & $GoCmd clean -cache -testcache 2>$null +} + +# Ensure output directory exists +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir | Out-Null + Write-Host "Created output directory: $OutputDir" -ForegroundColor Green +} + +# Build flags +$ldflags = "" +if ($Release) { + $ldflags = "-s -w" + Write-Host "Building RELEASE version (optimized, no debug symbols)..." -ForegroundColor Green +} else { + Write-Host "Building DEBUG version (with debug symbols)..." -ForegroundColor Yellow +} + +# Disable CGO for static binaries +$env:CGO_ENABLED = "0" + +$platforms = @( + @{ GOOS = "windows"; GOARCH = "386"; Suffix = ".exe" }, + @{ GOOS = "windows"; GOARCH = "amd64"; Suffix = ".exe" }, + @{ GOOS = "linux"; GOARCH = "amd64"; Suffix = "" }, + @{ GOOS = "linux"; GOARCH = "arm64"; Suffix = "" }, + @{ GOOS = "darwin"; GOARCH = "amd64"; Suffix = "" }, + @{ GOOS = "darwin"; GOARCH = "arm64"; Suffix = "" } +) + +function Build-Binary { + param( + [string]$GOOS, + [string]$GOARCH, + [string]$OutputPath + ) + + $env:GOOS = $GOOS + $env:GOARCH = $GOARCH + + if ($ldflags) { + & $GoCmd build -ldflags $ldflags -o $OutputPath . + } else { + & $GoCmd build -o $OutputPath . + } + + return $LASTEXITCODE +} + +try { + if ($CrossCompile -or $Release) { + Write-Host "Cross-compiling for all platforms..." -ForegroundColor Cyan + Write-Host "" + + foreach ($platform in $platforms) { + $outputFile = "$OutputName-$($platform.GOOS)-$($platform.GOARCH)$($platform.Suffix)" + $outputPath = Join-Path $OutputDir $outputFile + + Write-Host " Building $outputFile..." -ForegroundColor Gray + + $result = Build-Binary -GOOS $platform.GOOS -GOARCH $platform.GOARCH -OutputPath $outputPath + + if ($result -eq 0) { + $fileSizeMB = [math]::Round((Get-Item $outputPath).Length / 1MB, 2) + Write-Host " [OK] $fileSizeMB MB" -ForegroundColor Green + } else { + Write-Host " [FAILED]" -ForegroundColor Red + exit $result + } + } + + Write-Host "" + Write-Host "Cross-compilation complete!" -ForegroundColor Green + + } else { + $outputPath = Join-Path $OutputDir "$OutputName.exe" + + Write-Host "Compiling for Windows (amd64)..." -ForegroundColor Cyan + + $result = Build-Binary -GOOS "windows" -GOARCH "amd64" -OutputPath $outputPath + + if ($result -eq 0) { + $fileSizeMB = [math]::Round((Get-Item $outputPath).Length / 1MB, 2) + Write-Host "Build successful!" -ForegroundColor Green + Write-Host " Output: $outputPath" -ForegroundColor Gray + Write-Host " Size: $fileSizeMB MB" -ForegroundColor Gray + } else { + Write-Host "Build failed with exit code: $result" -ForegroundColor Red + exit $result + } + } +} catch { + Write-Host "Build error: $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" +Write-Host "Build complete!" -ForegroundColor Green +Write-Host "" +Write-Host "Usage:" -ForegroundColor Cyan +Write-Host " .\$OutputDir\$OutputName.exe --help" -ForegroundColor White +Write-Host " .\$OutputDir\$OutputName.exe add " -ForegroundColor White + +# Create release packages if requested +if ($Package) { + Write-Host "" + Write-Host "============================================" -ForegroundColor Cyan + Write-Host " Creating Release Packages" -ForegroundColor Cyan + Write-Host "============================================" -ForegroundColor Cyan + Write-Host "" + + $releasesDir = "releases" + if (-not (Test-Path $releasesDir)) { + New-Item -ItemType Directory -Path $releasesDir | Out-Null + Write-Host "Created releases directory: $releasesDir" -ForegroundColor Green + } + + foreach ($platform in $platforms) { + $platformName = "$($platform.GOOS)-$($platform.GOARCH)" + $packageName = "$OutputName-$gitTag-$platformName" + $packageDir = Join-Path $releasesDir $packageName + + $isWindowsPlatform = $platform.GOOS -eq "windows" + if ($isWindowsPlatform) { + $archivePath = "$packageDir.zip" + $exeName = "$OutputName.exe" + } else { + $archivePath = "$packageDir.tar.gz" + $exeName = $OutputName + } + + Write-Host "Creating package for $platformName..." -ForegroundColor Cyan + + if (Test-Path $packageDir) { + Remove-Item -Path $packageDir -Recurse -Force + } + if (Test-Path $archivePath) { + try { + Remove-Item -Path $archivePath -Force -ErrorAction Stop + } catch { + Write-Host " Warning: Could not remove old package (file may be in use)" -ForegroundColor Yellow + continue + } + } + + New-Item -ItemType Directory -Path $packageDir | Out-Null + + # Copy the binary + $binaryFile = "$OutputName-$platformName$($platform.Suffix)" + $binaryPath = Join-Path $OutputDir $binaryFile + if (-not (Test-Path $binaryPath)) { + Write-Host " Error: Binary not found at $binaryPath. Run build first." -ForegroundColor Red + continue + } + Copy-Item -Path $binaryPath -Destination (Join-Path $packageDir $exeName) + + # Copy README if present + if (Test-Path "README.md") { + Copy-Item -Path "README.md" -Destination (Join-Path $packageDir "README.md") + } + + # VERSION.txt + @" +dns-helper Version Information +=============================== + +Version: $gitTag +Git Commit: $gitHash +Build Time: $buildTime +Platform: $platformName + +Run '$exeName --help' for usage information. +"@ | Out-File -FilePath (Join-Path $packageDir "VERSION.txt") -Encoding UTF8 + + # CHECKSUMS.txt + $exeHash = (Get-FileHash -Path (Join-Path $packageDir $exeName) -Algorithm SHA256).Hash + @" +dns-helper Release Checksums +Version: $gitTag +Build: $gitHash +Platform: $platformName + +SHA256 Checksums: +----------------- +$exeName + $exeHash + +To verify (PowerShell): + `$hash = (Get-FileHash -Path $exeName -Algorithm SHA256).Hash + if (`$hash -eq "$exeHash") { Write-Host "Verified!" -ForegroundColor Green } + +To verify (Linux/macOS): + echo "$exeHash $exeName" | sha256sum -c - +"@ | Out-File -FilePath (Join-Path $packageDir "CHECKSUMS.txt") -Encoding UTF8 + + # Create archive + try { + if ($isWindowsPlatform) { + Compress-Archive -Path "$packageDir\*" -DestinationPath $archivePath -CompressionLevel Optimal -Force + } else { + Push-Location $releasesDir + try { + tar -czf "$packageName.tar.gz" -C . $packageName 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host " Warning: tar not available, using ZIP format" -ForegroundColor Yellow + $archivePath = "$packageDir.zip" + Compress-Archive -Path "$packageDir\*" -DestinationPath $archivePath -CompressionLevel Optimal -Force + } + } finally { + Pop-Location + } + } + + $archiveSizeMB = [math]::Round((Get-Item $archivePath).Length / 1MB, 2) + Remove-Item -Path $packageDir -Recurse -Force + Write-Host " [OK] $archivePath ($archiveSizeMB MB)" -ForegroundColor Green + + } catch { + Write-Host " Error creating package: $_" -ForegroundColor Red + if (Test-Path $packageDir) { Remove-Item -Path $packageDir -Recurse -Force } + } + } + + Write-Host "" + Write-Host "Release packaging complete!" -ForegroundColor Green + Write-Host "" + Write-Host "Package contents:" -ForegroundColor Cyan + Write-Host " - dns-helper executable" -ForegroundColor Gray + Write-Host " - README.md (if present)" -ForegroundColor Gray + Write-Host " - VERSION.txt" -ForegroundColor Gray + Write-Host " - CHECKSUMS.txt (integrity verification)" -ForegroundColor Gray +} diff --git a/bump-version.ps1 b/bump-version.ps1 new file mode 100644 index 0000000..5926f22 --- /dev/null +++ b/bump-version.ps1 @@ -0,0 +1,147 @@ +# Bump Version Script for dns-helper +# Automatically increments version based on semantic versioning +# +# USAGE: +# .\bump-version.ps1 -Bump patch # 1.0.0 -> 1.0.1 (creates and pushes git tag) +# .\bump-version.ps1 -Bump minor # 1.0.1 -> 1.1.0 (creates and pushes git tag) +# .\bump-version.ps1 -Bump major # 1.1.0 -> 2.0.0 (creates and pushes git tag) +# .\bump-version.ps1 -Bump patch -NoPush # Create tag locally only, don't push +# .\bump-version.ps1 -Bump patch -DryRun # Display only, don't create tag +# +# EXAMPLES: +# # Bug fix release +# .\bump-version.ps1 -Bump patch +# +# # New feature release +# .\bump-version.ps1 -Bump minor +# +# # Breaking change release +# .\bump-version.ps1 -Bump major +# +# # Preview what would happen +# .\bump-version.ps1 -Bump minor -DryRun + +param( + [Parameter(Mandatory=$true)] + [ValidateSet('major','minor','patch')] + [string]$Bump, + + [Parameter(Mandatory=$false)] + [switch]$NoPush, + + [Parameter(Mandatory=$false)] + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" + +Write-Host "============================================" -ForegroundColor Cyan +Write-Host " dns-helper" -ForegroundColor Cyan +Write-Host " Version Bump" -ForegroundColor Cyan +Write-Host "============================================" -ForegroundColor Cyan +Write-Host "" + +# Get current version from git tags +Write-Host "Fetching current version from git tags..." -ForegroundColor Gray +$currentTag = git describe --tags --abbrev=0 2>$null + +if (-not $currentTag) { + $currentTag = "v0.0.0" + Write-Host "No existing tags found. Starting from v0.0.0" -ForegroundColor Yellow +} + +Write-Host "Current version: $currentTag" -ForegroundColor Cyan + +# Parse version (remove 'v' prefix if present) +$version = $currentTag -replace '^v', '' +$parts = $version -split '\.' + +if ($parts.Count -ne 3) { + Write-Host "Error: Invalid version format '$currentTag'. Expected format: v1.2.3" -ForegroundColor Red + exit 1 +} + +$major = [int]$parts[0] +$minor = [int]$parts[1] +$patch = [int]$parts[2] + +# Bump version based on type +Write-Host "Bumping $Bump version..." -ForegroundColor Gray + +switch ($Bump) { + 'major' { + $major++ + $minor = 0 + $patch = 0 + Write-Host " Major version bump (breaking changes)" -ForegroundColor Yellow + } + 'minor' { + $minor++ + $patch = 0 + Write-Host " Minor version bump (new features)" -ForegroundColor Green + } + 'patch' { + $patch++ + Write-Host " Patch version bump (bug fixes)" -ForegroundColor Blue + } +} + +$newVersion = "v$major.$minor.$patch" + +Write-Host "" +Write-Host "Version Update:" -ForegroundColor White +Write-Host " From: $currentTag" -ForegroundColor Red +Write-Host " To: $newVersion" -ForegroundColor Green +Write-Host "" + +# Create git tag unless dry run +if ($DryRun) { + Write-Host "[DRY RUN] Would create git tag: $newVersion" -ForegroundColor Yellow + Write-Host "Run without -DryRun to create the tag." -ForegroundColor Gray +} else { + Write-Host "Creating git tag $newVersion..." -ForegroundColor Gray + + # Check for uncommitted changes + $status = git status --porcelain + if ($status) { + Write-Host "Warning: You have uncommitted changes:" -ForegroundColor Yellow + Write-Host $status -ForegroundColor Yellow + Write-Host "" + $response = Read-Host "Continue with tag creation? (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Host "Tag creation cancelled." -ForegroundColor Red + exit 1 + } + } + + # Create the tag + try { + git tag -a $newVersion -m "Release $newVersion" + Write-Host "[OK] Tag created: $newVersion" -ForegroundColor Green + + # Push unless -NoPush specified + if (-not $NoPush) { + Write-Host "Pushing tag to origin..." -ForegroundColor Gray + git push origin $newVersion + Write-Host "[OK] Tag pushed to origin" -ForegroundColor Green + } else { + Write-Host "" + Write-Host "Tag created locally. To push it, run:" -ForegroundColor Yellow + Write-Host " git push origin $newVersion" -ForegroundColor White + } + } + catch { + Write-Host "Error creating/pushing tag: $_" -ForegroundColor Red + exit 1 + } +} + +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Cyan +Write-Host " 1. Build release: .\build.ps1 -Release -Package" -ForegroundColor White +Write-Host " 2. Test the build: .\build\dns-helper.exe --help" -ForegroundColor White +Write-Host "" + +# Output the new version for CI/CD pipelines +Write-Host "NEW_VERSION=$newVersion" -ForegroundColor Magenta +Write-Output $newVersion diff --git a/dataprep.go b/dataprep.go deleted file mode 100644 index d543ad8..0000000 --- a/dataprep.go +++ /dev/null @@ -1,133 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "strings" -) - -var startPattern = "DNSHelper <<-> START CONFIG" -var endPattern = "DNSHelper <->> END CONFIG" - -func extractLinesToEdit(data *workingData) error { - startIndex := 0 - endIndex := 0 - for i, line := range data.DefaultContent { - if strings.Contains(line, startPattern) { - startIndex = i - continue - } - if strings.Contains(line, endPattern) { - endIndex = i - break - } - } - var fileModified bool - var fileCorrupted bool - switch { - case startIndex == 0 && endIndex == 0: - fileModified = false - fileCorrupted = false - case startIndex > 0 && endIndex > startIndex: - fileModified = true - fileCorrupted = false - default: - fileModified = true - fileCorrupted = true - } - - if fileCorrupted == true { - return errors.New("content has been corrupted") - } - - if fileModified == true { - data.PrefixContent = data.DefaultContent[:startIndex] - data.PostfixContent = data.DefaultContent[endIndex+1:] - data.ExistingContent = data.DefaultContent[startIndex+1 : endIndex] - } else { - data.PrefixContent = data.DefaultContent - data.PostfixContent = make([]string, 0) - data.ExistingContent = make([]string, 0) - } - data.NewContent = make([]string, 0) - //fmt.Println("PrefixContent:") - //for _, line := range data.PrefixContent { - // fmt.Println(line) - //} - //fmt.Println("ExistingContent:") - //for _, line := range data.ExistingContent { - // fmt.Println(line) - //} - //fmt.Println("PostfixContent:") - //for _, line := range data.PostfixContent { - // fmt.Println(line) - //} - return nil -} - -func removeHostsFromExistingContent(data *workingData) { - var updatedContent []string - for _, host := range data.Hosts { - for _, line := range data.ExistingContent { - if !strings.Contains(line, host) { - updatedContent = append(updatedContent, line) - } - } - } - data.NewContent = updatedContent -} - -func addHostsToWorkingData(data *workingData, hosts *string) { - hostToAddList := strings.Split(*hosts, ",") - var newHosts []string - for _, host := range hostToAddList { - newHosts = append(newHosts, host) - } - data.Hosts = newHosts -} - -func addResolversToWorkingData(data *workingData, resolvers *string) { - resolverToAddList := strings.Split(*resolvers, ",") - var newResolvers []string - for _, resolver := range resolverToAddList { - newResolvers = append(newResolvers, resolver) - } - data.Resolvers = newResolvers -} - -func addNameToIPMappingsToNewContent(data *workingData) { - for _, host := range data.Hosts { - for _, resolver := range data.Resolvers { - ips := lookupIP(host, resolver) - if len(ips) > 0 { - for _, ip := range ips { - data.NewContent = append(data.NewContent, fmt.Sprintf("%s\t%s", ip, host)) - } - } - } - } -} - -func slicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func dedupeSlice(slice []string) []string { - newSlice := make([]string, 0, len(slice)) - seen := make(map[string]bool) - for _, value := range slice { - if !seen[value] { - seen[value] = true - newSlice = append(newSlice, value) - } - } - return newSlice -} diff --git a/dns.go b/dns.go deleted file mode 100644 index b07f1f3..0000000 --- a/dns.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import ( - "fmt" - "github.com/miekg/dns" -) - -func lookupIP(hostname string, resolver string) []string { - c := dns.Client{} - m := dns.Msg{} - m.SetQuestion(dns.Fqdn(hostname), dns.TypeA) - r, _, err := c.Exchange(&m, resolver+":53") - if err != nil { - return nil - } - if r.Rcode != dns.RcodeSuccess { - fmt.Println("Error: querying " + resolver + " for A record of " + hostname + " failed with code " + dns.RcodeToString[r.Rcode]) - return nil - } - ips := make([]string, 0) - for _, ans := range r.Answer { - if rec, ok := ans.(*dns.A); ok { - ips = append(ips, rec.A.String()) - } - } - - if len(ips) > 0 { - return ips - } - - c = dns.Client{} - m = dns.Msg{} - m.SetQuestion(dns.Fqdn(hostname), dns.TypeCNAME) - r, _, err = c.Exchange(&m, resolver+":53") - if err != nil { - return nil - } - if r.Rcode != dns.RcodeSuccess { - fmt.Println("Error: querying " + resolver + " for CNAME record of " + hostname + " failed with code " + dns.RcodeToString[r.Rcode]) - return nil - } - for _, ans := range r.Answer { - if rec, ok := ans.(*dns.CNAME); ok { - ips = lookupIP(rec.Target, resolver) - for _, ip := range ips { - ips = append(ips, ip) - } - } - } - - if len(ips) > 0 { - return ips - } - - fmt.Println("Error: no IP addresses found for " + hostname) - return nil -} diff --git a/fileoperations.go b/fileoperations.go deleted file mode 100644 index 2ba8a95..0000000 --- a/fileoperations.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "bufio" - "os" -) - -func fileExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - if os.IsNotExist(err) { - return false, nil - } - return false, err -} - -func getFileContentLines(path string) ([]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - var lines []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - if err := scanner.Err(); err != nil { - return nil, err - } - return lines, nil -} - -func writeOverContentInFile(path string, lines []string) error { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return err - } - defer file.Close() - writer := bufio.NewWriter(file) - for _, line := range lines { - _, err := writer.WriteString(line + "\n") - if err != nil { - return err - } - } - err = writer.Flush() - if err != nil { - return err - } - return nil -} - -func writeHostsFile(data *workingData) error { - var newFileContent []string - newFileContent = append(newFileContent, data.PrefixContent...) - if len(data.NewContent) > 0 { - data.NewContent = dedupeSlice(data.NewContent) - newFileContent = append(newFileContent, "# "+startPattern) - newFileContent = append(newFileContent, data.NewContent...) - newFileContent = append(newFileContent, "# "+endPattern) - } - newFileContent = append(newFileContent, data.PostfixContent...) - if slicesEqual(newFileContent, data.DefaultContent) == false { - err := writeOverContentInFile(data.HostsFileLocation, newFileContent) - if err != nil { - return err - } - } - return nil -} diff --git a/fileoperations_darwin.go b/fileoperations_darwin.go deleted file mode 100644 index 5922e75..0000000 --- a/fileoperations_darwin.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build darwin - -package main - -import "errors" - -func getHostsFileLocation() (string, error) { - var hostsFileLocation string - hostsFileLocation = "/etc/hosts" - exists, err := fileExists(hostsFileLocation) - if err != nil { - term(err) - } - if exists == false { - term(errors.New("hosts file does not exist at " + hostsFileLocation)) - } - return hostsFileLocation, nil -} diff --git a/fileoperations_linux.go b/fileoperations_linux.go deleted file mode 100644 index c85b8d4..0000000 --- a/fileoperations_linux.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build linux - -package main - -import "errors" - -func getHostsFileLocation() (string, error) { - var hostsFileLocation string - hostsFileLocation = "/etc/hosts" - exists, err := fileExists(hostsFileLocation) - if err != nil { - term(err) - } - if exists == false { - term(errors.New("hosts file does not exist at " + hostsFileLocation)) - } - return hostsFileLocation, nil -} diff --git a/fileoperations_windows.go b/fileoperations_windows.go deleted file mode 100644 index 96dd429..0000000 --- a/fileoperations_windows.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build windows - -package main - -import ( - "errors" - "os" -) - -func getHostsFileLocation() (string, error) { - //return "hosts", nil - var hostsFileLocation string - value, envExists := os.LookupEnv("SystemRoot") - if envExists { - hostsFileLocation = value + "\\System32\\drivers\\etc\\hosts" - } else { - hostsFileLocation = "C:\\Windows\\System32\\drivers\\etc\\hosts" - } - exists, err := fileExists(hostsFileLocation) - if err != nil { - return "", err - } - if exists == false { - return "", errors.New("file does not exist: " + hostsFileLocation) - } - return hostsFileLocation, nil -} diff --git a/go.mod b/go.mod index fdb2df1..847b3fa 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,5 @@ module dns-helper -go 1.18 +go 1.20 -require ( - github.com/miekg/dns v1.1.59 // indirect - golang.org/x/mod v0.16.0 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/tools v0.19.0 // indirect -) +require golang.org/x/net v0.35.0 diff --git a/go.sum b/go.sum index 31f195d..f4761f9 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,2 @@ -github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= -github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= diff --git a/hostfile/filesystem.go b/hostfile/filesystem.go new file mode 100644 index 0000000..7ea042e --- /dev/null +++ b/hostfile/filesystem.go @@ -0,0 +1,31 @@ +package hostfile + +import "os" + +// FileSystem abstracts filesystem operations for testability. +type FileSystem interface { + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte, perm os.FileMode) error + Stat(path string) (os.FileInfo, error) + CreateTemp(dir, pattern string) (*os.File, error) + Rename(oldpath, newpath string) error + Remove(path string) error + Chmod(path string, mode os.FileMode) error +} + +// OSFileSystem implements FileSystem using real OS calls. +type OSFileSystem struct{} + +func (OSFileSystem) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } +func (OSFileSystem) WriteFile(path string, data []byte, perm os.FileMode) error { + return os.WriteFile(path, data, perm) +} +func (OSFileSystem) Stat(path string) (os.FileInfo, error) { return os.Stat(path) } +func (OSFileSystem) CreateTemp(dir, pattern string) (*os.File, error) { + return os.CreateTemp(dir, pattern) +} +func (OSFileSystem) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } +func (OSFileSystem) Remove(path string) error { return os.Remove(path) } +func (OSFileSystem) Chmod(path string, mode os.FileMode) error { + return os.Chmod(path, mode) +} diff --git a/hostfile/hostfile.go b/hostfile/hostfile.go new file mode 100644 index 0000000..f0f9514 --- /dev/null +++ b/hostfile/hostfile.go @@ -0,0 +1,201 @@ +package hostfile + +import ( + "fmt" + "math/rand" + "path/filepath" + "strings" + "time" +) + +const ( + StartMarker = "# DNSHelper <<-> START CONFIG" + EndMarker = "# DNSHelper <->> END CONFIG" +) + +// DNSEntry represents a single IP-to-hostname mapping. +type DNSEntry struct { + IP string + Hostname string +} + +// String returns the hosts file line representation: "IP\tHostname". +func (e DNSEntry) String() string { + return e.IP + "\t" + e.Hostname +} + +// HostsFile represents the parsed content of a hosts file. +type HostsFile struct { + Path string + OriginalContent []string + PrefixContent []string + ManagedContent []string + PostfixContent []string + HasManagedBlock bool +} + +// Manager handles hosts file read and atomic write operations. +type Manager struct { + fs FileSystem +} + +// NewManager creates a Manager with the given FileSystem implementation. +func NewManager(fs FileSystem) *Manager { + return &Manager{fs: fs} +} + +// Read reads and parses the hosts file at the given path. +// Returns an error if the file cannot be read or the managed block is corrupt. +func (m *Manager) Read(path string) (*HostsFile, error) { + data, err := m.fs.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading hosts file %s: %w", path, err) + } + + lines := strings.Split(string(data), "\n") + // Remove trailing empty line from Split if file ends with newline. + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + // Trim \r from each line to handle Windows \r\n line endings. + // strings.Split on "\n" does not strip \r, unlike bufio.Scanner. + for i, line := range lines { + lines[i] = strings.TrimRight(line, "\r") + } + + prefix, managed, postfix, hasBlock, err := ParseManagedBlock(lines) + if err != nil { + return nil, err + } + + return &HostsFile{ + Path: path, + OriginalContent: lines, + PrefixContent: prefix, + ManagedContent: managed, + PostfixContent: postfix, + HasManagedBlock: hasBlock, + }, nil +} + +// Write atomically writes the hosts file content back to disk. +// It creates a backup before writing and removes it on success. +// Uses the temp-file-and-rename pattern for atomicity (R-002). +// backupDir is the directory for the backup file (typically the exe directory). +func (m *Manager) Write(hf *HostsFile, backupDir string) error { + // 1. Assemble new content. + newContent := AssembleContent(hf.PrefixContent, hf.ManagedContent, hf.PostfixContent) + + // 2. Skip write if content is unchanged. + if slicesEqual(newContent, hf.OriginalContent) { + return nil + } + + // 3. Build output bytes (LF line endings). + var buf strings.Builder + for _, line := range newContent { + buf.WriteString(line) + buf.WriteString("\n") + } + output := []byte(buf.String()) + + // 4. Get original file permissions so we can restore them on the temp file. + info, err := m.fs.Stat(hf.Path) + if err != nil { + return fmt.Errorf("checking hosts file permissions: %w", err) + } + originalMode := info.Mode() + + // 5. Create backup (copy of current hosts file in backupDir). + backupPath, err := m.createBackup(hf.Path, backupDir) + if err != nil { + return fmt.Errorf("creating backup: %w", err) + } + + // 6. Write to a temp file in the SAME directory as the hosts file. + // This is required so the subsequent rename stays on the same filesystem + // partition (avoids EXDEV on Unix). + success := false + tempFile, err := m.fs.CreateTemp(filepath.Dir(hf.Path), ".dns-helper-tmp-*") + if err != nil { + m.fs.Remove(backupPath) //nolint:errcheck + return fmt.Errorf("creating temp file: %w", err) + } + tempPath := tempFile.Name() + defer func() { + if !success { + m.fs.Remove(tempPath) //nolint:errcheck + m.fs.Remove(backupPath) //nolint:errcheck + } + }() + + if _, err := tempFile.Write(output); err != nil { + tempFile.Close() //nolint:errcheck + return fmt.Errorf("writing temp file: %w", err) + } + // Sync before close for durability (data survives power loss). + if err := tempFile.Sync(); err != nil { + tempFile.Close() //nolint:errcheck + return fmt.Errorf("syncing temp file: %w", err) + } + // Close before rename — required on Windows. + tempFile.Close() //nolint:errcheck + + // 7. Set permissions on temp file to match original. + if err := m.fs.Chmod(tempPath, originalMode); err != nil { + return fmt.Errorf("setting temp file permissions: %w", err) + } + + // 8. Atomic rename (with retry on Windows sharing violations). + if err := m.renameWithRetry(tempPath, hf.Path); err != nil { + return fmt.Errorf("renaming temp file to hosts file: %w", err) + } + + // 9. Success — delete the backup (original was safely replaced). + success = true + m.fs.Remove(backupPath) //nolint:errcheck + return nil +} + +// createBackup copies the hosts file to /hosts.bak.-<4hex>. +func (m *Manager) createBackup(hostsPath, backupDir string) (string, error) { + data, err := m.fs.ReadFile(hostsPath) + if err != nil { + return "", fmt.Errorf("reading hosts file for backup: %w", err) + } + + timestamp := time.Now().Format("20060102") + suffix := fmt.Sprintf("%04x", rand.Uint32()&0xFFFF) + backupName := fmt.Sprintf("hosts.bak.%s-%s", timestamp, suffix) + backupPath := filepath.Join(backupDir, backupName) + + if err := m.fs.WriteFile(backupPath, data, 0600); err != nil { + return "", fmt.Errorf("writing backup file %s: %w", backupPath, err) + } + return backupPath, nil +} + +// renameWithRetry calls Rename and retries once on failure (for Windows +// sharing violations where the hosts file may be briefly locked). +func (m *Manager) renameWithRetry(src, dst string) error { + err := m.fs.Rename(src, dst) + if err == nil { + return nil + } + // Single retry after a short delay. + time.Sleep(200 * time.Millisecond) + return m.fs.Rename(src, dst) +} + +// slicesEqual reports whether a and b contain the same strings in the same order. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/hostfile/hostfile_test.go b/hostfile/hostfile_test.go new file mode 100644 index 0000000..d732a00 --- /dev/null +++ b/hostfile/hostfile_test.go @@ -0,0 +1,369 @@ +package hostfile_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "dns-helper/hostfile" +) + +// --------------------------------------------------------------------------- +// fakeFileSystem — implements hostfile.FileSystem for unit tests +// --------------------------------------------------------------------------- + +type fakeFileInfo struct { + mode os.FileMode +} + +func (f fakeFileInfo) Name() string { return "" } +func (f fakeFileInfo) Size() int64 { return 0 } +func (f fakeFileInfo) Mode() os.FileMode { return f.mode } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } + +type writeCall struct { + path string + data []byte + perm os.FileMode +} + +type renameCall struct { + src string + dst string +} + +type fakeFileSystem struct { + readFiles map[string][]byte + readErrors map[string]error + statMode os.FileMode + statErr error + renameErr error + removeErr error + + // tracking + createTempDir string + lastTempFile *os.File + writeCalls []writeCall + renameCalls []renameCall + removedPaths []string + chmodPath string + chmodMode os.FileMode + + // real temp dir on disk for CreateTemp to use + realTempDir string +} + +func newFakeFS(t *testing.T) *fakeFileSystem { + t.Helper() + return &fakeFileSystem{ + readFiles: make(map[string][]byte), + readErrors: make(map[string]error), + realTempDir: t.TempDir(), + statMode: 0644, + } +} + +func (f *fakeFileSystem) ReadFile(path string) ([]byte, error) { + if err, ok := f.readErrors[path]; ok { + return nil, err + } + if data, ok := f.readFiles[path]; ok { + return data, nil + } + return nil, fmt.Errorf("file not found in fake fs: %s", path) +} + +func (f *fakeFileSystem) WriteFile(path string, data []byte, perm os.FileMode) error { + f.writeCalls = append(f.writeCalls, writeCall{path: path, data: data, perm: perm}) + return nil +} + +func (f *fakeFileSystem) Stat(path string) (os.FileInfo, error) { + if f.statErr != nil { + return nil, f.statErr + } + return fakeFileInfo{mode: f.statMode}, nil +} + +func (f *fakeFileSystem) CreateTemp(dir, pattern string) (*os.File, error) { + f.createTempDir = dir + tf, err := os.CreateTemp(f.realTempDir, pattern) + if err == nil { + f.lastTempFile = tf + } + return tf, err +} + +func (f *fakeFileSystem) Rename(oldpath, newpath string) error { + f.renameCalls = append(f.renameCalls, renameCall{src: oldpath, dst: newpath}) + return f.renameErr +} + +func (f *fakeFileSystem) Remove(path string) error { + f.removedPaths = append(f.removedPaths, path) + return f.removeErr +} + +func (f *fakeFileSystem) Chmod(path string, mode os.FileMode) error { + f.chmodPath = path + f.chmodMode = mode + return nil +} + +// containsRemoved returns true if path was passed to Remove. +func (f *fakeFileSystem) containsRemoved(path string) bool { + for _, p := range f.removedPaths { + if p == path { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Read tests +// --------------------------------------------------------------------------- + +func TestRead_ParsesFileCorrectly(t *testing.T) { + fs := newFakeFS(t) + content := strings.Join([]string{ + "127.0.0.1 localhost", + hostfile.StartMarker, + "1.1.1.1\thost1", + hostfile.EndMarker, + "# end", + }, "\n") + "\n" + hostsPath := "/fake/hosts" + fs.readFiles[hostsPath] = []byte(content) + + mgr := hostfile.NewManager(fs) + hf, err := mgr.Read(hostsPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hf.Path != hostsPath { + t.Errorf("expected Path=%q, got %q", hostsPath, hf.Path) + } + if !hf.HasManagedBlock { + t.Error("expected HasManagedBlock=true") + } + if len(hf.ManagedContent) != 1 || hf.ManagedContent[0] != "1.1.1.1\thost1" { + t.Errorf("unexpected ManagedContent: %v", hf.ManagedContent) + } + if len(hf.PrefixContent) != 1 { + t.Errorf("expected 1 prefix line, got %v", hf.PrefixContent) + } + if len(hf.PostfixContent) != 1 { + t.Errorf("expected 1 postfix line, got %v", hf.PostfixContent) + } +} + +func TestRead_HandlesMissingFile(t *testing.T) { + fs := newFakeFS(t) + hostsPath := "/fake/hosts" + fs.readErrors[hostsPath] = errors.New("no such file") + + mgr := hostfile.NewManager(fs) + _, err := mgr.Read(hostsPath) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), hostsPath) { + t.Errorf("error should mention the path, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Write tests +// --------------------------------------------------------------------------- + +// buildTestHostsFile builds a HostsFile where OriginalContent differs from +// the assembled content (so Write proceeds). +func buildTestHostsFile(hostsPath string) *hostfile.HostsFile { + prefix := []string{"127.0.0.1 localhost"} + managed := []string{"1.1.1.1\thost1"} + postfix := []string{"# end"} + // OriginalContent is different (e.g. empty managed block originally) + original := prefix + return &hostfile.HostsFile{ + Path: hostsPath, + OriginalContent: original, + PrefixContent: prefix, + ManagedContent: managed, + PostfixContent: postfix, + HasManagedBlock: false, + } +} + +func TestWrite_CreatesBackupBeforeWriting(t *testing.T) { + fs := newFakeFS(t) + hostsPath := filepath.Join(t.TempDir(), "hosts") + backupDir := t.TempDir() + hostsContent := "127.0.0.1 localhost\n" + fs.readFiles[hostsPath] = []byte(hostsContent) + + mgr := hostfile.NewManager(fs) + hf := buildTestHostsFile(hostsPath) + if err := mgr.Write(hf, backupDir); err != nil { + t.Fatalf("Write failed: %v", err) + } + + // Verify backup was created via WriteFile + found := false + for _, wc := range fs.writeCalls { + if strings.HasPrefix(wc.path, backupDir) && strings.Contains(wc.path, "hosts.bak.") { + found = true + break + } + } + if !found { + t.Errorf("expected backup WriteFile call in backupDir %q, write calls: %v", backupDir, fs.writeCalls) + } +} + +func TestWrite_UsesTempFileInSameDirectory(t *testing.T) { + fs := newFakeFS(t) + hostsDir := t.TempDir() + hostsPath := filepath.Join(hostsDir, "hosts") + backupDir := t.TempDir() + fs.readFiles[hostsPath] = []byte("127.0.0.1 localhost\n") + + mgr := hostfile.NewManager(fs) + hf := buildTestHostsFile(hostsPath) + if err := mgr.Write(hf, backupDir); err != nil { + t.Fatalf("Write failed: %v", err) + } + + if fs.createTempDir != hostsDir { + t.Errorf("expected CreateTemp dir=%q, got %q", hostsDir, fs.createTempDir) + } +} + +func TestWrite_RenamesTempOverHostsFile(t *testing.T) { + fs := newFakeFS(t) + hostsPath := filepath.Join(t.TempDir(), "hosts") + backupDir := t.TempDir() + fs.readFiles[hostsPath] = []byte("127.0.0.1 localhost\n") + + mgr := hostfile.NewManager(fs) + hf := buildTestHostsFile(hostsPath) + if err := mgr.Write(hf, backupDir); err != nil { + t.Fatalf("Write failed: %v", err) + } + + if len(fs.renameCalls) == 0 { + t.Fatal("expected Rename to be called") + } + lastRename := fs.renameCalls[len(fs.renameCalls)-1] + if lastRename.dst != hostsPath { + t.Errorf("expected Rename dst=%q, got %q", hostsPath, lastRename.dst) + } +} + +func TestWrite_PreservesOriginalPermissions(t *testing.T) { + fs := newFakeFS(t) + fs.statMode = 0640 + hostsPath := filepath.Join(t.TempDir(), "hosts") + backupDir := t.TempDir() + fs.readFiles[hostsPath] = []byte("127.0.0.1 localhost\n") + + mgr := hostfile.NewManager(fs) + hf := buildTestHostsFile(hostsPath) + if err := mgr.Write(hf, backupDir); err != nil { + t.Fatalf("Write failed: %v", err) + } + + if fs.chmodMode != 0640 { + t.Errorf("expected Chmod mode=0640, got %v", fs.chmodMode) + } +} + +func TestWrite_DeletesBackupOnSuccess(t *testing.T) { + fs := newFakeFS(t) + hostsPath := filepath.Join(t.TempDir(), "hosts") + backupDir := t.TempDir() + fs.readFiles[hostsPath] = []byte("127.0.0.1 localhost\n") + + mgr := hostfile.NewManager(fs) + hf := buildTestHostsFile(hostsPath) + if err := mgr.Write(hf, backupDir); err != nil { + t.Fatalf("Write failed: %v", err) + } + + // Find the backup path from writeCalls + var backupPath string + for _, wc := range fs.writeCalls { + if strings.HasPrefix(wc.path, backupDir) && strings.Contains(wc.path, "hosts.bak.") { + backupPath = wc.path + break + } + } + if backupPath == "" { + t.Fatal("backup not found in writeCalls") + } + if !fs.containsRemoved(backupPath) { + t.Errorf("expected backup %q to be removed on success, removed paths: %v", backupPath, fs.removedPaths) + } +} + +func TestWrite_CleansUpTempOnFailure(t *testing.T) { + fs := newFakeFS(t) + fs.renameErr = errors.New("rename failed") + hostsPath := filepath.Join(t.TempDir(), "hosts") + backupDir := t.TempDir() + fs.readFiles[hostsPath] = []byte("127.0.0.1 localhost\n") + + mgr := hostfile.NewManager(fs) + hf := buildTestHostsFile(hostsPath) + err := mgr.Write(hf, backupDir) + if err == nil { + t.Fatal("expected Write to fail when Rename fails") + } + + if fs.lastTempFile == nil { + t.Fatal("expected CreateTemp to have been called") + } + tempPath := fs.lastTempFile.Name() + if !fs.containsRemoved(tempPath) { + t.Errorf("expected temp file %q to be removed on failure, removed: %v", tempPath, fs.removedPaths) + } +} + +func TestWrite_SkipsIfContentUnchanged(t *testing.T) { + fs := newFakeFS(t) + hostsPath := filepath.Join(t.TempDir(), "hosts") + backupDir := t.TempDir() + + prefix := []string{"127.0.0.1 localhost"} + managed := []string{"1.1.1.1\thost1"} + postfix := []string{"# end"} + // OriginalContent matches assembled content exactly + assembled := hostfile.AssembleContent(prefix, managed, postfix) + + hf := &hostfile.HostsFile{ + Path: hostsPath, + OriginalContent: assembled, + PrefixContent: prefix, + ManagedContent: managed, + PostfixContent: postfix, + HasManagedBlock: true, + } + + mgr := hostfile.NewManager(fs) + if err := mgr.Write(hf, backupDir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fs.lastTempFile != nil { + t.Error("expected no CreateTemp call when content is unchanged") + } + if len(fs.writeCalls) != 0 { + t.Errorf("expected no WriteFile calls, got: %v", fs.writeCalls) + } +} diff --git a/hostfile/managed.go b/hostfile/managed.go new file mode 100644 index 0000000..5a4afe7 --- /dev/null +++ b/hostfile/managed.go @@ -0,0 +1,148 @@ +package hostfile + +import ( + "fmt" + "strings" +) + +// ParseManagedBlock splits raw file lines into prefix, managed, and postfix sections. +// Returns an error if the managed block markers are corrupt. +// +// Uses -1 as sentinel for "not found" because the start marker can appear at line 0. +// The current code's zero-index bug (treating index 0 as "not found") is avoided here. +func ParseManagedBlock(lines []string) (prefix, managed, postfix []string, hasManagedBlock bool, err error) { + startIdx := -1 // sentinel: not found + endIdx := -1 // sentinel: not found + startCount := 0 + endCount := 0 + + for i, line := range lines { + if strings.Contains(line, "DNSHelper <<-> START CONFIG") { + startCount++ + if startIdx == -1 { + startIdx = i + } + } + if strings.Contains(line, "DNSHelper <->> END CONFIG") { + endCount++ + if endIdx == -1 { + endIdx = i + } + } + } + + // Corruption detection (R-008): scan ALL lines first, then evaluate. + if startCount > 1 || endCount > 1 { + return nil, nil, nil, false, fmt.Errorf("managed block is corrupt: duplicate start/end markers found") + } + if startIdx >= 0 && endIdx < 0 { + return nil, nil, nil, false, fmt.Errorf("managed block is corrupt: start marker found at line %d but no end marker", startIdx+1) + } + if endIdx >= 0 && startIdx < 0 { + return nil, nil, nil, false, fmt.Errorf("managed block is corrupt: end marker found at line %d but no start marker", endIdx+1) + } + if startIdx >= 0 && endIdx >= 0 && endIdx < startIdx { + return nil, nil, nil, false, fmt.Errorf("managed block is corrupt: end marker (line %d) appears before start marker (line %d)", endIdx+1, startIdx+1) + } + + if startIdx < 0 && endIdx < 0 { + // No managed block — all lines are prefix. + return lines, nil, nil, false, nil + } + + // Valid managed block. + prefix = lines[:startIdx] + managed = lines[startIdx+1 : endIdx] + postfix = lines[endIdx+1:] + return prefix, managed, postfix, true, nil +} + +// AddEntries removes existing entries for the same hostnames as the new entries, +// then appends the new entries. Returns deduplicated managed content lines. +func AddEntries(existing []string, entries []DNSEntry) []string { + // Collect hostnames being added. + hostnames := make(map[string]bool) + for _, e := range entries { + hostnames[e.Hostname] = true + } + + // Keep existing lines that don't match any new hostname. + var result []string + for _, line := range existing { + keep := true + for h := range hostnames { + if strings.Contains(line, h) { + keep = false + break + } + } + if keep { + result = append(result, line) + } + } + + // Append new entries. + for _, e := range entries { + result = append(result, e.String()) + } + + // Deduplicate preserving order. + return deduplicateLines(result) +} + +// RemoveByHostname removes all managed entries matching any of the specified +// hostnames. Returns the remaining content. +// +// The correct algorithm iterates lines on the outer loop (not hosts), which +// avoids the duplication bug in the legacy removeHostsFromExistingContent +// function (dataprep.go). +func RemoveByHostname(existing []string, hostnames []string) []string { + var result []string + for _, line := range existing { + shouldRemove := false + for _, host := range hostnames { + if strings.Contains(line, host) { + shouldRemove = true + break + } + } + if !shouldRemove { + result = append(result, line) + } + } + return result +} + +// RemoveAll returns nil, indicating all managed entries should be removed. +// When AssembleContent receives nil/empty managed content, it omits the +// managed block entirely (markers included). +func RemoveAll() []string { + return nil +} + +// AssembleContent builds the full file content from sections. +// If managed is empty, the managed block (markers included) is omitted entirely. +func AssembleContent(prefix, managed, postfix []string) []string { + var result []string + result = append(result, prefix...) + if len(managed) > 0 { + result = append(result, StartMarker) + result = append(result, managed...) + result = append(result, EndMarker) + } + result = append(result, postfix...) + return result +} + +// deduplicateLines removes duplicate lines preserving order. +func deduplicateLines(lines []string) []string { + seen := make(map[string]bool) + var result []string + for _, line := range lines { + if !seen[line] { + seen[line] = true + result = append(result, line) + } + } + return result +} diff --git a/hostfile/managed_test.go b/hostfile/managed_test.go new file mode 100644 index 0000000..4c0f7b0 --- /dev/null +++ b/hostfile/managed_test.go @@ -0,0 +1,360 @@ +package hostfile_test + +import ( + "strings" + "testing" + + "dns-helper/hostfile" +) + +// --------------------------------------------------------------------------- +// ParseManagedBlock tests +// --------------------------------------------------------------------------- + +func TestParseManagedBlock_NoManagedBlock(t *testing.T) { + lines := []string{ + "127.0.0.1 localhost", + "# some comment", + "1.2.3.4 example.com", + } + prefix, managed, postfix, has, err := hostfile.ParseManagedBlock(lines) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if has { + t.Fatal("expected HasManagedBlock=false") + } + if len(managed) != 0 { + t.Errorf("expected empty managed, got %v", managed) + } + if len(postfix) != 0 { + t.Errorf("expected empty postfix, got %v", postfix) + } + if len(prefix) != len(lines) { + t.Errorf("expected all lines in prefix, got %v", prefix) + } +} + +func TestParseManagedBlock_ValidBlock(t *testing.T) { + lines := []string{ + "127.0.0.1 localhost", + hostfile.StartMarker, + "1.1.1.1\thost1", + "2.2.2.2\thost2", + hostfile.EndMarker, + "# trailing comment", + } + prefix, managed, postfix, has, err := hostfile.ParseManagedBlock(lines) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !has { + t.Fatal("expected HasManagedBlock=true") + } + if len(prefix) != 1 || prefix[0] != "127.0.0.1 localhost" { + t.Errorf("unexpected prefix: %v", prefix) + } + if len(managed) != 2 { + t.Errorf("expected 2 managed lines, got %v", managed) + } + if len(postfix) != 1 || postfix[0] != "# trailing comment" { + t.Errorf("unexpected postfix: %v", postfix) + } +} + +func TestParseManagedBlock_StartMarkerAtLine0(t *testing.T) { + lines := []string{ + hostfile.StartMarker, + "1.1.1.1\thost1", + hostfile.EndMarker, + } + prefix, managed, postfix, has, err := hostfile.ParseManagedBlock(lines) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !has { + t.Fatal("expected HasManagedBlock=true") + } + if len(prefix) != 0 { + t.Errorf("expected empty prefix when start marker is at line 0, got %v", prefix) + } + if len(managed) != 1 || managed[0] != "1.1.1.1\thost1" { + t.Errorf("unexpected managed: %v", managed) + } + if len(postfix) != 0 { + t.Errorf("expected empty postfix, got %v", postfix) + } +} + +func TestParseManagedBlock_CorruptStartWithoutEnd(t *testing.T) { + lines := []string{ + "127.0.0.1 localhost", + hostfile.StartMarker, + "1.1.1.1\thost1", + } + _, _, _, _, err := hostfile.ParseManagedBlock(lines) + if err == nil { + t.Fatal("expected error for start marker without end marker") + } + if !strings.Contains(err.Error(), "start marker found") { + t.Errorf("error should mention 'start marker found', got: %v", err) + } + if !strings.Contains(err.Error(), "no end marker") { + t.Errorf("error should mention 'no end marker', got: %v", err) + } +} + +func TestParseManagedBlock_CorruptEndWithoutStart(t *testing.T) { + lines := []string{ + "127.0.0.1 localhost", + "1.1.1.1\thost1", + hostfile.EndMarker, + } + _, _, _, _, err := hostfile.ParseManagedBlock(lines) + if err == nil { + t.Fatal("expected error for end marker without start marker") + } + if !strings.Contains(err.Error(), "end marker found") { + t.Errorf("error should mention 'end marker found', got: %v", err) + } + if !strings.Contains(err.Error(), "no start marker") { + t.Errorf("error should mention 'no start marker', got: %v", err) + } +} + +func TestParseManagedBlock_CorruptEndBeforeStart(t *testing.T) { + lines := []string{ + "127.0.0.1 localhost", + hostfile.EndMarker, + "1.1.1.1\thost1", + hostfile.StartMarker, + } + _, _, _, _, err := hostfile.ParseManagedBlock(lines) + if err == nil { + t.Fatal("expected error for end marker before start marker") + } + if !strings.Contains(err.Error(), "end marker") { + t.Errorf("error should mention 'end marker', got: %v", err) + } + if !strings.Contains(err.Error(), "before start marker") { + t.Errorf("error should mention 'before start marker', got: %v", err) + } +} + +func TestParseManagedBlock_CorruptDuplicateMarkers(t *testing.T) { + lines := []string{ + hostfile.StartMarker, + "1.1.1.1\thost1", + hostfile.EndMarker, + hostfile.StartMarker, // duplicate + "2.2.2.2\thost2", + hostfile.EndMarker, + } + _, _, _, _, err := hostfile.ParseManagedBlock(lines) + if err == nil { + t.Fatal("expected error for duplicate markers") + } + if !strings.Contains(err.Error(), "duplicate") { + t.Errorf("error should mention 'duplicate', got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// AddEntries tests +// --------------------------------------------------------------------------- + +func TestAddEntries_AddsNewEntries(t *testing.T) { + existing := []string{"1.1.1.1\thost1"} + entries := []hostfile.DNSEntry{{IP: "2.2.2.2", Hostname: "host2"}} + result := hostfile.AddEntries(existing, entries) + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d: %v", len(result), result) + } + if result[0] != "1.1.1.1\thost1" { + t.Errorf("unexpected entry[0]: %q", result[0]) + } + if result[1] != "2.2.2.2\thost2" { + t.Errorf("unexpected entry[1]: %q", result[1]) + } +} + +func TestAddEntries_ReplacesExistingHostname(t *testing.T) { + existing := []string{"1.1.1.1\thost1", "2.2.2.2\thost2"} + // Replace host1 with a new IP + entries := []hostfile.DNSEntry{{IP: "9.9.9.9", Hostname: "host1"}} + result := hostfile.AddEntries(existing, entries) + // host1 should be replaced, host2 preserved + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d: %v", len(result), result) + } + found := false + for _, line := range result { + if strings.Contains(line, "1.1.1.1") && strings.Contains(line, "host1") { + t.Errorf("old entry for host1 should have been replaced: %v", result) + } + if strings.Contains(line, "9.9.9.9") && strings.Contains(line, "host1") { + found = true + } + } + if !found { + t.Errorf("new entry for host1 not found: %v", result) + } +} + +func TestAddEntries_Deduplicates(t *testing.T) { + existing := []string{"1.1.1.1\thost1"} + entries := []hostfile.DNSEntry{ + {IP: "2.2.2.2", Hostname: "host2"}, + {IP: "2.2.2.2", Hostname: "host2"}, // duplicate + } + result := hostfile.AddEntries(existing, entries) + count := 0 + for _, line := range result { + if line == "2.2.2.2\thost2" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 host2 entry after dedup, got %d: %v", count, result) + } +} + +// --------------------------------------------------------------------------- +// AssembleContent tests +// --------------------------------------------------------------------------- + +func TestAssembleContent_WithManagedBlock(t *testing.T) { + prefix := []string{"127.0.0.1 localhost"} + managed := []string{"1.1.1.1\thost1"} + postfix := []string{"# end"} + result := hostfile.AssembleContent(prefix, managed, postfix) + expected := []string{ + "127.0.0.1 localhost", + hostfile.StartMarker, + "1.1.1.1\thost1", + hostfile.EndMarker, + "# end", + } + if len(result) != len(expected) { + t.Fatalf("expected %d lines, got %d: %v", len(expected), len(result), result) + } + for i, line := range expected { + if result[i] != line { + t.Errorf("line %d: expected %q, got %q", i, line, result[i]) + } + } +} + +func TestAssembleContent_EmptyManagedOmitsBlock(t *testing.T) { + prefix := []string{"127.0.0.1 localhost"} + postfix := []string{"# end"} + result := hostfile.AssembleContent(prefix, nil, postfix) + if len(result) != 2 { + t.Fatalf("expected 2 lines (no managed block), got %d: %v", len(result), result) + } + for _, line := range result { + if strings.Contains(line, "DNSHelper") { + t.Errorf("managed block markers should be absent when managed is empty: %v", result) + } + } +} + +// --------------------------------------------------------------------------- +// RemoveByHostname tests (T018) +// --------------------------------------------------------------------------- + +func TestRemoveByHostname_SingleHost(t *testing.T) { + existing := []string{ + "1.1.1.1\thost1", + "2.2.2.2\thost2", + } + result := hostfile.RemoveByHostname(existing, []string{"host1"}) + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d: %v", len(result), result) + } + if result[0] != "2.2.2.2\thost2" { + t.Errorf("expected host2 entry to remain, got %q", result[0]) + } +} + +func TestRemoveByHostname_MultipleHosts(t *testing.T) { + existing := []string{ + "1.1.1.1\thost1", + "2.2.2.2\thost2", + "3.3.3.3\thost3", + } + result := hostfile.RemoveByHostname(existing, []string{"host1", "host2"}) + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d: %v", len(result), result) + } + if result[0] != "3.3.3.3\thost3" { + t.Errorf("expected host3 entry to remain, got %q", result[0]) + } +} + +func TestRemoveByHostname_HostNotFound(t *testing.T) { + existing := []string{ + "1.1.1.1\thost1", + } + result := hostfile.RemoveByHostname(existing, []string{"host2"}) + if len(result) != 1 { + t.Fatalf("expected content unchanged (1 entry), got %d: %v", len(result), result) + } + if result[0] != "1.1.1.1\thost1" { + t.Errorf("expected host1 entry unchanged, got %q", result[0]) + } +} + +func TestRemoveByHostname_AllHostsRemoved(t *testing.T) { + existing := []string{ + "1.1.1.1\thost1", + } + result := hostfile.RemoveByHostname(existing, []string{"host1"}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %d entries: %v", len(result), result) + } +} + +func TestRemoveByHostname_RegressionNoDuplicates(t *testing.T) { + // This is the R-004 regression test. The legacy code's loop-order bug + // produced duplicates when removing multiple hosts at once. + existing := []string{ + "1.1.1.1\thost1", + "2.2.2.2\thost2", + "3.3.3.3\thost3", + } + result := hostfile.RemoveByHostname(existing, []string{"host1", "host2"}) + if len(result) != 1 { + t.Fatalf("expected exactly 1 entry after multi-host removal, got %d: %v", len(result), result) + } + if result[0] != "3.3.3.3\thost3" { + t.Errorf("expected only host3 to remain, got %q", result[0]) + } +} + +// --------------------------------------------------------------------------- +// RemoveAll tests (T018) +// --------------------------------------------------------------------------- + +func TestRemoveAll_ReturnEmptySlice(t *testing.T) { + result := hostfile.RemoveAll() + if len(result) != 0 { + t.Errorf("expected empty result, got %d entries: %v", len(result), result) + } +} + +func TestRemoveAll_AssembleContentOmitsBlock(t *testing.T) { + // When RemoveAll result is passed to AssembleContent, no managed block markers + // should appear in the assembled output. + prefix := []string{"127.0.0.1 localhost"} + postfix := []string{"# trailing"} + result := hostfile.AssembleContent(prefix, hostfile.RemoveAll(), postfix) + if len(result) != 2 { + t.Fatalf("expected 2 lines with no managed block, got %d: %v", len(result), result) + } + for _, line := range result { + if strings.Contains(line, "DNSHelper") { + t.Errorf("managed block markers should be absent after RemoveAll: %v", result) + } + } +} diff --git a/lockfile/lockfile.go b/lockfile/lockfile.go new file mode 100644 index 0000000..394b60a --- /dev/null +++ b/lockfile/lockfile.go @@ -0,0 +1,62 @@ +package lockfile + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + lockFileName = ".dns-helper.lock" + staleTimeout = 2 * time.Minute + retryInterval = 200 * time.Millisecond + maxWait = 2 * time.Second +) + +// Lock represents an acquired lock file that can be released. +type Lock interface { + Release() error +} + +type fileLock struct { + path string +} + +// Release deletes the lock file. It is safe to call more than once. +func (l *fileLock) Release() error { + err := os.Remove(l.path) + if err != nil && os.IsNotExist(err) { + return nil + } + return err +} + +// Acquire creates a lock file in dir and returns a Lock that can release it. +// It waits up to maxWait for a stale or otherwise held lock to clear. +func Acquire(dir string) (Lock, error) { + lockPath := filepath.Join(dir, lockFileName) + deadline := time.Now().Add(maxWait) + for { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err == nil { + // Write PID for debugging (not used for staleness detection) + fmt.Fprintf(f, "%d\n", os.Getpid()) + f.Close() + return &fileLock{path: lockPath}, nil + } + if !os.IsExist(err) { + return nil, fmt.Errorf("creating lock file %s: %w", lockPath, err) + } + // Lock file exists — check staleness + info, statErr := os.Stat(lockPath) + if statErr == nil && time.Since(info.ModTime()) > staleTimeout { + os.Remove(lockPath) + continue // Retry after breaking stale lock + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("could not acquire lock file %s: another instance may be running", lockPath) + } + time.Sleep(retryInterval) + } +} diff --git a/lockfile/lockfile_test.go b/lockfile/lockfile_test.go new file mode 100644 index 0000000..1351afc --- /dev/null +++ b/lockfile/lockfile_test.go @@ -0,0 +1,95 @@ +package lockfile_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "dns-helper/lockfile" +) + +// T006-1: Acquire succeeds and lock file exists on disk. +func TestAcquireSucceeds(t *testing.T) { + dir := t.TempDir() + lock, err := lockfile.Acquire(dir) + if err != nil { + t.Fatalf("Acquire returned unexpected error: %v", err) + } + defer lock.Release() + + lockPath := filepath.Join(dir, ".dns-helper.lock") + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + t.Error("lock file does not exist after Acquire") + } +} + +// T006-2: Release deletes the lock file. +func TestReleaseDeletesLockFile(t *testing.T) { + dir := t.TempDir() + lock, err := lockfile.Acquire(dir) + if err != nil { + t.Fatalf("Acquire returned unexpected error: %v", err) + } + + if err := lock.Release(); err != nil { + t.Fatalf("Release returned unexpected error: %v", err) + } + + lockPath := filepath.Join(dir, ".dns-helper.lock") + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("lock file still exists after Release") + } +} + +// T006-3: Double acquire fails with timeout error. +func TestDoubleAcquireFails(t *testing.T) { + dir := t.TempDir() + lock, err := lockfile.Acquire(dir) + if err != nil { + t.Fatalf("first Acquire returned unexpected error: %v", err) + } + defer lock.Release() + + _, err = lockfile.Acquire(dir) + if err == nil { + t.Fatal("second Acquire expected to fail, but it succeeded") + } +} + +// T006-4: Stale lock file (>2 minutes old) is broken and Acquire succeeds. +func TestStaleLockBroken(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, ".dns-helper.lock") + + // Manually create a lock file with a stale modification time. + if err := os.WriteFile(lockPath, []byte("99999\n"), 0644); err != nil { + t.Fatalf("failed to create stale lock file: %v", err) + } + staleTime := time.Now().Add(-3 * time.Minute) + if err := os.Chtimes(lockPath, staleTime, staleTime); err != nil { + t.Fatalf("failed to set stale mtime: %v", err) + } + + lock, err := lockfile.Acquire(dir) + if err != nil { + t.Fatalf("Acquire over stale lock returned unexpected error: %v", err) + } + lock.Release() +} + +// T006-5: Release is idempotent — calling twice does not return an error. +func TestReleaseIsIdempotent(t *testing.T) { + dir := t.TempDir() + lock, err := lockfile.Acquire(dir) + if err != nil { + t.Fatalf("Acquire returned unexpected error: %v", err) + } + + if err := lock.Release(); err != nil { + t.Fatalf("first Release returned unexpected error: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("second Release returned unexpected error: %v", err) + } +} diff --git a/main.go b/main.go index 440f448..413e8c6 100644 --- a/main.go +++ b/main.go @@ -6,89 +6,284 @@ import ( "os" "path/filepath" "strings" + + "dns-helper/hostfile" + "dns-helper/lockfile" + "dns-helper/platform" + "dns-helper/resolver" ) -var fileName = filepath.Base(os.Args[0]) - -func init() { +func main() { + // Banner (always printed to stdout). fmt.Println("DNSHelper v1.0") fmt.Println("Copyright (c) 2024 Emberkom LLC") fmt.Println("") + if len(os.Args) < 2 { printUsage() - defer os.Exit(1) - } -} - -func printUsage() { - fmt.Println("This utility will update the local hosts file with DNS entries obtained by the specified DNS server.") - fmt.Println("Usage: " + fileName + " [add|delete] -host hostname [-server dns.example.com]") - fmt.Println("Example:") - fmt.Println(" " + fileName + " add -host xyz.acme.com -server dns.example.com") - fmt.Println(" This will use dns.example.com to find and add all IP addresses for xyz.acme.com to the local hosts file.") - fmt.Println(" " + fileName + " delete -host hostname.example.com") - fmt.Println(" This will delete all entries for hostname.example.com from the local hosts file.") - fmt.Println(" " + fileName + " delete all") - fmt.Println(" This will delete all entries from the local hosts file that were added by this utility.") - fmt.Println("Note: Adding a hostname will first remove all entries in the hosts file that match the same hostname.") - fmt.Println(" This utility will only remove entries from the hosts file that it added.") -} - -func term(err error) { - fmt.Println("Error: " + err.Error()) - defer os.Exit(1) -} - -func main() { - modePing := flag.NewFlagSet("ping", flag.ExitOnError) - modeAdd := flag.NewFlagSet("add", flag.ExitOnError) - hostToAdd := modeAdd.String("host", "", "Comma separated list of hostnames to add") - dnsResolver := modeAdd.String("server", "", "DNS resolver to use") - modeDelete := flag.NewFlagSet("delete", flag.ExitOnError) - hostToDelete := modeDelete.String("host", "", "Comma separated list of hostnames to delete") - - data := new(workingData) - - var err error - data.HostsFileLocation, err = getHostsFileLocation() - if err != nil { - term(err) - } - - data.DefaultContent, err = getFileContentLines(data.HostsFileLocation) - if err != nil { - term(err) - } - - if err = extractLinesToEdit(data); err != nil { - term(err) + os.Exit(1) } switch strings.ToLower(os.Args[1]) { case "a", "add": - err = modeAdd.Parse(os.Args[2:]) - if err != nil { - term(err) - } - addHostsToWorkingData(data, hostToAdd) - addResolversToWorkingData(data, dnsResolver) - removeHostsFromExistingContent(data) - addNameToIPMappingsToNewContent(data) + os.Exit(runAdd(os.Args[2:])) case "d", "del", "delete": - err = modeDelete.Parse(os.Args[2:]) - if err != nil { - term(err) - } - if strings.ToLower(*hostToDelete) != "all" && strings.ToLower(*hostToDelete) != "a" { - addHostsToWorkingData(data, hostToDelete) - removeHostsFromExistingContent(data) - } + os.Exit(runDelete(os.Args[2:])) default: printUsage() - } - - err = writeHostsFile(data) - if err != nil { - term(err) + os.Exit(1) } } + +func runAdd(args []string) int { + fs := flag.NewFlagSet("add", flag.ContinueOnError) + hostFlag := fs.String("host", "", "Comma separated list of hostnames to add") + serverFlag := fs.String("server", "", "DNS resolver to use") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Validation (FR-006). + if *hostFlag == "" { + fmt.Fprintf(os.Stderr, "Error: -host flag is required for the add command\n") + return 1 + } + if *serverFlag == "" { + fmt.Fprintf(os.Stderr, "Error: -server flag is required for the add command\n") + return 1 + } + + hostnames := strings.Split(*hostFlag, ",") + server := *serverFlag + + // Step 1: DNS resolution before lock acquisition (FR-016). + res := resolver.New() + var entries []hostfile.DNSEntry + var warnings []string + for _, h := range hostnames { + h = strings.TrimSpace(h) + ips, err := res.LookupIP(h, server) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Warning: failed to resolve %s: %v", h, err)) + continue + } + for _, ip := range ips { + entries = append(entries, hostfile.DNSEntry{IP: ip, Hostname: h}) + } + } + + if len(entries) == 0 && len(warnings) > 0 { + for _, w := range warnings { + fmt.Fprintln(os.Stderr, w) + } + return 1 + } + + // Step 2: Get hosts file path. + hostsPath, err := platform.GetHostsFilePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 3: Get executable directory for lock and backup storage. + exePath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: determining executable path: %v\n", err) + return 1 + } + exeDir := filepath.Dir(exePath) + + // Step 4: Acquire lock (FR-016). + lock, err := lockfile.Acquire(exeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + defer lock.Release() + + // Step 5: Read, modify, write. + mgr := hostfile.NewManager(hostfile.OSFileSystem{}) + hf, err := mgr.Read(hostsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + hf.ManagedContent = hostfile.AddEntries(hf.ManagedContent, entries) + + if err := mgr.Write(hf, exeDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 6: Output summary (FR-013). + entryCounts := make(map[string]int) + for _, e := range entries { + entryCounts[e.Hostname]++ + } + for _, h := range hostnames { + h = strings.TrimSpace(h) + if count, ok := entryCounts[h]; ok { + fmt.Printf("Added %d entries for %s\n", count, h) + } + } + for _, w := range warnings { + fmt.Fprintln(os.Stderr, w) + } + + if len(warnings) > 0 { + return 1 // Partial failure (FR-015). + } + return 0 +} + +func runDelete(args []string) int { + // Check for "all" keyword first — positional argument, not a flag. + if len(args) > 0 && (strings.ToLower(args[0]) == "all" || strings.ToLower(args[0]) == "a") { + return runDeleteAll() + } + + fs := flag.NewFlagSet("delete", flag.ContinueOnError) + hostFlag := fs.String("host", "", "Comma separated list of hostnames to delete") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + if *hostFlag == "" { + fmt.Fprintf(os.Stderr, "Error: -host flag is required for the delete command\n") + return 1 + } + + hostnames := strings.Split(*hostFlag, ",") + for i, h := range hostnames { + hostnames[i] = strings.TrimSpace(h) + } + + // Step 1: Get hosts file path. + hostsPath, err := platform.GetHostsFilePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 2: Get executable directory. + exePath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: determining executable path: %v\n", err) + return 1 + } + exeDir := filepath.Dir(exePath) + + // Step 3: Acquire lock. + lock, err := lockfile.Acquire(exeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + defer lock.Release() + + // Step 4: Read and parse hosts file. + mgr := hostfile.NewManager(hostfile.OSFileSystem{}) + hf, err := mgr.Read(hostsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 5: Remove entries by hostname, counting originals for summary. + // Count original managed lines per hostname before removal. + originalCounts := make(map[string]int) + for _, h := range hostnames { + for _, line := range hf.ManagedContent { + if strings.Contains(line, h) { + originalCounts[h]++ + } + } + } + + hf.ManagedContent = hostfile.RemoveByHostname(hf.ManagedContent, hostnames) + + // Step 6: Write updated file. + if err := mgr.Write(hf, exeDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 7: Print per-hostname summary. + for _, h := range hostnames { + if count, ok := originalCounts[h]; ok && count > 0 { + fmt.Printf("Removed %d entries for %s\n", count, h) + } else { + fmt.Printf("No managed entries found for %s\n", h) + } + } + return 0 +} + +func runDeleteAll() int { + // Step 1: Get hosts file path. + hostsPath, err := platform.GetHostsFilePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 2: Get executable directory. + exePath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: determining executable path: %v\n", err) + return 1 + } + exeDir := filepath.Dir(exePath) + + // Step 3: Acquire lock. + lock, err := lockfile.Acquire(exeDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + defer lock.Release() + + // Step 4: Read and parse hosts file. + mgr := hostfile.NewManager(hostfile.OSFileSystem{}) + hf, err := mgr.Read(hostsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 5: Remove all managed entries. + entryCount := len(hf.ManagedContent) + hf.ManagedContent = hostfile.RemoveAll() + + // Step 6: Write updated file. + if err := mgr.Write(hf, exeDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Step 7: Print summary. + if entryCount > 0 { + fmt.Printf("Removed all managed entries (%d entries removed)\n", entryCount) + } else { + fmt.Println("No managed entries found") + } + return 0 +} + +func printUsage() { + fmt.Println("This utility will update the local hosts file with DNS entries obtained by the specified DNS server.") + fmt.Println("Usage: dns-helper [add|delete] -host hostname [-server dns.example.com]") + fmt.Println("Example:") + fmt.Println(" dns-helper add -host xyz.acme.com -server dns.example.com") + fmt.Println(" This will use dns.example.com to find and add all IP addresses for xyz.acme.com to the local hosts file.") + fmt.Println(" dns-helper delete -host hostname.example.com") + fmt.Println(" This will delete all entries for hostname.example.com from the local hosts file.") + fmt.Println(" dns-helper delete all") + fmt.Println(" This will delete all entries from the local hosts file that were added by this utility.") + fmt.Println("Note: Adding a hostname will first remove all entries in the hosts file that match the same hostname.") + fmt.Println(" This utility will only remove entries from the hosts file that it added.") +} diff --git a/platform/platform.go b/platform/platform.go new file mode 100644 index 0000000..0cb431c --- /dev/null +++ b/platform/platform.go @@ -0,0 +1,2 @@ +// Package platform provides the platform-specific hosts file path. +package platform diff --git a/platform/platform_darwin.go b/platform/platform_darwin.go new file mode 100644 index 0000000..65a66c3 --- /dev/null +++ b/platform/platform_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package platform + +import ( + "fmt" + "os" +) + +// GetHostsFilePath returns the absolute path to the system hosts file on macOS. +// Returns an error if the file does not exist or cannot be accessed. +func GetHostsFilePath() (string, error) { + path := "/etc/hosts" + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file does not exist: %s", path) + } + return "", fmt.Errorf("accessing hosts file: %w", err) + } + return path, nil +} diff --git a/platform/platform_linux.go b/platform/platform_linux.go new file mode 100644 index 0000000..15a3d2d --- /dev/null +++ b/platform/platform_linux.go @@ -0,0 +1,21 @@ +//go:build linux + +package platform + +import ( + "fmt" + "os" +) + +// GetHostsFilePath returns the absolute path to the system hosts file on Linux. +// Returns an error if the file does not exist or cannot be accessed. +func GetHostsFilePath() (string, error) { + path := "/etc/hosts" + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file does not exist: %s", path) + } + return "", fmt.Errorf("accessing hosts file: %w", err) + } + return path, nil +} diff --git a/platform/platform_test.go b/platform/platform_test.go new file mode 100644 index 0000000..f92884e --- /dev/null +++ b/platform/platform_test.go @@ -0,0 +1,56 @@ +package platform_test + +import ( + "os" + "strings" + "testing" + + "dns-helper/platform" +) + +// T007-1: GetHostsFilePath returns a non-empty path with no error on the current OS. +func TestGetHostsFilePathReturnsPath(t *testing.T) { + path, err := platform.GetHostsFilePath() + if err != nil { + t.Fatalf("GetHostsFilePath returned unexpected error: %v", err) + } + if path == "" { + t.Error("GetHostsFilePath returned empty path") + } +} + +// T007-2: The returned path exists on the filesystem. +func TestGetHostsFilePathExists(t *testing.T) { + path, err := platform.GetHostsFilePath() + if err != nil { + t.Fatalf("GetHostsFilePath returned unexpected error: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("hosts file path %q does not exist or cannot be accessed: %v", path, err) + } +} + +// T024-1: GetHostsFilePath returns a path that is an absolute path (contains a +// directory separator), ensuring the error messages that embed the path are descriptive. +func TestGetHostsFilePathIsAbsolute(t *testing.T) { + path, err := platform.GetHostsFilePath() + if err != nil { + t.Fatalf("GetHostsFilePath returned unexpected error: %v", err) + } + if !strings.ContainsAny(path, `/\`) { + t.Errorf("expected an absolute path containing a directory separator, got %q", path) + } +} + +// T024-2: GetHostsFilePath function signature returns (string, error) — process +// termination (os.Exit, log.Fatal, term()) is absent from the platform package. +// This is verified statically by T023 grep/vet; the runtime counterpart is that the +// function can be called without panicking or exiting. +func TestGetHostsFilePathDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("GetHostsFilePath panicked: %v", r) + } + }() + platform.GetHostsFilePath() //nolint:errcheck // return values checked in other tests +} diff --git a/platform/platform_windows.go b/platform/platform_windows.go new file mode 100644 index 0000000..40e0d6e --- /dev/null +++ b/platform/platform_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package platform + +import ( + "fmt" + "os" +) + +// GetHostsFilePath returns the absolute path to the system hosts file on Windows. +// Returns an error if the file does not exist or cannot be accessed. +func GetHostsFilePath() (string, error) { + var path string + if val, ok := os.LookupEnv("SystemRoot"); ok { + path = val + "\\System32\\drivers\\etc\\hosts" + } else { + path = "C:\\Windows\\System32\\drivers\\etc\\hosts" + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file does not exist: %s", path) + } + return "", fmt.Errorf("accessing hosts file: %w", err) + } + return path, nil +} diff --git a/resolver/resolver.go b/resolver/resolver.go new file mode 100644 index 0000000..9652b56 --- /dev/null +++ b/resolver/resolver.go @@ -0,0 +1,175 @@ +package resolver + +import ( + "fmt" + "math/rand" + "net" + "strings" + "time" + + "golang.org/x/net/dns/dnsmessage" +) + +const ( + defaultTimeout = 5 * time.Second + maxCNAMEDepth = 10 + udpBufSize = 1232 +) + +// Resolver performs DNS lookups against a specific server. +type Resolver interface { + LookupIP(hostname, server string) ([]string, error) +} + +// DNSResolver implements Resolver using golang.org/x/net/dns/dnsmessage. +type DNSResolver struct{} + +// New returns a new DNSResolver. +func New() *DNSResolver { + return &DNSResolver{} +} + +// LookupIP performs a DNS A-record lookup for hostname using the given DNS server. +// server may include a port (e.g. "8.8.8.8:53") or just an IP/host (":53" appended). +func (d *DNSResolver) LookupIP(hostname, server string) ([]string, error) { + return d.lookupIPWithDepth(hostname, server, 0) +} + +func (d *DNSResolver) lookupIPWithDepth(hostname, server string, depth int) ([]string, error) { + if depth > maxCNAMEDepth { + return nil, fmt.Errorf("CNAME chain depth exceeded for %s (max %d)", hostname, maxCNAMEDepth) + } + + // Ensure FQDN (trailing dot required by dnsmessage.NewName). + fqdn := hostname + if !strings.HasSuffix(fqdn, ".") { + fqdn += "." + } + name, err := dnsmessage.NewName(fqdn) + if err != nil { + return nil, fmt.Errorf("invalid hostname %q: %w", hostname, err) + } + + // Build A query with random ID. + id := uint16(rand.Uint32()) //nolint:gosec + msg := dnsmessage.Message{ + Header: dnsmessage.Header{ + ID: id, + RecursionDesired: true, + }, + Questions: []dnsmessage.Question{{ + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }}, + } + packed, err := msg.Pack() + if err != nil { + return nil, fmt.Errorf("packing DNS query: %w", err) + } + + // Connect over UDP. + addr := server + if !strings.Contains(server, ":") { + addr = server + ":53" + } + conn, err := net.DialTimeout("udp", addr, defaultTimeout) + if err != nil { + return nil, fmt.Errorf("connecting to DNS server %s: %w", server, err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(defaultTimeout)) //nolint:errcheck + + if _, err := conn.Write(packed); err != nil { + return nil, fmt.Errorf("sending DNS query to %s: %w", server, err) + } + + buf := make([]byte, udpBufSize) + n, err := conn.Read(buf) + if err != nil { + return nil, fmt.Errorf("reading DNS response from %s: %w", server, err) + } + + // Parse response using the streaming Parser API (idiomatic dnsmessage approach). + var parser dnsmessage.Parser + respHeader, err := parser.Start(buf[:n]) + if err != nil { + return nil, fmt.Errorf("parsing DNS response: %w", err) + } + + // Validate response ID to guard against spoofing / mismatched replies. + if respHeader.ID != id { + return nil, fmt.Errorf("DNS response ID mismatch (expected %d, got %d)", id, respHeader.ID) + } + + // Reject truncated responses — no TCP fallback per contract. + if respHeader.Truncated { + return nil, fmt.Errorf("DNS response truncated for %s; TCP fallback not supported", hostname) + } + + // Non-success rcodes. + if respHeader.RCode != dnsmessage.RCodeSuccess { + return nil, fmt.Errorf("DNS query for %s failed: %s", hostname, respHeader.RCode.String()) + } + + // Skip the questions section. + if err := parser.SkipAllQuestions(); err != nil { + return nil, fmt.Errorf("parsing DNS response questions: %w", err) + } + + // Collect A records and CNAME targets from the answer section. + var ips []string + var cnameTargets []string + for { + hdr, err := parser.AnswerHeader() + if err == dnsmessage.ErrSectionDone { + break + } + if err != nil { + return nil, fmt.Errorf("parsing DNS answer header: %w", err) + } + switch hdr.Type { + case dnsmessage.TypeA: + aRec, err := parser.AResource() + if err != nil { + return nil, fmt.Errorf("parsing A record: %w", err) + } + ips = append(ips, net.IP(aRec.A[:]).String()) + case dnsmessage.TypeCNAME: + cnameRec, err := parser.CNAMEResource() + if err != nil { + return nil, fmt.Errorf("parsing CNAME record: %w", err) + } + cnameTargets = append(cnameTargets, cnameRec.CNAME.String()) + default: + if err := parser.SkipAnswer(); err != nil { + return nil, fmt.Errorf("skipping DNS answer: %w", err) + } + } + } + + // If no A records but CNAME targets exist, follow the CNAME chain. + // Only follow when there are no A records — some servers return both. + if len(ips) == 0 && len(cnameTargets) > 0 { + for _, target := range cnameTargets { + // Strip trailing dot: dnsmessage CNAME targets are FQDNs with dot. + targetHost := strings.TrimSuffix(target, ".") + cnameIPs, err := d.lookupIPWithDepth(targetHost, server, depth+1) + if err != nil { + return nil, err + } + ips = append(ips, cnameIPs...) + } + } + + // Deduplicate IPs preserving order. + seen := make(map[string]bool) + var unique []string + for _, ip := range ips { + if !seen[ip] { + seen[ip] = true + unique = append(unique, ip) + } + } + return unique, nil +} diff --git a/resolver/resolver_test.go b/resolver/resolver_test.go new file mode 100644 index 0000000..7580bcd --- /dev/null +++ b/resolver/resolver_test.go @@ -0,0 +1,346 @@ +package resolver_test + +import ( + "encoding/binary" + "net" + "strings" + "testing" + "time" + + "dns-helper/resolver" + + "golang.org/x/net/dns/dnsmessage" +) + +// --------------------------------------------------------------------------- +// Fake DNS server helpers +// --------------------------------------------------------------------------- + +// startFakeDNS starts a UDP listener that handles a single request and responds +// using the provided handler. Returns the server address "host:port". +func startFakeDNS(t *testing.T, handler func(query []byte) []byte) string { + t.Helper() + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("startFakeDNS: %v", err) + } + t.Cleanup(func() { conn.Close() }) + go func() { + buf := make([]byte, 1232) + n, addr, err := conn.ReadFrom(buf) + if err != nil { + return + } + resp := handler(buf[:n]) + if resp != nil { + conn.WriteTo(resp, addr) //nolint:errcheck + } + }() + return conn.LocalAddr().String() +} + +// startFakeDNSMulti starts a UDP listener that handles multiple requests. +func startFakeDNSMulti(t *testing.T, handler func(query []byte) []byte) string { + t.Helper() + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("startFakeDNSMulti: %v", err) + } + t.Cleanup(func() { conn.Close() }) + go func() { + buf := make([]byte, 1232) + for { + n, addr, err := conn.ReadFrom(buf) + if err != nil { + return + } + resp := handler(buf[:n]) + if resp != nil { + conn.WriteTo(resp, addr) //nolint:errcheck + } + } + }() + return conn.LocalAddr().String() +} + +// queryID extracts the DNS message ID from the first 2 bytes. +func queryID(msg []byte) uint16 { + if len(msg) < 2 { + return 0 + } + return binary.BigEndian.Uint16(msg[:2]) +} + +// buildAResponse builds a DNS response with the given A records. +func buildAResponse(id uint16, name dnsmessage.Name, ips [][4]byte) []byte { + answers := make([]dnsmessage.Resource, len(ips)) + for i, ip := range ips { + answers[i] = dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{ + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + TTL: 60, + }, + Body: &dnsmessage.AResource{A: ip}, + } + } + msg := dnsmessage.Message{ + Header: dnsmessage.Header{ + ID: id, + Response: true, + RCode: dnsmessage.RCodeSuccess, + }, + Questions: []dnsmessage.Question{{ + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }}, + Answers: answers, + } + packed, err := msg.Pack() + if err != nil { + panic("buildAResponse: pack failed: " + err.Error()) + } + return packed +} + +// buildCNAMEResponse builds a DNS response containing a single CNAME record. +func buildCNAMEResponse(id uint16, queryName, cnameTarget dnsmessage.Name) []byte { + msg := dnsmessage.Message{ + Header: dnsmessage.Header{ + ID: id, + Response: true, + RCode: dnsmessage.RCodeSuccess, + }, + Questions: []dnsmessage.Question{{ + Name: queryName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }}, + Answers: []dnsmessage.Resource{{ + Header: dnsmessage.ResourceHeader{ + Name: queryName, + Type: dnsmessage.TypeCNAME, + Class: dnsmessage.ClassINET, + TTL: 60, + }, + Body: &dnsmessage.CNAMEResource{CNAME: cnameTarget}, + }}, + } + packed, err := msg.Pack() + if err != nil { + panic("buildCNAMEResponse: pack failed: " + err.Error()) + } + return packed +} + +// buildNXDOMAINResponse builds a DNS response with NXDOMAIN rcode. +func buildNXDOMAINResponse(id uint16, name dnsmessage.Name) []byte { + msg := dnsmessage.Message{ + Header: dnsmessage.Header{ + ID: id, + Response: true, + RCode: dnsmessage.RCodeNameError, // NXDOMAIN + }, + Questions: []dnsmessage.Question{{ + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }}, + } + packed, err := msg.Pack() + if err != nil { + panic("buildNXDOMAINResponse: pack failed: " + err.Error()) + } + return packed +} + +// mustNewName creates a dnsmessage.Name from an FQDN, panicking on error. +func mustNewName(fqdn string) dnsmessage.Name { + n, err := dnsmessage.NewName(fqdn) + if err != nil { + panic("mustNewName: " + err.Error()) + } + return n +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestLookupIP_ARecordSuccess(t *testing.T) { + name := mustNewName("example.com.") + serverAddr := startFakeDNS(t, func(query []byte) []byte { + id := queryID(query) + return buildAResponse(id, name, [][4]byte{ + {1, 1, 1, 1}, + {2, 2, 2, 2}, + }) + }) + + r := resolver.New() + ips, err := r.LookupIP("example.com", serverAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ips) != 2 { + t.Fatalf("expected 2 IPs, got %d: %v", len(ips), ips) + } + want := map[string]bool{"1.1.1.1": true, "2.2.2.2": true} + for _, ip := range ips { + if !want[ip] { + t.Errorf("unexpected IP %q", ip) + } + } +} + +func TestLookupIP_CNAMEChainToARecords(t *testing.T) { + queryName := mustNewName("alias.example.com.") + targetName := mustNewName("real.example.com.") + + callCount := 0 + serverAddr := startFakeDNSMulti(t, func(query []byte) []byte { + id := queryID(query) + callCount++ + if callCount == 1 { + // First query: return CNAME + return buildCNAMEResponse(id, queryName, targetName) + } + // Second query (for CNAME target): return A record + return buildAResponse(id, targetName, [][4]byte{{3, 3, 3, 3}}) + }) + + r := resolver.New() + ips, err := r.LookupIP("alias.example.com", serverAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ips) != 1 || ips[0] != "3.3.3.3" { + t.Errorf("expected [3.3.3.3], got %v", ips) + } +} + +func TestLookupIP_CNAMEDepthLimit(t *testing.T) { + // Always return a CNAME chain; the resolver should fail at depth > 10. + counter := 0 + serverAddr := startFakeDNSMulti(t, func(query []byte) []byte { + id := queryID(query) + counter++ + // Build a CNAME pointing to a unique target to avoid caching + srcName := mustNewName("host.example.com.") + targetName := mustNewName("host.example.com.") + return buildCNAMEResponse(id, srcName, targetName) + }) + + r := resolver.New() + _, err := r.LookupIP("host.example.com", serverAddr) + if err == nil { + t.Fatal("expected error for CNAME depth limit") + } + if !strings.Contains(err.Error(), "CNAME chain depth") { + t.Errorf("expected 'CNAME chain depth' in error, got: %v", err) + } +} + +func TestLookupIP_NXDOMAIN(t *testing.T) { + name := mustNewName("notexist.example.com.") + serverAddr := startFakeDNS(t, func(query []byte) []byte { + id := queryID(query) + return buildNXDOMAINResponse(id, name) + }) + + r := resolver.New() + _, err := r.LookupIP("notexist.example.com", serverAddr) + if err == nil { + t.Fatal("expected error for NXDOMAIN") + } + if !strings.Contains(err.Error(), "notexist.example.com") { + t.Errorf("error should mention hostname, got: %v", err) + } +} + +func TestLookupIP_ServerTimeout(t *testing.T) { + // Use a real listener but never reply — causes a read timeout. + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket: %v", err) + } + serverAddr := conn.LocalAddr().String() + conn.Close() // close immediately — resolver can't connect + + r := resolver.New() + done := make(chan error, 1) + go func() { + _, err := r.LookupIP("example.com", serverAddr) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected error for unreachable server") + } + case <-time.After(10 * time.Second): + t.Fatal("LookupIP did not return within 10s") + } +} + +func TestLookupIP_Deduplication(t *testing.T) { + name := mustNewName("example.com.") + serverAddr := startFakeDNS(t, func(query []byte) []byte { + id := queryID(query) + // Return the same IP twice + return buildAResponse(id, name, [][4]byte{ + {1, 1, 1, 1}, + {1, 1, 1, 1}, + }) + }) + + r := resolver.New() + ips, err := r.LookupIP("example.com", serverAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ips) != 1 { + t.Errorf("expected 1 unique IP after dedup, got %d: %v", len(ips), ips) + } +} + +func TestLookupIP_FQDNNormalization(t *testing.T) { + // Hostname without trailing dot — resolver must append it internally. + name := mustNewName("example.com.") + serverAddr := startFakeDNS(t, func(query []byte) []byte { + id := queryID(query) + return buildAResponse(id, name, [][4]byte{{5, 5, 5, 5}}) + }) + + r := resolver.New() + // Pass hostname WITHOUT trailing dot + ips, err := r.LookupIP("example.com", serverAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ips) != 1 || ips[0] != "5.5.5.5" { + t.Errorf("expected [5.5.5.5], got %v", ips) + } +} + +func TestLookupIP_ResponseIDMismatch(t *testing.T) { + name := mustNewName("example.com.") + serverAddr := startFakeDNS(t, func(query []byte) []byte { + id := queryID(query) + // Return response with wrong ID (XOR with 0xFFFF) + wrongID := id ^ 0xFFFF + return buildAResponse(wrongID, name, [][4]byte{{1, 1, 1, 1}}) + }) + + r := resolver.New() + _, err := r.LookupIP("example.com", serverAddr) + if err == nil { + t.Fatal("expected error for response ID mismatch") + } + if !strings.Contains(strings.ToLower(err.Error()), "mismatch") { + t.Errorf("error should mention 'mismatch', got: %v", err) + } +} diff --git a/specs/001-safety-reliability-refactor/tasks.md b/specs/001-safety-reliability-refactor/tasks.md index 0dde632..6193027 100644 --- a/specs/001-safety-reliability-refactor/tasks.md +++ b/specs/001-safety-reliability-refactor/tasks.md @@ -26,7 +26,7 @@ - **Key types/interfaces used**: None — this phase creates the skeleton - **Codebase conventions to follow**: Go standard project layout with sub-packages. Module name is `dns-helper` (from current `go.mod`). Build tags for platform-specific files use `//go:build` directive (Go 1.17+ format). -- [ ] T001 Create package directory structure per implementation plan +- [x] T001 Create package directory structure per implementation plan **Context**: - **Target state**: The project currently has a flat structure with all `.go` files in the root `main` package. Create four new package directories: @@ -40,7 +40,7 @@ - **Key decisions**: These package names were chosen in the plan (see plan.md "Project Structure" section). Each package has a single responsibility: `resolver` = DNS lookups, `hostfile` = hosts file read/parse/write, `platform` = OS-specific paths, `lockfile` = concurrency serialization. - **Acceptance signal**: All four directories exist at the repository root alongside the existing source files. -- [ ] T002 Update `go.mod` to Go 1.20 and add `golang.org/x/net v0.35.0` +- [x] T002 Update `go.mod` to Go 1.20 and add `golang.org/x/net v0.35.0` **Context**: - **Current go.mod**: @@ -75,7 +75,7 @@ - **Key types/interfaces used**: `os.FileInfo`, `os.FileMode`, `os.File` from Go stdlib - **Codebase conventions to follow**: All package functions return errors to caller (Constitution Principle IV). No `os.Exit()` outside `main()`. Use `fmt.Errorf("context: %w", err)` for error wrapping. Use Go 1.20 `//go:build` directive for build tags (not the legacy `// +build` comment). -- [ ] T003 [P] Create FileSystem interface and OS implementation in `hostfile/filesystem.go` +- [x] T003 [P] Create FileSystem interface and OS implementation in `hostfile/filesystem.go` **Context**: - **Implements**: The FileSystem abstraction from research R-007 for testability (Constitution Principle VI). All Host file I/O goes through this interface so tests can inject fakes. @@ -111,7 +111,7 @@ - **Key decisions (R-007)**: Production code uses `OSFileSystem{}`. Tests inject a fake struct that records calls and returns configurable errors. `Chmod` is included because atomic write must copy permissions from the original hosts file to the temp file before rename (R-002 step 2). - **Acceptance signal**: `go build ./hostfile/` compiles without errors. -- [ ] T004 [P] Create platform package with interface and implementations in `platform/` +- [x] T004 [P] Create platform package with interface and implementations in `platform/` **Context**: - **Implements**: The platform contract from `contracts/packages.md`: @@ -178,7 +178,7 @@ - **Gotcha**: Use `//go:build` directive (Go 1.17+ syntax), NOT the legacy `// +build` comment. Go 1.20 supports both but the new syntax is preferred. - **Acceptance signal**: `go build ./platform/` compiles without errors on the current OS. -- [ ] T005 [P] Create lockfile package in `lockfile/lockfile.go` +- [x] T005 [P] Create lockfile package in `lockfile/lockfile.go` **Context**: - **Implements**: The lockfile contract from `contracts/packages.md`: @@ -252,7 +252,7 @@ - **Gotcha**: `Release()` should not return error if the file is already gone — check for `os.IsNotExist` and suppress that specific error. - **Acceptance signal**: `go build ./lockfile/` compiles without errors. -- [ ] T006 [P] Create lockfile tests in `lockfile/lockfile_test.go` +- [x] T006 [P] Create lockfile tests in `lockfile/lockfile_test.go` **Context**: - **Target file state**: `lockfile/lockfile.go` exists from T005 with `Acquire()` and `Lock.Release()`. @@ -266,7 +266,7 @@ - **Key decisions**: Keep max wait short in tests or provide a way to configure timeout. Since the constants are package-level, tests may need to accept the 2-second wait for the "double acquire" test case. - **Acceptance signal**: `go test ./lockfile/` passes all test cases. -- [ ] T007 [P] Create platform tests in `platform/platform_test.go` +- [x] T007 [P] Create platform tests in `platform/platform_test.go` **Context**: - **Target file state**: `platform/` package exists from T004 with `GetHostsFilePath()` for each OS. @@ -309,7 +309,7 @@ > **NOTE: Write these tests FIRST, ensure they FAIL before implementation (TDD Red phase)** -- [ ] T008 [P] [US1] Write managed block parsing tests in `hostfile/managed_test.go` +- [x] T008 [P] [US1] Write managed block parsing tests in `hostfile/managed_test.go` **Context**: - **Depends on**: T003 (FileSystem interface exists so the package compiles). T009 (types must exist for tests to reference). @@ -333,7 +333,7 @@ ``` - **Acceptance signal**: Tests compile (with stubs) or fail with clear assertion errors. After T012 implementation, `go test ./hostfile/ -run TestManaged` passes. -- [ ] T009 [P] [US1] Write hostfile read/write tests in `hostfile/hostfile_test.go` +- [x] T009 [P] [US1] Write hostfile read/write tests in `hostfile/hostfile_test.go` **Context**: - **Depends on**: T003 (FileSystem interface for mocking). @@ -366,7 +366,7 @@ ### Implementation for User Story 1 -- [ ] T010 [US1] Create HostsFile and DNSEntry types with Manager interface in `hostfile/hostfile.go` +- [x] T010 [US1] Create HostsFile and DNSEntry types with Manager interface in `hostfile/hostfile.go` **Context**: - **Implements**: Types from `data-model.md` and Manager interface from `contracts/packages.md`: @@ -435,7 +435,7 @@ - **Gotcha**: Do NOT implement `Read()` or `Write()` yet — just the types, constants, and constructor. Read/Write are implemented in T013 and T014. - **Acceptance signal**: `go build ./hostfile/` compiles. Tests from T008/T009 can now reference the types (though they'll still fail on logic). -- [ ] T011 [US1] Implement managed block parsing and corruption detection in `hostfile/managed.go` +- [x] T011 [US1] Implement managed block parsing and corruption detection in `hostfile/managed.go` **Context**: - **Implements**: Managed block parsing that splits raw file lines into prefix/managed/postfix sections. Also implements corruption detection (R-008, FR-011). @@ -506,7 +506,7 @@ - **Gotcha**: The current code's `extractLinesToEdit` in `dataprep.go` (lines 14-51) has the zero-index bug. The new implementation must NOT replicate this. Use `-1` sentinel. - **Acceptance signal**: `go test ./hostfile/ -run TestParseManagedBlock` passes (corruption scenarios and valid parsing). -- [ ] T012 [US1] Implement AddEntries and content assembly in `hostfile/managed.go` +- [x] T012 [US1] Implement AddEntries and content assembly in `hostfile/managed.go` **Context**: - **Implements**: The `AddEntries` function from `contracts/packages.md`: @@ -581,7 +581,7 @@ - `AssembleContent` omits the managed block entirely when there are zero managed entries (per `data-model.md` ManagedBlock rules: "When all entries are removed, the entire block markers included is removed"). - **Acceptance signal**: `go test ./hostfile/ -run TestAddEntries` and `go test ./hostfile/ -run TestAssembleContent` pass. -- [ ] T013 [US1] Implement Read function in `hostfile/hostfile.go` +- [x] T013 [US1] Implement Read function in `hostfile/hostfile.go` **Context**: - **Implements**: The `Read` method on `Manager` from `contracts/packages.md`: @@ -628,7 +628,7 @@ - **Gotcha**: The `\r` trim loop MUST be included — without it, all managed block marker matching and hostname matching will fail on Windows because lines will have a trailing `\r`. The current codebase uses `bufio.Scanner` which handles this automatically, but `strings.Split` does not. - **Acceptance signal**: `go test ./hostfile/ -run TestRead` passes — correctly parses files with and without managed blocks. -- [ ] T014 [US1] Implement atomic Write with backup in `hostfile/hostfile.go` +- [x] T014 [US1] Implement atomic Write with backup in `hostfile/hostfile.go` **Context**: - **Implements**: The `Write` method on `Manager` from `contracts/packages.md`: @@ -759,7 +759,7 @@ > **NOTE: Write these tests FIRST, ensure they FAIL before implementation (TDD Red phase)** -- [ ] T015 [P] [US2] Write resolver tests in `resolver/resolver_test.go` +- [x] T015 [P] [US2] Write resolver tests in `resolver/resolver_test.go` **Context**: - **Depends on**: T016 (interface stubs must exist for tests to compile). @@ -796,7 +796,7 @@ ### Implementation for User Story 2 -- [ ] T016 [US2] Create Resolver interface and DNSResolver struct in `resolver/resolver.go` +- [x] T016 [US2] Create Resolver interface and DNSResolver struct in `resolver/resolver.go` **Context**: - **Implements**: Resolver interface from `contracts/packages.md`: @@ -834,7 +834,7 @@ - **Key decisions (R-001)**: Constants match the contract: 5s timeout, max 10 CNAME depth, 1232-byte UDP buffer (standard DNS over UDP limit). - **Acceptance signal**: `go build ./resolver/` compiles. -- [ ] T017 [US2] Implement LookupIP with dnsmessage, CNAME chain, and dedup in `resolver/resolver.go` +- [x] T017 [US2] Implement LookupIP with dnsmessage, CNAME chain, and dedup in `resolver/resolver.go` **Context**: - **Implements**: The full `LookupIP` method on `DNSResolver`. This replaces the current `lookupIP` function in `dns.go` which has two bugs: @@ -1041,7 +1041,7 @@ > **NOTE: Write these tests FIRST, ensure they FAIL before implementation (TDD Red phase)** -- [ ] T018 [P] [US3] Write deletion tests in `hostfile/managed_test.go` +- [x] T018 [P] [US3] Write deletion tests in `hostfile/managed_test.go` **Context**: - **Target file state**: `hostfile/managed_test.go` already exists from T008 with parsing and AddEntries tests. Append new test functions for deletion. @@ -1072,7 +1072,7 @@ ### Implementation for User Story 3 -- [ ] T019 [US3] Implement RemoveByHostname in `hostfile/managed.go` +- [x] T019 [US3] Implement RemoveByHostname in `hostfile/managed.go` **Context**: - **Implements**: `RemoveByHostname` from `contracts/packages.md`: @@ -1106,7 +1106,7 @@ - **Key decisions (R-004)**: The critical fix is inverting the loop order — iterate LINES on the outside, check ALL hostnames on the inside. The buggy code iterated hostnames on the outside and re-scanned original content each pass, producing duplicates and failing to remove. - **Acceptance signal**: `go test ./hostfile/ -run TestRemoveByHostname` passes all 7 test cases from T018. -- [ ] T020 [US3] Implement RemoveAll in `hostfile/managed.go` +- [x] T020 [US3] Implement RemoveAll in `hostfile/managed.go` **Context**: - **Implements**: `RemoveAll` from `contracts/packages.md`: @@ -1163,7 +1163,7 @@ ``` - **Codebase conventions to follow**: Only `main()` calls `os.Exit()`. Errors printed to stderr (`fmt.Fprintf(os.Stderr, ...)`). Informational output to stdout. Banner prints on every invocation. See `contracts/cli.md` for exact output format. -- [ ] T021 [US4] Rewrite `main.go` with CLI parsing, validation, and full package integration +- [x] T021 [US4] Rewrite `main.go` with CLI parsing, validation, and full package integration **Context**: - **Replaces**: The entire current `main.go` (100 lines). The current code has multiple problems: @@ -1495,7 +1495,7 @@ - **Gotcha**: When `delete -host` finds no matching entries, report "No managed entries found" but exit 0 (not an error per CLI contract). - **Acceptance signal**: `go build .` compiles. Manual testing of `dns-helper add -host ...`, `dns-helper delete -host ...`, `dns-helper delete all`, `dns-helper` (no args), and `dns-helper invalid` all produce expected output per `contracts/cli.md`. -- [ ] T022 [US4] Verify all CLI helpers compile and match `contracts/cli.md` output +- [x] T022 [US4] Verify all CLI helpers compile and match `contracts/cli.md` output **Context**: - **Target file state**: `main.go` now contains complete implementations from T021: `main()`, `runAdd()`, `runDelete()`, `runDeleteAll()`, and `printUsage()`. @@ -1522,7 +1522,7 @@ - **Key types/interfaces used**: `func GetHostsFilePath() (string, error)` from T004 - **Codebase conventions to follow**: Error messages include the path that was checked. Use `fmt.Errorf` with `%w` for wrapping. No `os.Exit`, `log.Fatal`, or `term()` anywhere in the platform package. -- [ ] T023 [US5] Audit platform implementations for error consistency and fix if needed +- [x] T023 [US5] Audit platform implementations for error consistency and fix if needed **Context**: - **Current state**: The platform package was created from scratch in T004 with correct error handling. This task verifies the implementations are consistent and adds any missing error detail. @@ -1548,7 +1548,7 @@ - **Expected state after this task**: All three platform files use `os.Stat` + `fmt.Errorf` and return errors. No calls to `term()`, `os.Exit()`, `log.Fatal()`, or any process-terminating function. - **Acceptance signal**: `grep -r "os.Exit\|log.Fatal\|term(" platform/` returns no results. `go vet ./platform/` passes. -- [ ] T024 [P] [US5] Add cross-platform error message tests in `platform/platform_test.go` +- [x] T024 [P] [US5] Add cross-platform error message tests in `platform/platform_test.go` **Context**: - **Target file state**: `platform/platform_test.go` exists from T007 with basic happy-path tests. @@ -1573,7 +1573,7 @@ - **Files modified**: `go.mod`, `go.sum` (via `go mod tidy`) - **Codebase conventions to follow**: Run `go vet ./...` and `go build ./...` to verify cleanliness. No unused imports or variables. -- [ ] T025 [US6] Remove old source files from repository root +- [x] T025 [US6] Remove old source files from repository root **Context**: - **Files to delete** (all at repository root): @@ -1594,7 +1594,7 @@ - **Gotcha**: After deleting these files, `go build .` should succeed because `main.go` no longer imports `miekg/dns` or references any of the old functions. If compilation fails, check that `main.go` doesn't reference any old function names. - **Acceptance signal**: All 7 old files deleted. `go build .` succeeds on the current OS. -- [ ] T026 [US6] Run `go mod tidy` to remove `miekg/dns` and transitive dependencies +- [x] T026 [US6] Run `go mod tidy` to remove `miekg/dns` and transitive dependencies **Context**: - **Current go.mod dependencies** (after T002): @@ -1619,7 +1619,7 @@ - **Expected implementation**: Run `go mod tidy`. Verify `go.mod` no longer contains `miekg/dns`, `x/mod`, or `x/tools`. Run `go list -m all` to confirm only approved dependencies remain. - **Acceptance signal**: `go mod tidy` succeeds. `go list -m all` shows only `golang.org/x/net` and `golang.org/x/sys`. No unapproved dependencies (FR-010, SC-006). -- [ ] T027 [US6] Verify clean build, all tests pass, and no dead code +- [x] T027 [US6] Verify clean build, all tests pass, and no dead code **Context**: - **Verification checklist** (from spec SC-006): @@ -1640,7 +1640,7 @@ **Purpose**: Final validation against quickstart scenarios and acceptance criteria -- [ ] T028 Run quickstart validation scenarios from `quickstart.md` +- [x] T028 Run quickstart validation scenarios from `quickstart.md` **Context**: - **Quickstart scenarios** (from `quickstart.md`): @@ -1656,7 +1656,7 @@ 5. `go mod tidy` produces no changes (already clean). - **Acceptance signal**: All quickstart commands succeed. The tool is ready for manual integration testing. -- [ ] T029 Final review: verify all success criteria from spec.md +- [x] T029 Final review: verify all success criteria from spec.md **Context**: - **Success criteria checklist** (from spec.md): diff --git a/structs.go b/structs.go deleted file mode 100644 index ee9713d..0000000 --- a/structs.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -type workingData struct { - HostsFileLocation string - IsNew bool - Hosts []string - Resolvers []string - DefaultContent []string - PrefixContent []string - NewContent []string - ExistingContent []string - PostfixContent []string -}