- 新增 Elixir + Mix escript 專案(Req 0.5+、Jason) - login --token <PAT>:以 PAT 驗證 /userinfo 後寫入憑證檔(0600) - whoami:GET /userinfo 顯示身分 claims(支援 --json) - token:印出 access token 供 pipe(--refresh 在 PAT 模式忽略) - logout:清除本機憑證;status:純本機判定 - 憑證檔採原子寫入(rename)、0600、O_CREAT|O_EXCL 防 symlink - 全域選項:--issuer/--config/--json/-v/-h/--version - 29 個單元測試;mix precommit(compile --warnings-as-errors + format + test) - 更新 README.md 與 docs/commands.md 反映 PAT 模式 MVP Device Flow 登入待伺服器端 P1(bear 倉庫)完成後再接。issue #2
88 lines
2.2 KiB
Elixir
88 lines
2.2 KiB
Elixir
defmodule BearCli.Credentials do
|
||
@moduledoc """
|
||
本機憑證檔的讀寫(0600,原子寫入、防 symlink 攻擊)。
|
||
|
||
憑證檔結構(JSON):
|
||
|
||
{"issuer": "https://alterminal.com", "access_token": "<PAT>", "email": "..."}
|
||
|
||
PAT 模式沒有 refresh token,`access_token` 即個人存取權杖本身。
|
||
"""
|
||
|
||
alias BearCli.Config
|
||
|
||
@doc """
|
||
載入憑證,回傳 `{:ok, map}` 或 `:error`(不存在/JSON 損毀)。
|
||
"""
|
||
def load(path \\ Config.credentials_path()) do
|
||
case File.read(path) do
|
||
{:ok, content} ->
|
||
case Jason.decode(content) do
|
||
{:ok, map} when is_map(map) -> {:ok, map}
|
||
_ -> :error
|
||
end
|
||
|
||
{:error, _} ->
|
||
:error
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
寫入憑證(原子、0600)。建立父目錄(0700)。回傳 `:ok` 或 `{:error, reason}`。
|
||
"""
|
||
def save(map, path \\ Config.credentials_path()) do
|
||
json = Jason.encode!(map)
|
||
|
||
with :ok <- ensure_parent_dir(path),
|
||
{:ok, tmp} <- write_temp(path, json) do
|
||
# rename(2) 會原子地取代目標(包含取代 symlink 本身,而非其指向的檔案)。
|
||
File.rename(tmp, path)
|
||
else
|
||
{:error, _} = error -> error
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
刪除憑證檔;檔案不存在也算成功。
|
||
"""
|
||
def delete(path \\ Config.credentials_path()) do
|
||
case File.rm(path) do
|
||
:ok -> :ok
|
||
{:error, :enoent} -> :ok
|
||
{:error, reason} -> {:error, reason}
|
||
end
|
||
end
|
||
|
||
defp ensure_parent_dir(path) do
|
||
dir = Path.dirname(path)
|
||
File.mkdir_p(dir)
|
||
|
||
# 目錄權限 0700 為 best-effort(例如憑證路徑落在 /tmp 等共享目錄時無法 chmod,
|
||
# 忽略即可;真正的保護是憑證檔本身的 0600)。
|
||
_ = File.chmod(dir, 0o700)
|
||
:ok
|
||
end
|
||
|
||
defp write_temp(path, json) do
|
||
dir = Path.dirname(path)
|
||
tmp = Path.join(dir, ".credentials.#{System.unique_integer([:positive])}.tmp")
|
||
|
||
result =
|
||
with {:ok, io} <- File.open(tmp, [:write, :exclusive, :binary]),
|
||
:ok <- IO.binwrite(io, json),
|
||
:ok <- File.close(io),
|
||
:ok <- File.chmod(tmp, 0o600) do
|
||
{:ok, tmp}
|
||
end
|
||
|
||
case result do
|
||
{:ok, _} = ok ->
|
||
ok
|
||
|
||
{:error, _} = error ->
|
||
_ = File.rm(tmp)
|
||
error
|
||
end
|
||
end
|
||
end
|