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
68 changes: 60 additions & 8 deletions lib/tdig/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,18 @@ defmodule Tdig.CLI do
end
end

# The subnet clause comes first because an ECS option needs EDNS to carry it.
# `parse_args/1` sets :edns through `Map.put_new/3`, so the key is always
# present and the `edns: false` clause below would otherwise shadow this one,
# dropping --subnet silently unless --edns was also given. dig behaves the
# same way: +subnet turns EDNS on even alongside an explicit +noedns.
# `Map.put_new/3` leaves an explicitly requested --bufsize alone.
def check_edns(%{subnet: subnet} = arg) when is_binary(subnet),
do: arg |> Map.put_new(:bufsize, DNS.edns_max_udpsize()) |> mk_edns_with_subnet

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.

ℹ️ [LOW] 確認です。--subnet--bufsize を両方指定した場合、subnet 節が先頭で一致して mk_edns_with_subnet に入ります。この経路で --bufsize の値が実際に EDNS リクエストに反映されるか(つまり mk_edns_with_subnetbufsize を尊重するか)を念のため確認しておくと安全です。テストは parse_args 出力の :bufsize を検証しているものの、最終的に送出される EDNS の UDP サイズまでは検証していないように見えます。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

確認しました。反映されますが、「テストが parse_args 出力までしか見ていない」というご指摘は妥当なので、テストを OPT レコードまで伸ばしました(0b3430c)。

bufsizelib/tdig.exTdig.check_edns/1(CLI 側の同名関数とは別物)で OPT 疑似レコードの payload_size になります。

# lib/tdig.ex:67
payload_size: arg.bufsize,

--bufsize 1232 --subnet 192.0.2.1/24 を実際に通すと bufsize=1232 が保たれ、mk_edns_with_subnetbufsize を触りません(Map.put_new/3 に変えたのはまさにこのためです)。

追加したテストは中間状態ではなく送出される OPT レコードそのものを検証します。

assert [opt] = argv |> Tdig.CLI.parse_args() |> Tdig.check_edns()
assert opt.type == :opt
assert opt.payload_size == 1232
assert [{:edns_client_subnet, ecs}] = opt.rdata

mix test → 74 passed。

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] Map.put_new/3 への変更は良い判断です。--bufsize を明示した場合にその値を尊重できるようになり、テストでも --subnet + --bufsize 1232 のケースを検証していて意図が明確です。

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] check_edns/1 の subnet 節を先頭に移動し、Map.putMap.put_new に変えた修正は、シャドウイングバグの本質を正確に捉えており、--bufsize の明示値を尊重する点でも適切です。コメントで意図(ECS が EDNS を含意すること、dig の挙動との整合)が明記されており、将来の保守者にとって非常に価値があります。

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] check_edns/1 の subnet 節を先頭に移動して shadowing を解消した修正は的確です。コメントで dig の +subnet が +noedns があっても EDNS を有効にする挙動と一致させた理由まで明記されており、意図が明確に伝わります。Map.putMap.put_new によって明示的な --bufsize を尊重する変更も適切です。


def check_edns(%{bufsize: size} = arg) when is_integer(size), do: mk_edns(arg)
def check_edns(%{edns: false} = arg), do: arg

def check_edns(%{subnet: subnet} = arg) when is_binary(subnet),
do: arg |> Map.put(:bufsize, DNS.edns_max_udpsize()) |> mk_edns_with_subnet

def check_edns(arg) do
arg
|> Map.put(:bufsize, DNS.edns_max_udpsize())
Expand Down Expand Up @@ -249,15 +255,15 @@ defmodule Tdig.CLI do
%{
family: 1 | 2,
client_subnet: :inet.ip_address(),
source_prefix: integer(),
source_prefix: non_neg_integer(),

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.

ℹ️ [LOW] source_prefix の型を non_neg_integer() に厳格化したのは実装が非負を保証するようになったことと整合しており良い変更です。ただし現状ではクランプ後の値のみが格納されるため、より厳密には 0..128 のような範囲型で表現することも検討できます(dialyzer 上は現状で十分です)。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

0..128より厳密にはならないので据え置きます。

この型は IPv4 と IPv6 の両方の ECS オプションを表しており、実際の上限はファミリごとに違います。

  • family: 1(IPv4)→ min(prefix, 32) なので 0..32
  • family: 2(IPv6)→ min(prefix, 128) なので 0..128

0..128 と書くと IPv4 の場合に 33..128 という到達し得ない値を許すことになるため、精度が上がるのではなく別の形で不正確になります。正確に表現するにはファミリごとに型を分ける(edns_client_subnet を 2 つの型の union にする)必要があり、familyclient_subnetsource_prefix の 3 つが連動する構造をこの目的のために分割するのは割に合わないと判断しました。

non_neg_integer() は「どちらのファミリでも成り立つ、嘘のない上界なしの下界」なので、現状ではこれが妥当と考えます。

scope_prefix: 0
}}

@spec parse_subnet_option(String.t()) :: edns_client_subnet()
def parse_subnet_option(subnet) do
case String.split(subnet, "/") do
[addr_str, prefix_str] ->
prefix = String.to_integer(prefix_str)
case split_subnet(subnet) do
{:ok, addr_str, prefix_str} ->
prefix = prefix_length!(subnet, prefix_str)

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.

ℹ️ [LOW] 補足の指摘です。IPv6 で prefix を省略した記法(例: 2001:db8::1/)や、/ が複数含まれるケース(String.split(subnet, "/") が 3 要素以上を返す場合)は、この節に一致せず既存の Invalid subnet format パスへ流れる想定でしょうか。空 prefix 文字列 ""parse_prefix_length/1:error を返すため適切に弾かれますが、複数スラッシュのケースの扱いを一度確認しておくとよいと思います。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ご想定のとおりです。実測して 0b3430c でテストに固定しました。

"192.0.2.1/24/8" split=["192.0.2.1", "24", "8"]   → 3 要素 → Invalid subnet format
"192.0.2.1//24"  split=["192.0.2.1", "", "24"]    → 3 要素 → Invalid subnet format
"2001:db8::1/"   split=["2001:db8::1", ""]        → 2 要素 → Invalid prefix length
"2001:db8::1/64" split=["2001:db8::1", "64"]      → 正常

複数スラッシュは [addr_str, prefix_str] の節に一致しないので既存の _ -> 節に落ち、Invalid subnet format. Use: address/prefix (e.g., 192.0.2.1/24) になります。エラーの種類として妥当(prefix の問題ではなく形式の問題)なので、この分岐のままにしました。

IPv6 は区切りが : なのでアドレス部にスラッシュは現れず、2001:db8::1/ は 2 要素になってご指摘のとおり parse_prefix_length(""):error を返します。escript でも確認済みです。

$ ./tdig example.com A --subnet 192.0.2.1/
Invalid prefix length in 192.0.2.1/: not a valid number    (exit=1)


case :inet.parse_address(String.to_charlist(addr_str)) do
{:ok, {a, b, c, d}} ->
Expand Down Expand Up @@ -289,12 +295,58 @@ defmodule Tdig.CLI do
System.halt(1)
end

_ ->
:error ->
IO.puts(:stderr, "Invalid subnet format. Use: address/prefix (e.g., 192.0.2.1/24)")
System.halt(1)
end
end

@doc """
Splits a `--subnet` argument into its address and prefix parts.

Anything other than exactly one separator is a format error rather than a
prefix error, so `"192.0.2.1/24/8"` and `"192.0.2.1"` are both rejected here
and never reach `parse_prefix_length/1`.
"""
@spec split_subnet(String.t()) :: {:ok, String.t(), String.t()} | :error
def split_subnet(subnet) do
case String.split(subnet, "/") do
[addr_str, prefix_str] -> {:ok, addr_str, prefix_str}
_ -> :error
end
end

defp prefix_length!(subnet, prefix_str) do
case parse_prefix_length(prefix_str) do
{:ok, prefix} -> prefix
:error -> invalid_argument("Invalid prefix length in #{subnet}: not a valid number")
end
end

