Skip to content

Fix intermittent SPF not found in dns command#16

Merged
linyows merged 2 commits into
mainfrom
fix-spf-intermittent-not-found
Apr 20, 2026
Merged

Fix intermittent SPF not found in dns command#16
linyows merged 2 commits into
mainfrom
fix-spf-intermittent-not-found

Conversation

@linyows
Copy link
Copy Markdown
Owner

@linyows linyows commented Apr 20, 2026

Summary

  • Fix reports dns intermittently showing SPF as "not found" for domains with multiple TXT records
  • Root cause: DNS TXT parser only returned the first answer record; DNS response order is not guaranteed
  • Add queryAllTxt to ipinfo.zig that parses all TXT answer records from a DNS response
  • Update SPF lookup in cmdDns to search all TXT records for v=spf1

Details

Domains like tomohisaoda.com have multiple TXT records (SPF + google-site-verification). The previous parseTxtFromResponse only extracted the first answer record. When the non-SPF record came first in the DNS response, SPF was incorrectly reported as not found.

Test plan

  • zig build compiles successfully
  • zig build test all tests pass
  • zig fmt --check src/ passes
  • reports dns --domain tomohisaoda.com consistently finds SPF across 5 consecutive runs

🤖 Generated with Claude Code

The DNS TXT parser only returned the first answer record. When a domain
has multiple TXT records (e.g. SPF + google-site-verification), the SPF
record was missed if it wasn't first in the response. DNS response order
is not guaranteed, causing intermittent failures.

Add queryAllTxt to parse all TXT answer records, and use it for SPF
lookup so all records are searched for v=spf1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes intermittent false negatives in reports dns SPF detection by ensuring TXT lookups consider all TXT answer records rather than only the first (since DNS answer order is not guaranteed).

Changes:

  • Add queryAllTxt and parseAllTxtFromResponse to return all TXT records from a DNS response.
  • Refactor parseTxtFromResponse to reuse the new multi-answer parser.
  • Update cmdDns SPF detection to scan all TXT records for v=spf1.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/ipinfo.zig Adds multi-record TXT query/parsing and refactors existing single-TXT parsing to build on it.
src/cli/commands.zig Updates SPF lookup to iterate all TXT records and select the SPF entry.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/ipinfo.zig Outdated
Comment on lines +439 to +447
var results = try parseAllTxtFromResponse(allocator, data);
defer {
for (results) |r| allocator.free(r);
if (results.len > 0) allocator.free(results);
}
if (results.len == 0) return error.NoAnswer;
// Return first record; others are freed by defer above
const first = results[0];
results[0] = "";
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In parseTxtFromResponse, the defer frees every element in results, but the function returns results[0]. The current approach relies on mutating results[0] later to an empty slice to avoid freeing it, which is fragile and makes ownership unclear. Prefer explicitly extracting first, then freeing results[1..], and finally freeing the outer results slice (even if empty) so the returned slice is never part of the cleanup path.

Suggested change
var results = try parseAllTxtFromResponse(allocator, data);
defer {
for (results) |r| allocator.free(r);
if (results.len > 0) allocator.free(results);
}
if (results.len == 0) return error.NoAnswer;
// Return first record; others are freed by defer above
const first = results[0];
results[0] = "";
const results = try parseAllTxtFromResponse(allocator, data);
if (results.len == 0) {
allocator.free(results);
return error.NoAnswer;
}
const first = results[0];
defer {
for (results[1..]) |r| allocator.free(r);
allocator.free(results);
}

Copilot uses AI. Check for mistakes.
Comment thread src/ipinfo.zig Outdated
Comment on lines +441 to +448
for (results) |r| allocator.free(r);
if (results.len > 0) allocator.free(results);
}
if (results.len == 0) return error.NoAnswer;
// Return first record; others are freed by defer above
const first = results[0];
results[0] = "";
return first;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

results[0] = "" is used to prevent the defer loop from freeing the returned allocation, but it depends on allocator.free() being a no-op for zero-length slices that were not allocated by that allocator. This is not guaranteed for arbitrary allocators passed into queryTxt. Avoid mutating the slice and instead skip freeing index 0 in the defer cleanup (or remove index 0 from the list before returning).

Suggested change
for (results) |r| allocator.free(r);
if (results.len > 0) allocator.free(results);
}
if (results.len == 0) return error.NoAnswer;
// Return first record; others are freed by defer above
const first = results[0];
results[0] = "";
return first;
if (results.len > 0) {
for (results[1..]) |r| allocator.free(r);
allocator.free(results);
}
}
if (results.len == 0) return error.NoAnswer;
// Return first record; others are freed by defer above
return results[0];

Copilot uses AI. Check for mistakes.
Comment thread src/cli/commands.zig
Comment on lines +294 to +299
for (records) |txt| {
if (std.mem.indexOf(u8, txt, "v=spf1") != null) {
spf_txt = txt;
} else {
allocator.free(txt);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the SPF loop, if more than one TXT record matches v=spf1, spf_txt will be overwritten and the previously stored allocation will be leaked (only the final value is freed by the defer). Consider only capturing the first match and freeing any subsequent matches, or freeing the previous spf_txt before overwriting it.

Copilot uses AI. Check for mistakes.
Comment thread src/ipinfo.zig
Comment on lines +471 to +476
// Parse all answer records
var results: std.ArrayList([]const u8) = .empty;
errdefer {
for (results.items) |r| allocator.free(r);
results.deinit(allocator);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new multi-answer TXT parsing logic is the core of this fix, but the existing tests only cover a single-answer TXT response. Add a test that constructs a DNS response with multiple TXT answer records (e.g., a non-SPF TXT followed by an SPF TXT) to ensure parseAllTxtFromResponse/SPF lookup behaves deterministically.

Copilot uses AI. Check for mistakes.
- Fix fragile ownership in parseTxtFromResponse: free results[1..]
  explicitly instead of mutating results[0] to empty string
- Fix potential memory leak in SPF lookup when multiple v=spf1 records
  exist by only capturing the first match
- Add unit tests for parseAllTxtFromResponse with multiple TXT records,
  non-TXT record skipping, and parseTxtFromResponse returning first record

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@linyows linyows merged commit 7f0baba into main Apr 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants