diff --git a/README.md b/README.md index cb25c6b..414d066 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,29 @@ Run `tdig --help` for the in-binary reference. | TCP forcing | `+tcp` | `--tcp` | | Reverse lookup | `-x` | `-x` | | Packet I/O | external tools | **Built-in `--read` / `--write`** | +| Multi-string TXT | `"abc" "def"` | `"abcdef"` (concatenated) | + +### Known difference: multi-string TXT records + +A TXT record's RDATA is a list of character-strings, and values longer than 255 +bytes (DKIM keys, long SPF records) are always split across several of them. +`dig` prints each one separately: + +``` +example.com. 300 IN TXT "abc" "def" +``` + +`tdig` prints their concatenation as a single quoted value instead: + +``` +example.com. 300 IN TXT "abcdef" +``` + +**No data is lost** — the full value is shown, only the string boundaries are +not. This follows [`tenbin_dns`](https://github.com/smkwlab/tenbin_dns), which +returns the concatenation as the record's logical value (matching how SPF +treats multi-string TXT per RFC 7208 §3.3) and deliberately does not preserve +boundaries. If you already have BIND's `dig`, you don't need `tdig`. It's useful when: diff --git a/lib/tdig.ex b/lib/tdig.ex index 4dd1ea7..6c939d9 100644 --- a/lib/tdig.ex +++ b/lib/tdig.ex @@ -267,7 +267,7 @@ defmodule Tdig do def rdata_to_string(rdata, :ns), do: escape(rdata.name) def rdata_to_string(rdata, :ptr), do: escape(rdata.name) def rdata_to_string(rdata, :cname), do: escape(rdata.name) - def rdata_to_string(rdata, :txt), do: escape(rdata.txt) + def rdata_to_string(rdata, :txt), do: quote_character_string(rdata.txt) def rdata_to_string(rdata, :mx), do: "#{rdata.preference} #{escape(rdata.name)}" def rdata_to_string(rdata, :caa), @@ -296,6 +296,23 @@ defmodule Tdig do defp escape_byte(byte) when byte in 0x20..0x7E, do: <> defp escape_byte(byte), do: "\\" <> String.pad_leading(Integer.to_string(byte), 3, "0") + # Render a TXT character-string the way dig does: wrapped in double quotes, + # with `"` escaped on top of the shared escaping above. Domain names go + # through escape/1 instead — dig leaves those unquoted, so the quoting must + # not be folded into escape_byte/1. + # + # tenbin_dns returns the concatenation of a TXT record's character-strings + # as one binary (boundaries are deliberately not preserved, see + # smkwlab/tenbin_dns#95), so a multi-string record prints as a single quoted + # value where dig prints `"abc" "def"`. The value itself is complete. + defp quote_character_string(string) when is_binary(string) do + escaped = for <>, into: "", do: escape_character_string_byte(byte) + <> + end + + defp escape_character_string_byte(?"), do: "\\\"" + defp escape_character_string_byte(byte), do: escape_byte(byte) + def disp_tailer(server, port, size, time) do # Get system local time using NaiveDateTime (OS-independent, cleaner) now = NaiveDateTime.local_now() |> NaiveDateTime.to_string() diff --git a/test/tdig_test.exs b/test/tdig_test.exs index af73afa..e7cfff8 100644 --- a/test/tdig_test.exs +++ b/test/tdig_test.exs @@ -246,7 +246,39 @@ defmodule TdigTest do test "rdata_to_string formats TXT records" do rdata = %{txt: "v=spf1 include:_spf.google.com ~all"} - assert Tdig.rdata_to_string(rdata, :txt) == "v=spf1 include:_spf.google.com ~all" + assert Tdig.rdata_to_string(rdata, :txt) == ~s("v=spf1 include:_spf.google.com ~all") + end + + test "rdata_to_string escapes double quotes inside TXT records" do + rdata = %{txt: ~s(a"b)} + assert Tdig.rdata_to_string(rdata, :txt) == ~S("a\"b") + end + + test "rdata_to_string escapes backslashes inside TXT records" do + rdata = %{txt: ~S(a\b)} + assert Tdig.rdata_to_string(rdata, :txt) == ~S("a\\b") + end + + test "rdata_to_string escapes non-printable bytes inside TXT records" do + # Terminal escape sequences must not survive into the output; dig renders + # non-printable octets as three-digit decimal escapes. + rdata = %{txt: <<0x1B, "[31m">>} + assert Tdig.rdata_to_string(rdata, :txt) == ~S("\027[31m") + end + + test "rdata_to_string renders an empty TXT record as empty quotes" do + rdata = %{txt: ""} + assert Tdig.rdata_to_string(rdata, :txt) == ~s("") + end + + test "rdata_to_string leaves domain names unquoted" do + # dig quotes TXT character-strings but not domain names; the TXT-specific + # quoting must not leak into the shared escape/1 path. + assert Tdig.rdata_to_string(%{name: "ns1.example.com"}, :ns) == "ns1.example.com" + assert Tdig.rdata_to_string(%{name: "alias.example.com"}, :cname) == "alias.example.com" + + assert Tdig.rdata_to_string(%{preference: 10, name: "mail.example.com"}, :mx) == + "10 mail.example.com" end test "rdata_to_string handles unknown types" do @@ -363,7 +395,9 @@ defmodule TdigTest do end test "rdata_to_string escapes untrusted TXT and name bytes" do - assert Tdig.rdata_to_string(%{txt: <<"hi", 0x1B, "!">>}, :txt) == "hi\\027!" + # TXT is additionally wrapped in quotes to match dig; the escaping of the + # ESC byte is what matters here. + assert Tdig.rdata_to_string(%{txt: <<"hi", 0x1B, "!">>}, :txt) == ~S("hi\027!") assert Tdig.rdata_to_string(%{name: <<"ns", 0x1B, ".example.">>}, :cname) == "ns\\027.example."