@doc """
Parses a subnet prefix length, accepting only a bare non-negative number.

A value above the address family's width is *not* rejected here: dig caps it
at 32 or 128 rather than erroring, and `parse_subnet_option/1` does the same
with `min/2`. What dig does reject is anything that is not an unsigned
number, which is what this function screens out — `min/2` only caps the
upper end, so a negative prefix would otherwise reach the query untouched.

Digits only, so a sign is refused even where `Integer.parse/1` would accept
it (`"+24"`); leading zeros are fine. This matches dig, measured with 9.20.26:

192.0.2.1/+24 => dig: invalid prefix length in '192.0.2.1/+24': not a valid number
192.0.2.1/024 => CLIENT-SUBNET: 192.0.2.0/24/0
"""
@spec parse_prefix_length(String.t()) :: {:ok, non_neg_integer()} | :error
def parse_prefix_length(prefix_str) do

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] parse_prefix_length/1 の正規表現 \A\d+\z による検証は、Integer.parse/1 の符号許容という落とし穴を的確に回避しており、dig の挙動(+24 を拒否、先頭ゼロは許容)と厳密に整合しています。docstring も根拠が明快で優れています。

if String.match?(prefix_str, ~r/\A\d+\z/) do

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.

ℹ️ [LOW] parse_prefix_length/1String.match?/2 の後に String.to_integer/1 を呼んでいますが、正規表現 \A\d+\z にマッチした時点で必ず整数化可能なので安全です。ただし極端に長い桁数(例: "999999...")でも巨大整数として受理され、その後の min/2 で 32/128 にクランプされるため実害はありません。念のためこの挙動が意図通りか(dig と同じくクランプで良いか)はテストで担保しておくとより堅牢です。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

「テストで担保しておくとより堅牢」の点、92a73b7 で追加しました。ただし裏を取ったところ、dig はこの領域で clamp ではなくエラーにしますので、意図的な差異として記録します。

dig 9.20.26 の実測で境界を特定しました。

/255         => CLIENT-SUBNET: 192.0.2.1/32/0
/65536       => CLIENT-SUBNET: 192.0.2.1/32/0
/4294967295  => CLIENT-SUBNET: 192.0.2.1/32/0
/4294967296  => dig: invalid prefix length in '...': out of range

境界は 32 bit 符号なしの上限ちょうどで、not a valid number とは別のメッセージです。つまり dig は「uint32 として読めなければ out of range、読めたらファミリ幅にクランプ」という二段構えで、上限は C の整数幅に由来する実装上の産物です。

tdig にこの上限は設けず、クランプのままにしました。理由は、dig が受理する全ての値について送出されるオプションが一致するためです(ファミリ幅以上はすべて 32 / 128 になる)。差が出るのは 2^32 以上という、どちらにせよ無意味な入力に対してだけで、そこに 4294967295 という定数を持ち込む価値は薄いと判断しました。

挙動が意図どおりであることをテストで固定しています。

