16 KiB
Research: Safety, Reliability & Idiomatic Refactor
Feature Branch: 001-safety-reliability-refactor
Date: 2026-03-03
R-001: Replacing github.com/miekg/dns with golang.org/x/net/dns/dnsmessage
Decision
Use golang.org/x/net/dns/dnsmessage (part of the approved golang.org/x source) to replace all DNS query/response functionality currently provided by github.com/miekg/dns.
Rationale
dnsmessageprovides low-level DNS message building and parsing (A, CNAME, and all standard record types viaMessage.Pack()/Message.Unpack()or the streamingBuilder/ParserAPI).- The current usage surface of
miekg/dnsis narrow: build a query, send over UDP, parse A/CNAME responses.dnsmessagehandles all of this. - Eliminates 4 transitive dependencies (
x/mod,x/sys,x/toolspulled in bymiekg/dns) from the dependency tree. golang.org/x/netis already in the project's dependency list and is an approved source per the constitution.
Go 1.20 Compatibility
golang.org/x/net v0.35.0is the latest version withgo 1.18directive (compatible with Go 1.20). Starting atv0.36.0, the module requiresgo 1.23.0.- The
dnsmessagesub-package API is stable and has not changed materially across these versions. - Pin to
golang.org/x/net v0.35.0ingo.mod.
Implementation Pattern
- Use
Message.Pack()to build queries (simpler API, adequate for single-query CLI tool). - Use
Message.Unpack()to parse responses. - Send raw DNS packets over UDP using
net.DialTimeout("udp", server+":53", timeout). No length prefix needed for UDP (only TCP requires a 2-byte length prefix). - DNS names must be FQDN with trailing dot:
dnsmessage.MustNewName("example.com."). AResource.Ais[4]byte, convert to string vianet.IP(r.A[:]).String().- Response buffer of 1232 bytes is sufficient for standard UDP DNS responses.
- Validate response
Header.IDmatches query ID to prevent spoofing. - Default 5-second timeout via
conn.SetDeadline().
Key Type Mappings (miekg/dns → dnsmessage)
| miekg/dns | dnsmessage |
|---|---|
dns.Client{} + c.Exchange() |
net.DialTimeout("udp", ...) + conn.Write()/conn.Read() |
dns.Msg{}.SetQuestion() |
dnsmessage.Message{}.Pack() |
dns.TypeA |
dnsmessage.TypeA |
dns.TypeCNAME |
dnsmessage.TypeCNAME |
*dns.A → rec.A.String() |
*dnsmessage.AResource → net.IP(r.A[:]).String() |
*dns.CNAME → rec.Target |
*dnsmessage.CNAMEResource → r.CNAME.String() |
dns.RcodeSuccess |
dnsmessage.RCodeSuccess |
dns.RcodeToString[code] |
rcode.String() (method on RCode type) |
Alternatives Considered
net.Resolverwith customDialer: The Go stdlibnet.Resolverdoes not support querying a specific DNS server by address without hacky workarounds. The tool's core purpose requires directing queries to a user-specified resolver.- Keep
miekg/dns: Violates Constitution Principle II (Standard Library First). The constitution explicitly calls this a "grandfather clause" dependency with preferred migration. - Raw UDP + manual DNS wire format: Would work but requires implementing DNS message serialization from scratch.
dnsmessagealready does this correctly and is an approved package.
R-002: Atomic File Write Strategy (temp-file-and-rename)
Decision
Write new hosts file content to a temporary file in the same directory, then os.Rename() over the original. Combined with a pre-write backup in the executable's directory.
Rationale
os.Rename()on same-volume is atomic (or effectively atomic) on all three target platforms:- Linux: POSIX
rename(2)is atomic. Guaranteed by the kernel. - macOS: Darwin
rename(2)follows POSIX semantics. Atomic on both APFS and HFS+. - Windows: Go's
os.RenamecallsMoveFileExwithMOVEFILE_REPLACE_EXISTING. Effectively atomic on NTFS (MFT metadata update), though Microsoft does not formally guarantee atomicity.
- Linux: POSIX
- The temp file must be in the same directory as the hosts file to avoid cross-filesystem/cross-volume failures (
EXDEVon Unix, copy+delete on Windows). - The original hosts file is never opened for writing or truncated.
Go 1.20 Compatibility
os.CreateTemp(dir, pattern)available since Go 1.16.os.Rename()stable across all Go versions.- No known bugs in Go 1.20 affecting this pattern.
Implementation Details
- Temp file creation:
os.CreateTemp(filepath.Dir(hostsPath), ".dns-helper-tmp-*")— creates file in same directory with recognizable prefix. - Permissions: The renamed file retains the temp file's permissions, NOT the original's. Must
os.Stat(hostsPath)to get original mode, thenos.Chmod(tempFile, originalMode)before renaming. - Close before rename: Required on Windows — cannot rename a file that's still open by the writing process.
- Sync before close: Call
file.Sync()for durability before closing. - Cleanup on failure: Use a
deferwith a success flag to remove the temp file if any step fails. - Windows retry:
os.Renameon the hosts file may fail withERROR_SHARING_VIOLATIONif Windows Defender or the DNS Client service has a transient read lock. Implement 2-3 retry attempts with 100-200ms delay. - Orphan cleanup: On startup, optionally clean stale
.dns-helper-tmp-*files in the hosts directory.
Backup Strategy (FR-002)
- Before any write operation, copy the current hosts file to
<exe-dir>/hosts.bak.<date>-<random>(e.g.,hosts.bak.20260303-a7f3). - The backup is a regular file copy (not rename), since it's in a different directory (exe dir vs hosts file dir).
- On successful rename of temp over hosts file: delete the backup.
- On rename failure: the original hosts file was never modified. Clean up temp file and backup.
- The date+random naming handles concurrent instances.
Alternatives Considered
- Write directly to hosts file (current approach): Uses
O_WRONLY|O_TRUNC, which immediately truncates the file. A crash during write leaves it empty/partial. This is the bug being fixed. - Backup-only (no atomic rename): Would still truncate the hosts file during write. The backup only helps after a failure, not during.
- Write to temp, then copy content over original: The copy step is not atomic — a crash during copy is the same as a crash during direct write.
R-003: Lock File Implementation
Decision
Use os.OpenFile with O_CREATE|O_EXCL to create a lock file in the executable's directory. Use timeout-based staleness detection (file modification time).
Rationale
O_CREATE|O_EXCLis atomic on all three platforms (Windows maps toCREATE_NEW, Unix maps toO_CREAT|O_EXCL).- Single code path — no platform-specific build files needed for the lock mechanism.
- Timeout-based staleness avoids the cross-platform pitfalls of PID-based detection (Windows doesn't support
Signal(0)for process existence checks withoutx/sys).
Implementation Details
| Parameter | Value | Rationale |
|---|---|---|
| Lock file name | .dns-helper.lock |
Hidden file, recognizable prefix |
| Lock location | Executable's directory | Same as backup files; writable by admin/root (required anyway) |
| Stale timeout | 2 minutes | Lock held only during read-modify-write (<1s normally). 2min is very conservative. |
| Retry interval | 200ms | Short enough to be responsive, long enough to avoid busy-wait |
| Max wait | 2 seconds | 10 retries. Enough for another instance's write cycle. |
Lock protocol per FR-016:
- Parse args and validate input.
- Perform DNS resolution (before lock acquisition to minimize lock hold time).
- Acquire lock (retry up to 2s, break stale locks >2min).
- Read hosts file → modify content → write temp file → rename over hosts file.
- Release lock (delete lock file).
- Lock release in
deferto handle error paths and panics.
Staleness detection:
os.Stat(lockFile).ModTime()— if older than 2 minutes,os.Remove()and retry.- Cross-platform via Go stdlib. No platform-specific code needed.
Signal handling:
defer lock.Release()handles normal exit and errors.- For SIGKILL/power loss, the stale lock persists. Next invocation detects and breaks it after 2 minutes.
- Write PID to lock file for debugging (human inspection), but don't use it for staleness logic.
Alternatives Considered
- Advisory file locking (
flock/LockFileEx): Auto-releases on crash (no stale lock problem), but requires 3 platform-specific build files.syscall.Flockdoesn't exist on Windows. Adds complexity for marginal benefit. - PID-based staleness: More precise than timeout, but
os.FindProcess+Signal(0)doesn't work on Windows withoutgolang.org/x/sys/windows. Defeats the single-code-path advantage. - No locking: Risk of two instances writing the hosts file simultaneously. The spec explicitly requires serialization (FR-016).
R-004: Multi-Host Deletion Bug Analysis
Decision
Fix the nested loop logic in removeHostsFromExistingContent by inverting the loop order (iterate lines on the outside, check all hosts on the inside).
Rationale — Bug Analysis
Current code:
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
}
Bug: The inner loop always iterates over data.ExistingContent (the original, unfiltered content). updatedContent is never reset between outer iterations. Result:
- Lines removed by one host are re-added by another host's pass (re-scans original).
- Surviving lines are duplicated once per host in
data.Hosts.
Trace with Hosts = ["host1", "host2"], ExistingContent = ["1.1.1.1\thost1", "2.2.2.2\thost2", "3.3.3.3\thost3"]:
- Pass 1 (host1): keeps
host2,host3→updatedContent = [host2, host3] - Pass 2 (host2): re-scans original, keeps
host1,host3→updatedContent = [host2, host3, host1, host3] - Result: Both hosts survive,
host3is duplicated. Every invariant violated.
Correct Algorithm
Iterate lines on the outside; for each line, check if it matches ANY host:
func removeHostsFromExistingContent(data *workingData) {
var updatedContent []string
for _, line := range data.ExistingContent {
shouldRemove := false
for _, host := range data.Hosts {
if strings.Contains(line, host) {
shouldRemove = true
break
}
}
if !shouldRemove {
updatedContent = append(updatedContent, line)
}
}
data.NewContent = updatedContent
}
R-005: CNAME Resolution Duplicate IP Bug Analysis
Decision
Fix the self-appending loop in the CNAME resolution path of lookupIP() and add a max-depth limit for CNAME chain following.
Rationale — Bug Analysis
Current code:
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)
}
}
}
Bug: ips is the result of the recursive lookupIP call. The for range then iterates over ips and appends each element back to itself. In Go, for range evaluates the slice length once at the start, so this is not infinite — it exactly doubles every element. With ips = ["10.0.0.1", "10.0.0.2"], the result is ["10.0.0.1", "10.0.0.2", "10.0.0.1", "10.0.0.2"].
Additional issue: No CNAME chain depth limit. Circular CNAMEs would cause infinite recursion.
Correct Algorithm
for _, ans := range r.Answer {
if rec, ok := ans.(*dns.CNAME); ok {
cnameIPs := lookupIP(rec.Target, resolver)
ips = append(ips, cnameIPs...)
}
}
Plus a max-depth parameter (10 levels) to prevent infinite CNAME recursion.
R-006: Go 1.20 Dependency Cleanup
Decision
Update go.mod to go 1.20. Pin golang.org/x/net to v0.35.0. Remove github.com/miekg/dns and all its transitive dependencies.
Rationale
Current go.mod dependencies:
github.com/miekg/dns v1.1.59 ← removing (FR-014)
golang.org/x/mod v0.16.0 ← transitive dep of miekg/dns, will be removed
golang.org/x/net v0.22.0 ← keeping, updating to v0.35.0
golang.org/x/sys v0.18.0 ← transitive dep of x/net, will be updated
golang.org/x/tools v0.19.0 ← transitive dep of miekg/dns, will be removed
After cleanup:
golang.org/x/net v0.35.0 ← for dns/dnsmessage
golang.org/x/sys (transitive) ← pulled by x/net
golang.org/x/mod and golang.org/x/tools are only needed by miekg/dns and will be removed by go mod tidy after removing the miekg/dns import.
Version Verification
golang.org/x/net v0.35.0:go 1.18directive — compatible with Go 1.20.golang.org/x/net v0.36.0:go 1.23.0directive — NOT compatible. Must pin at v0.35.0.
R-007: Testability Design for TDD (Constitution Principle VI)
Decision
Define interfaces for external dependencies (filesystem, DNS, lock) to enable testing with fakes/stubs. All business logic operates on interfaces, not concrete OS calls.
Rationale
Constitution Principle VI mandates TDD with testable interfaces. The current codebase has zero tests and directly calls OS-level I/O from business logic.
Interface Design
Filesystem interface (for hostfile package):
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
}
Resolver interface (for resolver package):
type Resolver interface {
LookupIP(hostname, server string) ([]string, error)
}
Lock interface (for lockfile package):
type Lock interface {
Release() error
}
type Locker interface {
Acquire(dir string) (Lock, error)
}
Production code uses real implementations. Tests inject fakes that simulate errors, crashes, and edge cases without touching the real filesystem or DNS.
Test Categories
- Unit tests: Managed block parsing, line removal, deduplication, input validation — pure logic, no I/O.
- Integration tests with fakes: Atomic write sequence, backup creation/cleanup, lock acquisition/release — use in-memory filesystem fakes.
- Platform tests:
getHostsFileLocation()on each OS — verify return value and error handling against build tags.
R-008: Managed Block Corruption Detection (FR-011)
Decision
Detect and report four corruption scenarios for managed block markers. Refuse to modify the file when corruption is detected.
Rationale
The current extractLinesToEdit function detects "start without end" via a fileCorrupted flag, but the error message ("content has been corrupted") is vague and doesn't cover all cases.
Corruption Scenarios
| Scenario | Detection | Error Message |
|---|---|---|
| Start marker without end marker | startIndex > 0 && endIndex == 0 |
"managed block is corrupt: start marker found at line N but no end marker" |
| End marker without start marker | startIndex == 0 && endIndex > 0 |
"managed block is corrupt: end marker found at line N but no start marker" |
| End marker before start marker | endIndex < startIndex |
"managed block is corrupt: end marker (line N) appears before start marker (line M)" |
| Duplicate start or end markers | Multiple occurrences detected during scan | "managed block is corrupt: duplicate start/end markers found" |
All corruption scenarios must result in an error returned to the caller. The hosts file must not be modified.
Current Code Bug
The current code uses startIndex == 0 to mean "not found," but index 0 is a valid line position (the first line). Should use a sentinel value (-1) or a boolean flag for "found/not found."