defmodule BearCli.Credentials do @moduledoc """ 本機憑證檔的讀寫(0600,原子寫入、防 symlink 攻擊)。 憑證檔結構(JSON): {"issuer": "https://alterminal.com", "access_token": "", "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