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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user