From a29b976f1321aecd30c3da399499032f1ac54eea Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 14 Sep 2026 19:26:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20stdin=20TTY=20=E5=81=B5=E6=B8=AC?= =?UTF-8?q?=E6=94=B9=E4=BB=A5=20fd=200=20=E5=AF=A6=E9=9A=9B=E8=A3=9D?= =?UTF-8?q?=E7=BD=AE=E5=88=A4=E5=AE=9A=EF=BC=8Cescript=20=E7=9C=9F?= =?UTF-8?q?=E7=B5=82=E7=AB=AF=E6=A9=9F=E4=B8=8D=E5=86=8D=E8=AA=A4=E5=88=A4?= =?UTF-8?q?=EF=BC=88issue=20#19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BearCli.TTY:readlink /proc/self/fd/0 判定 pts/tty/console/ptmx; 無 /proc 平台退回 :io.rows/0 - vault/self_service 的 tty_stdin?、accounts 的 tty? 改用 TTY.stdin_tty? - 根因::io.rows/0/:io.columns/0 走 io 協定問 group leader,escript (noshell standard_io)不支援行列查詢,一律回 enotsup——真終端機被 誤判成非 TTY,互動指令(vault unlock 等)無法讀 stdin 密碼 (bear #52 驗收建議 2) - 非 TTY(pipe/redirect)行為不變:明確提示+退出碼 2 - 新增 tty_test(4 測試);mix precommit 全綠(200 passed) --- lib/bear_cli/accounts.ex | 5 ++- lib/bear_cli/self_service.ex | 7 +++-- lib/bear_cli/tty.ex | 59 ++++++++++++++++++++++++++++++++++++ lib/bear_cli/vault.ex | 6 ++-- test/bear_cli/tty_test.exs | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 lib/bear_cli/tty.ex create mode 100644 test/bear_cli/tty_test.exs diff --git a/lib/bear_cli/accounts.ex b/lib/bear_cli/accounts.ex index 4c514b4..7d9bd03 100644 --- a/lib/bear_cli/accounts.ex +++ b/lib/bear_cli/accounts.ex @@ -21,6 +21,7 @@ defmodule BearCli.Accounts do import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2] alias BearCli.Api + alias BearCli.TTY @list_per_page_fetch 100 @max_fetch_pages 50 @@ -251,8 +252,10 @@ defmodule BearCli.Accounts do defp weak_password?(_), do: true + # stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.columns/0 + # 一律 enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。 defp tty? do - :io.columns() != {:error, :enotsup} + TTY.stdin_tty?() end defp prompt_password(prompt) do diff --git a/lib/bear_cli/self_service.ex b/lib/bear_cli/self_service.ex index 9a66e22..3af67dc 100644 --- a/lib/bear_cli/self_service.ex +++ b/lib/bear_cli/self_service.ex @@ -15,7 +15,7 @@ defmodule BearCli.SelfService do 3 未登入或 401;6 網路/伺服器錯誤;8 權限不足(403)。 """ - alias BearCli.{Api, Config, Credentials} + alias BearCli.{Api, Config, Credentials, TTY} @genders ~w(male female other) @@ -551,9 +551,10 @@ defmodule BearCli.SelfService do end end - # :io.rows/0 僅在終端機裝置成功;pipe/檔案重導回 {:error, :enotsup}。 + # stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.rows/0 一律 + # enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。 defp tty_stdin? do - match?({:ok, _}, :io.rows()) + TTY.stdin_tty?() end # -- 輸出 -- diff --git a/lib/bear_cli/tty.ex b/lib/bear_cli/tty.ex new file mode 100644 index 0000000..34e23ca --- /dev/null +++ b/lib/bear_cli/tty.ex @@ -0,0 +1,59 @@ +defmodule BearCli.TTY do + @moduledoc """ + stdin 是否為終端機(TTY)的偵測(issue #19;來源 bear #52 驗收建議 2)。 + + 不再只依賴 `:io.rows/0`/`:io.columns/0`:兩者走 io 協定詢問 group + leader,escript(`erl -noshell` 的 `standard_io`)不支援行列查詢, + 即使 fd 0 是真終端機也回 `{:error, :enotsup}`,互動環境會被誤判成 + 非 TTY(無法讀 stdin 密碼,只能靠測試注入介面繞過)。 + + 本模組改為直接判定 fd 0 指向的裝置: + + - Linux:readlink `/proc/self/fd/0`,指向 `/dev/pts/*`、`/dev/tty*`、 + `/dev/console`、`/dev/ptmx` 視為 TTY;pipe/socket/一般檔案(含 + `/dev/null`)視為非 TTY。 + - 無 `/proc` 的平台(如 macOS、Windows):退回 `:io.rows/0`(僅在 + 終端機裝置成功)。 + """ + + @tty_prefixes ["/dev/pts/", "/dev/tty", "/dev/console", "/dev/ptmx"] + + @doc "stdin(fd 0)是否為終端機。" + @spec stdin_tty?() :: boolean() + def stdin_tty? do + case fd0_path() do + {:ok, path} -> tty_path?(path) + :error -> rows_tty?() + end + end + + @doc """ + 裝置路徑是否為終端機(公開供測試與診斷)。 + + iex> BearCli.TTY.tty_path?("/dev/pts/0") + true + iex> BearCli.TTY.tty_path?("pipe:[42]") + false + """ + @spec tty_path?(String.t()) :: boolean() + def tty_path?(path) when is_binary(path) do + String.starts_with?(path, @tty_prefixes) + end + + @doc """ + 舊式偵測(`:io.rows/0`):終端機 io 裝置回 `{:ok, rows}`;escript 的 + `standard_io` 一律回 `{:error, :enotsup}`。僅作為無 `/proc` 平台的退路。 + """ + @spec rows_tty?() :: boolean() + def rows_tty? do + match?({:ok, _}, :io.rows()) + end + + # fd 0 實際指向的路徑(readlink /proc/self/fd/0);無 /proc 或讀取失敗 → :error + defp fd0_path do + case :file.read_link(~c"/proc/self/fd/0") do + {:ok, path} -> {:ok, to_string(path)} + {:error, _} -> :error + end + end +end diff --git a/lib/bear_cli/vault.ex b/lib/bear_cli/vault.ex index 5f30298..8bd6665 100644 --- a/lib/bear_cli/vault.ex +++ b/lib/bear_cli/vault.ex @@ -25,7 +25,7 @@ defmodule BearCli.Vault do 錯誤(含非 TTY);3 未登入或 401;6 網路/伺服器錯誤;8 權限不足。 """ - alias BearCli.{Api, Config, Credentials} + alias BearCli.{Api, Config, Credentials, TTY} alias BearCli.Vault.{BIP39, Crypto} @session_env "BEAR_VAULT_SESSION" @@ -932,7 +932,9 @@ defmodule BearCli.Vault do end end - defp tty_stdin?, do: match?({:ok, _}, :io.rows()) + # stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.rows/0 一律 + # enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。 + defp tty_stdin?, do: TTY.stdin_tty?() defp require_nonempty(""), do: {:error, "不可空白", 2} defp require_nonempty(value) when is_binary(value), do: :ok diff --git a/test/bear_cli/tty_test.exs b/test/bear_cli/tty_test.exs new file mode 100644 index 0000000..ee31109 --- /dev/null +++ b/test/bear_cli/tty_test.exs @@ -0,0 +1,57 @@ +defmodule BearCli.TTYTest do + @moduledoc """ + `BearCli.TTY` 單元測試(issue #19): + + - `tty_path?/1`:裝置路徑分類(pts/tty/console=TTY;pipe/檔案/ + socket//dev/null=非 TTY)。 + - `stdin_tty?/0`:在本測試環境(ExUnit 捕獲 IO,stdin 非 TTY)應為 + `false`;`rows_tty?/0` 在 escript/noshell 環境回 `enotsup` → `false`。 + - fd 0 偵測走 `/proc/self/fd/0`,Linux 上必可用。 + """ + use ExUnit.Case, async: true + + alias BearCli.TTY + + describe "tty_path?/1" do + test "終端機裝置路徑 → true" do + assert TTY.tty_path?("/dev/pts/0") + assert TTY.tty_path?("/dev/pts/17") + assert TTY.tty_path?("/dev/tty1") + assert TTY.tty_path?("/dev/tty") + assert TTY.tty_path?("/dev/ttyS0") + assert TTY.tty_path?("/dev/console") + assert TTY.tty_path?("/dev/ptmx") + end + + test "pipe/檔案/socket/null → false" do + refute TTY.tty_path?("pipe:[12345]") + refute TTY.tty_path?("socket:[12345]") + refute TTY.tty_path?("/dev/null") + refute TTY.tty_path?("/home/user/secret.txt") + refute TTY.tty_path?("/proc/self/fd/0") + refute TTY.tty_path?("") + end + end + + describe "stdin_tty?/0" do + test "測試環境(stdin 非 TTY)→ false" do + # ExUnit 的 group leader 為擷取裝置、測試程序的 stdin 非終端機。 + refute TTY.stdin_tty?() + end + end + + describe "fd0 實體判定(Linux /proc)" do + test "/proc/self/fd/0 可解析且與路徑分類一致" do + # 本測試在 Linux 跑:readlink 一定有結果;stdin 非 TTY → 分類為非 TTY。 + assert {:ok, path} = call_fd0_path() + assert TTY.stdin_tty?() == TTY.tty_path?(path) + end + end + + defp call_fd0_path do + case :file.read_link('/proc/self/fd/0') do + {:ok, p} -> {:ok, to_string(p)} + {:error, e} -> {:error, e} + end + end +end