fix: stdin TTY 偵測改以 fd 0 實際裝置判定,escript 真終端機不再誤判(issue #19)

- 新增 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)
This commit is contained in:
2026-09-14 19:26:23 +08:00
parent a2edb03a0e
commit a29b976f13
5 changed files with 128 additions and 6 deletions
+4 -1
View File
@@ -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
+4 -3
View File
@@ -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
# -- 輸出 --
+59
View File
@@ -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
+4 -2
View File
@@ -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