feat: Implement platform-specific hosts file path retrieval
- Created a new `platform` package to handle OS-specific hosts file paths. - Added implementations for macOS, Linux, and Windows to retrieve the hosts file path. - Introduced tests for the `GetHostsFilePath` function to ensure correct behavior across platforms. feat: Add DNS resolver for IP lookups - Implemented a `resolver` package for performing DNS lookups. - Created a `DNSResolver` struct with a `LookupIP` method to handle A-record lookups and CNAME resolution. - Added comprehensive tests for various DNS scenarios, including CNAME chains and error handling. chore: Refactor project structure and update dependencies - Restructured the project to follow Go's standard package layout. - Updated `go.mod` to Go 1.20 and added `golang.org/x/net` dependency. - Removed old source files and ensured a clean build with all tests passing.
This commit is contained in:
@@ -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-<os>-<arch>[.exe]` (cross-compile).
|
||||
Release packages: `releases\dns-helper-<version>-<os>-<arch>.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`)
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
## 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.
|
||||
|
||||
+19
-1
@@ -1,5 +1,23 @@
|
||||
# Build outputs
|
||||
build/
|
||||
bin/
|
||||
*.exe
|
||||
*.out
|
||||
*.test
|
||||
|
||||
# Dependencies
|
||||
vendor/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temp and logs
|
||||
*.tmp
|
||||
*.swp
|
||||
*.log
|
||||
.env*
|
||||
Vendored
+2
-1
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
".specify/scripts/bash/": true,
|
||||
".specify/scripts/powershell/": true
|
||||
".specify/scripts/powershell/": true,
|
||||
"&": true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-<os>-<arch>[.exe]
|
||||
# - Release packages: releases\dns-helper-<version>-<os>-<arch>.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 <hostname> <ip>" -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
|
||||
}
|
||||
@@ -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
|
||||
-133
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <backupDir>/hosts.bak.<YYYYMMDD>-<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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package platform provides the platform-specific hosts file path.
|
||||
package platform
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
-13
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user