Skip to content

fix(txt): chunk long TXT records per RFC 1035 (#32)#1

Open
bindreams wants to merge 8 commits into
masterfrom
fix/long-txt-roundtrip
Open

fix(txt): chunk long TXT records per RFC 1035 (#32)#1
bindreams wants to merge 8 commits into
masterfrom
fix/long-txt-roundtrip

Conversation

@bindreams
Copy link
Copy Markdown
Owner

Summary

Fixes libdns#32 — long TXT records (>255 bytes, e.g. DKIM keys, long SPF, large ACME challenges) fail to round-trip cleanly through the provider.

Cloudflare stores TXT records >255 bytes as multiple RFC 1035 §3.3.14 character-strings, returned literally as "chunk1" "chunk2". The old unwrapContent/wrapContent helpers only handled a single pair of outer quotes, so:

  • GetRecords surfaced zone-file artifacts (embedded " " between chunks) inside libdns.TXT.Text.
  • DeleteRecords silently no-op'd because the match comparator never produced the chunked form on either side.

This PR rewrites both helpers to chunk on write and parse multi-segment on read, then compares structurally (on unwrapped content) in getDNSRecords. Adjacent hardening surfaced during review is included.

Reproduction (from the issue)

value := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA", 9) + "IDAQAB"
rec := libdns.TXT{Name: "postern-test._domainkey", Text: value, TTL: 5 * time.Minute}

p.AppendRecords(ctx, zone, []libdns.Record{rec})           // succeeds
got, _ := p.GetRecords(ctx, zone)                          // rr.Data has length 413 (input 410); embedded `" "`
del, _ := p.DeleteRecords(ctx, zone, []libdns.Record{rec}) // err=nil, len(del)=0 — silent no-op

After this PR: rr.Data round-trips byte-identical, DeleteRecords returns 1.

Commits

  1. test(txt): add failing tests for long-TXT round-trip (#32) — TDD red. Table-driven cases in client_test.go that fail against the old implementation, demonstrating both symptoms.
  2. fix(txt): chunk long TXT records per RFC 1035 (#32) — rewrites helpers, drops the long-content content.contains URL filter for TXT, collapses the two-pass byte-equality match loop into a single structural comparison on unwrapped content, and tightens the error-handling path in getDNSRecords (the old TXT branch silently swallowed doAPIRequest errors).
  3. test(txt): add libdnstest lifecycle test for long-TXT round-trip — env-gated integration test mirroring the issue reproduction against the real Cloudflare API.
  4. fix(txt): accept CR/LF as TXT chunk separators; harden tests (#32) — broadens the separator parser to accept all ASCII whitespace, adds tests for CR/LF/CRLF/empty-leading-chunk/invalid-escape, and randomizes the integration test record name so parallel CI runs against the same zone don't collide.
  5. refactor: paginate via shared helper; fix nil ResultInfo dereference — extracts pagination into a listDNSRecords helper used by both GetRecords and getDNSRecords. Two motivations: (a) dropping content.contains for TXT means getDNSRecords can now see >100 matching candidates for a single name (e.g. many parallel ACME challenges) and needs pagination; (b) the existing GetRecords had a latent nil-pointer bug — it computed lastPage := response.ResultInfo.TotalCount / response.ResultInfo.PerPage before checking response.ResultInfo == nil.

Test plan

  • go vet ./... at repo root and in libdnstest/ — clean.
  • go test ./... at repo root — all unit tests pass (credential-free; covers wrap/unwrap helpers, the round-trip property over ASCII/UTF-8/raw-byte/boundary inputs, malformed-input fallback, and separator variants).
  • cd libdnstest && go test -v -run TestCloudflareLongTXTRoundTrip with CLOUDFLARE_API_TOKEN + CLOUDFLARE_TEST_ZONE set — needs to run against a real zone to confirm the wire-format contract.
  • cd libdnstest && go test -v ./... with credentials — full upstream libdnstest suite still passes (no regression on short TXT or other record types).

Notes for review

  • Helper names left as wrapContent/unwrapContent rather than renamed to *TXT* — they're package-private and all three callsites already guard on r.Type == \"TXT\". The doc comments now spell out the TXT-only contract.
  • Error propagation change in getDNSRecords is technically observable to SetRecords: a transient API error during the "does record exist" check used to fall through to createRecord (and likely fail with 81058 identical record exists or similar), now it surfaces directly. That's correct behavior — the old swallow was hiding real problems — but flagged here in case you want to discuss.
  • Out of scope but worth noting: for SRV/HTTPS/SVCB records, getDNSRecords already had no content filter (the API doesn't support content.exact for those types), so DeleteRecords against any of those types would match by name+type only and delete every record at that name. That's a pre-existing bug, not introduced or affected here.

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.

Long TXT (>255 bytes, e.g. DKIM) round-trip fails: GetRecords surfaces zone-file segment separators in Text; DeleteRecords silent no-op

1 participant