Fix intermittent SPF not found in dns command#16
Conversation
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>
There was a problem hiding this comment.
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
queryAllTxtandparseAllTxtFromResponseto return all TXT records from a DNS response. - Refactor
parseTxtFromResponseto reuse the new multi-answer parser. - Update
cmdDnsSPF detection to scan all TXT records forv=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.
| 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] = ""; |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| 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; |
There was a problem hiding this comment.
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).
| 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]; |
| for (records) |txt| { | ||
| if (std.mem.indexOf(u8, txt, "v=spf1") != null) { | ||
| spf_txt = txt; | ||
| } else { | ||
| allocator.free(txt); | ||
| } |
There was a problem hiding this comment.
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.
| // Parse all answer records | ||
| var results: std.ArrayList([]const u8) = .empty; | ||
| errdefer { | ||
| for (results.items) |r| allocator.free(r); | ||
| results.deinit(allocator); | ||
| } |
There was a problem hiding this comment.
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.
- 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>
Summary
reports dnsintermittently showing SPF as "not found" for domains with multiple TXT recordsqueryAllTxttoipinfo.zigthat parses all TXT answer records from a DNS responsecmdDnsto search all TXT records forv=spf1Details
Domains like
tomohisaoda.comhave multiple TXT records (SPF + google-site-verification). The previousparseTxtFromResponseonly 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 buildcompiles successfullyzig build testall tests passzig fmt --check src/passesreports dns --domain tomohisaoda.comconsistently finds SPF across 5 consecutive runs🤖 Generated with Claude Code