Files
ExploreDNS/internal/output/text.go
T
368f200d23
CI / test (pull_request) Failing after 3m40s
Phase 4.1: Error handling, edge cases, and robustness
- Exponential backoff retry logic (100ms, 200ms, 400ms... capped at 2s)
  replacing fixed 100ms delay between retries
- Explicit REFUSED and NOTIMP response types (RespREFUSED, RespNOTIMPL)
  surfaced as terminal results with user-visible messages
- CNAME loop detection: walking the ancestor referral chain before
  following a CNAME prevents infinite recursion; produces RespCNAMELoop
- DNAME record support: synthesize CNAME target from DNAME mapping when
  the server omits the RFC 6672 synthesized CNAME record
- ErrorMessage field on Response for surfacing error details to users
- Fix resolveGlueViaSystem timeout bug: deadline.Sub(deadline) was
  always 0; replaced with time.Until(deadline)
- DNAME records excluded from hasFinalAnswer so DNAME-only responses
  are correctly classified as RespCNAMEFollow
- Text and JSON output updated with labels for all new response types
- Tests: CNAME loop (2-step and direct), REFUSED, NOTIMP, graceful
  degradation (partial and total server failure), DNAME synthesis,
  IsNameInChain, backoffDelay, ResponseClassification strings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 03:11:46 +10:00

292 lines
7.3 KiB
Go