{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("192.0.2.1/4294967295")
assert ecs.source_prefix == 32

{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("192.0.2.1/99999999999999999999")
assert ecs.source_prefix == 32

out of range まで再現すべきというご判断であれば対応しますので、その場合はお知らせください。

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.

ℹ️ [LOW] parse_prefix_length/1 で正規表現 ~r/\A\d+\z/ を用いて数字のみを受理し、負値・符号付き・非数値を弾く方針は dig の挙動と一致しており的確です。なお \d は Unicode の数字(例: 全角数字やアラビア数字)にもマッチし得るため、ASCII 数字のみに限定したい場合は ~r/\A\d+\z/ に代えて String.to_charlist で範囲チェックするか、(?-u) フラグの利用を検討してもよいでしょう。実害は限定的ですが一応の指摘です。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

既に ASCII 数字のみです。 Elixir の正規表現は Unicode 対応が既定で off で、u 修飾子を付けたときだけ \d が Unicode の数字に広がります。実測です。

全角 24:              既定 => false   /u 付き => true
アラビア ٢٤:            既定 => false   /u 付き => true
デーヴァナーガリー २४:  既定 => false   /u 付き => true
ASCII 24:               既定 => true    /u 付き => true

parse_prefix_length/1 を直接通しても弾かれます。

"24" => :error
"٢٤"  => :error
"24"  => {:ok, 24}

(?-u) は既定の状態を明示するだけで挙動を変えないため、追加しません。もし将来 u を付ける変更が入れば String.to_integer/1 側が ArgumentError を投げて気づけます(String.to_integer("24") は失敗する)。

{:ok, String.to_integer(prefix_str)}
else
:error
end
end

def check_args(%{help: true}), do: %{help: true, exit_code: 0}
def check_args(%{name: nil, read: nil, version: nil}), do: %{help: true, exit_code: 1}

Expand Down
127 changes: 127 additions & 0 deletions test/tdig_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,133 @@ defmodule TdigTest do
end
end

describe "subnet prefix length validation (Issue #86)" do
# dig parses the prefix as an unsigned number and refuses anything else
# ("invalid prefix length in '...': not a valid number"), so a negative or
# non-numeric value is an error rather than something to coerce.
test "parse_prefix_length/1 accepts a bare non-negative number" do
assert Tdig.CLI.parse_prefix_length("24") == {:ok, 24}
assert Tdig.CLI.parse_prefix_length("0") == {:ok, 0}
# dig accepts leading zeros: 192.0.2.1/024 => CLIENT-SUBNET: 192.0.2.0/24/0
assert Tdig.CLI.parse_prefix_length("024") == {:ok, 24}
end

test "parse_prefix_length/1 rejects a negative number" do
assert Tdig.CLI.parse_prefix_length("-5") == :error
assert Tdig.CLI.parse_prefix_length("-1") == :error
end

test "parse_prefix_length/1 rejects anything that is not a bare number" do
for input <- ["abc", "", " 24", "24x", "2.4", "+24"] do
assert Tdig.CLI.parse_prefix_length(input) == :error,
"expected #{inspect(input)} to be rejected"
end
end

# An over-range prefix is NOT an error in dig: it is capped at the address
# family's width. Measured with dig 9.20.26 via +qr:
# 192.0.2.1/999 => CLIENT-SUBNET: 192.0.2.1/32/0
# 2001:db8::1/200 => CLIENT-SUBNET: 2001:db8::1/128/0
test "an over-range IPv4 prefix is clamped to 32 rather than rejected" do
{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("192.0.2.1/999")
assert ecs.source_prefix == 32

{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("192.0.2.1/33")
assert ecs.source_prefix == 32
end

test "an over-range IPv6 prefix is clamped to 128 rather than rejected" do
{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("2001:db8::1/200")
assert ecs.source_prefix == 128
end

test "an extra slash is a format error, not a prefix error" do

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.

⚠️ [MEDIUM] an extra slash is a format error のテストが、実際に parse_subnet_option/1 を呼んでフォーマットエラー(stderr + exit 1)になることを検証せず、String.split/2 の出力形状のみを assert しています。これでは「3要素になったときに実際にどの節へフォールスルーし、どう扱われるか」を検証できておらず、意図を説明するコメントと実際のテスト内容に乖離があります。可能なら parse_subnet_option("192.0.2.1/24/8") が期待するエラー動作をするところまで検証することを推奨します。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ご指摘のとおりです。あのテストは String.split/2(標準ライブラリ)の戻り値の形を assert していて、tdig 側の分岐を何も検証していませんでした。92a73b7 で直しました。

parse_subnet_option/1 が 2 つのエラーメッセージを選び分けている地点を split_subnet/1 として切り出し、そこを直接検証します。System.halt/1 を呼ぶ関数はそのままでは ExUnit で検証できないため、判断だけを純粋関数に出すという parse_prefix_length/1 と同じ手を使いました。

assert Tdig.CLI.split_subnet("192.0.2.1/24/8") == :error
assert Tdig.CLI.split_subnet("192.0.2.1//24")  == :error
assert Tdig.CLI.split_subnet("192.0.2.1")      == :error

assert Tdig.CLI.split_subnet("192.0.2.1/24")   == {:ok, "192.0.2.1", "24"}
# 空 prefix は分割としては正常なので、prefix 側のエラーになる
assert Tdig.CLI.split_subnet("2001:db8::1/")   == {:ok, "2001:db8::1", ""}
assert Tdig.CLI.parse_prefix_length("")        == :error

これで「3 要素になったときにどちらのメッセージになるか」が実際にテストで固定されました。escript での確認も一致します。

$ ./tdig example.com A --subnet 192.0.2.1/24/8
Invalid subnet format. Use: address/prefix (e.g., 192.0.2.1/24)   (exit=1)
$ ./tdig example.com A --subnet 192.0.2.1/
Invalid prefix length in 192.0.2.1/: not a valid number           (exit=1)

# split_subnet/1 is where parse_subnet_option/1 decides between its two
# error messages. Asserting it directly, rather than the halting call,
# keeps the distinction testable: :error here means the input never
# reaches parse_prefix_length/1 and gets the format message instead.
assert Tdig.CLI.split_subnet("192.0.2.1/24/8") == :error
assert Tdig.CLI.split_subnet("192.0.2.1//24") == :error
assert Tdig.CLI.split_subnet("192.0.2.1") == :error

assert Tdig.CLI.split_subnet("192.0.2.1/24") == {:ok, "192.0.2.1", "24"}
# an empty prefix is a well-formed split, so it is a prefix error
assert Tdig.CLI.split_subnet("2001:db8::1/") == {:ok, "2001:db8::1", ""}
assert Tdig.CLI.parse_prefix_length("") == :error
end

test "a prefix far above the family width is still clamped" do
# dig stops at a 32-bit unsigned and reports "out of range" beyond it
# (4294967295 clamps to 32, 4294967296 errors), which is an artefact of
# its C parsing rather than a protocol limit. tdig has no such ceiling;
# every value at or above the family width produces the same option, so
# the emitted query matches dig for everything dig accepts.
{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("192.0.2.1/4294967295")
assert ecs.source_prefix == 32

{:edns_client_subnet, ecs} = Tdig.CLI.parse_subnet_option("192.0.2.1/99999999999999999999")
assert ecs.source_prefix == 32
end
end

describe "subnet option reaches the query (Issue #86)" do
# parse_args/1 sets :edns via Map.put_new, so the key is always present.
# These go through parse_args rather than calling check_edns/1 with a
# hand-built map, because the shadowing bug was invisible to a map that
# omitted :edns.
defp ecs_options(argv), do: Tdig.CLI.parse_args(argv)[:options]

test "--subnet alone carries the ECS option" do
assert [{:edns_client_subnet, ecs}] =
ecs_options(["example.com", "--subnet", "192.0.2.1/24"])

assert ecs.family == 1
assert ecs.source_prefix == 24
end

test "--subnet combined with --bufsize carries the ECS option" do
argv = ["example.com", "--bufsize", "1232", "--subnet", "192.0.2.1/24"]
assert [{:edns_client_subnet, ecs}] = ecs_options(argv)
assert ecs.source_prefix == 24
end

test "--subnet keeps an explicitly requested bufsize" do
argv = ["example.com", "--bufsize", "1232", "--subnet", "192.0.2.1/24"]
assert Tdig.CLI.parse_args(argv)[:bufsize] == 1232
end

test "--subnet with --bufsize reaches the OPT record that gets sent" do
# parse_args/1 alone does not prove the value is emitted: the OPT
# pseudo-record is built later, in Tdig.check_edns/1.
argv = ["example.com", "--bufsize", "1232", "--subnet", "192.0.2.1/24"]
assert [opt] = argv |> Tdig.CLI.parse_args() |> Tdig.check_edns()
assert opt.type == :opt
assert opt.payload_size == 1232
assert [{:edns_client_subnet, ecs}] = opt.rdata
assert ecs.source_prefix == 24
end

test "--subnet combined with --edns still carries the ECS option" do
argv = ["example.com", "--edns", "--subnet", "192.0.2.1/24"]
assert [{:edns_client_subnet, _}] = ecs_options(argv)
end

test "--subnet turns EDNS on, as dig's +subnet does" do
assert Tdig.CLI.parse_args(["example.com", "--subnet", "192.0.2.1/24"])[:edns] == true
end

test "EDNS without a subnet carries no options" do
assert ecs_options(["example.com", "--edns"]) == []
assert ecs_options(["example.com", "--bufsize", "1232"]) == []
end

test "a plain query still has EDNS off" do
args = Tdig.CLI.parse_args(["example.com"])
assert args[:edns] == false
assert args[:options] == nil
end
end

describe "version reporting (Issue #49)" do
test "version/0 returns the mix.exs project version" do
# Guards against stale hardcoded version strings drifting from mix.exs.
Expand Down
Loading