Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
19 changes: 18 additions & 1 deletion lib/tdig.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -296,6 +296,23 @@ defmodule Tdig do
defp escape_byte(byte) when byte in 0x20..0x7E, do: <<byte>>
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 <<byte <- string>>, into: "", do: escape_character_string_byte(byte)
<<?", escaped::binary, ?">>
end

defp escape_character_string_byte(?"), do: "\\\""
defp escape_character_string_byte(byte), do: escape_byte(byte)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✨ [POSITIVE] 実装・コメント・委譲設計とも良好です。escape_character_string_byte/1"以外をescape_byte/1に委譲することで、\\\や非印字バイト→\DDDのルールが1箇所に集約され、重複実装を回避できている点は保守性の観点で優れています。ドメイン名の非引用を守るためにTXT専用経路に閉じている判断も適切です。


def disp_tailer(server, port, size, time) do
# Get system local time using NaiveDateTime (OS-independent, cleaner)
now = NaiveDateTime.local_now() |> NaiveDateTime.to_string()
Expand Down
38 changes: 36 additions & 2 deletions test/tdig_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down
Loading