package output
import (
"fmt"
"io"
"sort"
"strings"
"github.com/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse"
)
type textFormatter struct {
cfg *Config
w io.Writer
}
func newTextFormatter(cfg *Config, w io.Writer) *textFormatter {
return &textFormatter{cfg: cfg, w: w}
}
func (f *textFormatter) WriteProgress(event traverse.TraversalEvent) error {
if event.Stage != traverse.EventStart {
return nil
}
line := f.formatReferralLine(event.Result, false)
if !event.Result.Referral.HasAddresses() {
line += " -- resolving"
}
return f.writeLine(line)
}
func (f *textFormatter) WriteResolve(event traverse.TraversalEvent) error {
if event.Stage != traverse.EventStart {
return nil
}
return f.writeLine(f.formatReferralLine(event.Result, true))
}
func (f *textFormatter) WriteResult(result traverse.TraversalResult) error {
if result.Response == nil || result.Referral == nil {
return nil
}
prefix := strings.Repeat(" ", result.Referral.Depth+1)
line := prefix + f.formatResultLine(result)
return f.writeLine(line)
}
func (f *textFormatter) WriteSummary(results []traverse.TraversalResult) error {
if f.cfg.ShowServers {
if err := f.writeServers(results); err != nil {
return err
}
}
if f.cfg.ShowResults {
if err := f.writeResults(results); err != nil {
return err
}
}
if f.cfg.ShowSummaryResults {
if err := f.writeSummaryResults(results); err != nil {
return err
}
}
return nil
}
func (f *textFormatter) Flush() error {
return nil
}
func (f *textFormatter) writeServers(results []traverse.TraversalResult) error {
servers := collectServers(results)
if len(servers) == 0 {
return nil
}
if _, err := fmt.Fprintln(f.w, "The following servers were encountered:"); err != nil {
return err
}
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
return strings.ToLower(names[i]) > strings.ToLower(names[j])
})
width := 16
for _, name := range names {
if len(name) > width {
width = len(name)
}
}
for _, name := range names {
for _, ip := range servers[name] {
line := fmt.Sprintf("%*s: %-15s", width, name, ip)
if f.cfg.ShowVersions {
if version, ok := f.cfg.Fingerprints[ip]; ok && version != "" {
line += " " + version
}
}
if _, err := fmt.Fprintln(f.w, line); err != nil {
return err
}
}
}
_, err := fmt.Fprintln(f.w)
return err
}
func (f *textFormatter) writeResults(results []traverse.TraversalResult) error {
if _, err := fmt.Fprintln(f.w, "Results:"); err != nil {
return err
}
terminal := terminalResults(results)
for _, result := range terminal {
prefix := strings.Repeat(" ", result.Referral.Depth+1)
line := prefix + f.formatResultLine(result)
if _, err := fmt.Fprintln(f.w, line); err != nil {
return err
}
}
_, err := fmt.Fprintln(f.w)
return err
}
func (f *textFormatter) writeSummaryResults(results []traverse.TraversalResult) error {
stats := ComputeSummary(results)
if stats == nil {
return nil
}
if _, err := fmt.Fprintln(f.w, "Summary:"); err != nil {
return err
}
prefix := " "
for _, answer := range stats.Answers {
line := fmt.Sprintf("%s%s answered with %s", prefix, formatProbability(answer.Prob), answer.RData)
if _, err := fmt.Fprintln(f.w, f.colorize(line, colorGreen)); err != nil {
return err
}
}
types := make([]string, 0, len(stats.ByType))
for respType := range stats.ByType {
types = append(types, respType)
}
sort.Strings(types)
for _, respType := range types {
line := fmt.Sprintf("%s%s %s", prefix, formatProbability(stats.ByType[respType]), summaryTypeLabel(respType))
if _, err := fmt.Fprintln(f.w, line); err != nil {
return err
}
}
_, err := fmt.Fprintln(f.w)
return err
}
func (f *textFormatter) formatReferralLine(result traverse.TraversalResult, isResolve bool) string {
ref := result.Referral
if ref == nil {
return ""
}
indent := strings.Repeat(" ", ref.Depth)
refID := referralID(ref)
server := referralServerLabel(ref, result.Response)
qtype := dns.QNameType(ref.Qtype)
qname := trimDomain(ref.Name)
if f.cfg.Verbose {
bailiwick := trimDomain(ref.Bailiwick)
if isResolve {
return fmt.Sprintf("%s%s [%s] %s <%s>", indent, refID, qname, server, bailiwick)
}
return fmt.Sprintf("%s%s [%s] %s <%s> (%s)", indent, refID, qname, server, bailiwick, qtype)
}
if isResolve {
return fmt.Sprintf("%s%s %s", indent, refID, server)
}
return fmt.Sprintf("%s%s %s (%s)", indent, refID, server, qtype)
}
func (f *textFormatter) formatResultLine(result traverse.TraversalResult) string {
prob := formatProbability(result.Referral.Prob)
switch result.Response.Type {
case traverse.RespAnswer:
key, rrs := answerKey(result.Response)
if key == "" {
return fmt.Sprintf("%s resulted in answer", prob)
}
if len(rrs) == 1 {
return f.colorize(fmt.Sprintf("%s answered with %s", prob, rrs[0]), colorGreen)
}
return f.colorize(fmt.Sprintf("%s answered with %s", prob, strings.Join(rrs, " / ")), colorGreen)
case traverse.RespNODATA:
return fmt.Sprintf("%s found no such record", prob)
case traverse.RespNXDOMAIN:
return f.colorize(fmt.Sprintf("%s name does not exist", prob), colorYellow)
case traverse.RespSERVFAIL:
return f.colorize(fmt.Sprintf("%s resulted in SERVFAIL", prob), colorRed)
case traverse.RespREFUSED:
return f.colorize(fmt.Sprintf("%s query refused by server", prob), colorRed)
case traverse.RespNOTIMPL:
return f.colorize(fmt.Sprintf("%s query type not implemented by server", prob), colorRed)
case traverse.RespCNAMELoop:
msg := "CNAME loop detected"
if result.Response.ErrorMessage != "" {
msg = result.Response.ErrorMessage
}
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorRed)
case traverse.RespError:
msg := "resulted in an error"
if result.Response.ErrorMessage != "" {
msg = result.Response.ErrorMessage
}
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorRed)
default:
return fmt.Sprintf("%s %s", prob, result.Response.Type)
}
}
func (f *textFormatter) writeLine(line string) error {
if line == "" {
return nil
}
_, err := fmt.Fprintln(f.w, line)
return err
}
func (f *textFormatter) colorize(text, color string) string {
if !f.cfg.Color || color == "" {
return text
}
return color + text + colorReset
}
func referralID(ref *traverse.Referral) string {
if ref == nil {
return ""
}
return fmt.Sprintf("%d", ref.Depth+1)
}
func referralServerLabel(ref *traverse.Referral, resp *traverse.Response) string {
if resp != nil && resp.Server != nil {
return resp.Server.String()
}
if ref.HasAddresses() {
ips := make([]string, 0, len(ref.Addresses))
for _, addr := range ref.Addresses {
ips = append(ips, addr.String())
}
return strings.Join(ips, ", ")
}
if ref.NSName != "" {
return ref.NSName
}
if ref.Bailiwick != "" && ref.Bailiwick != "." {
return trimDomain(ref.Bailiwick)
}
return "unknown"
}
func terminalResults(results []traverse.TraversalResult) []traverse.TraversalResult {
var terminal []traverse.TraversalResult
for _, result := range results {
if result.Response != nil && result.Response.IsTerminal() {
terminal = append(terminal, result)
}
}
return terminal
}
const (
colorReset = "\033[0m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorRed = "\033[31m"
)