gh_token/
lib.rs

1#![doc(html_root_url = "https://docs.rs/gh-token/0.1.8")]
2#![allow(
3    clippy::missing_errors_doc,
4    clippy::module_name_repetitions,
5    clippy::uninlined_format_args
6)]
7
8use crate::error::ParseError;
9use serde_derive::Deserialize;
10use std::env;
11use std::fmt::{self, Debug, Display};
12use std::fs;
13use std::io::ErrorKind;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17#[derive(Deserialize)]
18struct Config {
19    #[serde(rename = "github.com")]
20    github_com: Option<Host>,
21}
22
23#[derive(Deserialize)]
24struct Host {
25    oauth_token: Option<String>,
26}
27
28pub enum Error {
29    NotConfigured(PathBuf),
30    Parse(error::ParseError),
31}
32
33mod error {
34    use std::io;
35    use std::path::PathBuf;
36
37    pub enum ParseError {
38        EnvNonUtf8(&'static str),
39        Io(PathBuf, io::Error),
40        Yaml(PathBuf, serde_yaml::Error),
41    }
42}
43
44impl std::error::Error for Error {}
45
46impl Display for Error {
47    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
48        match self {
49            Error::NotConfigured(path) => {
50                write!(
51                    formatter,
52                    "no github.com token found in {}; use `gh auth login` to authenticate",
53                    path.display(),
54                )
55            }
56            Error::Parse(ParseError::EnvNonUtf8(var)) => {
57                write!(
58                    formatter,
59                    "environment variable ${} contains non-utf8 value",
60                    var,
61                )
62            }
63            Error::Parse(ParseError::Io(path, io_error)) => {
64                write!(formatter, "failed to read {}: {}", path.display(), io_error)
65            }
66            Error::Parse(ParseError::Yaml(path, yaml_error)) => {
67                write!(
68                    formatter,
69                    "failed to parse {}: {}",
70                    path.display(),
71                    yaml_error,
72                )
73            }
74        }
75    }
76}
77
78impl Debug for Error {
79    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
80        write!(formatter, "Error({:?})", self.to_string())
81    }
82}
83
84pub fn get() -> Result<String, Error> {
85    for var in ["GH_TOKEN", "GITHUB_TOKEN"] {
86        if let Some(token_from_env) = env::var_os(var) {
87            return token_from_env
88                .into_string()
89                .map_err(|_| Error::Parse(ParseError::EnvNonUtf8(var)));
90        }
91    }
92
93    let Some(path) = hosts_config_file() else {
94        let fallback_path = Path::new("~").join(".config").join("gh").join("hosts.yml");
95        return Err(Error::NotConfigured(fallback_path));
96    };
97
98    let content = match fs::read(&path) {
99        Ok(content) => content,
100        Err(io_error) => {
101            return Err(if io_error.kind() == ErrorKind::NotFound {
102                Error::NotConfigured(path)
103            } else {
104                Error::Parse(ParseError::Io(path, io_error))
105            });
106        }
107    };
108
109    let config: Config = match serde_yaml::from_slice(&content) {
110        Ok(config) => config,
111        Err(yaml_error) => return Err(Error::Parse(ParseError::Yaml(path, yaml_error))),
112    };
113
114    if let Some(github_com) = config.github_com {
115        if let Some(oauth_token) = github_com.oauth_token {
116            return Ok(oauth_token);
117        }
118    }
119
120    // While support for `gh auth token` is being rolled out, do not report
121    // errors from it yet. It probably means the user's installed `gh` does not
122    // have the feature.
123    //
124    // "As of right now storing the authentication token in the system keyring
125    // is an opt-in feature, but in the near future it will be required"
126    if let Some(token) = token_from_cli() {
127        return Ok(token);
128    }
129
130    // When system keyring auth tokens become required in the near future, this
131    // message needs to change to stop recommending putting a plain-text token
132    // into that yaml file.
133    Err(Error::NotConfigured(path))
134}
135
136fn hosts_config_file() -> Option<PathBuf> {
137    let config_dir = config_dir()?;
138    Some(config_dir.join("hosts.yml"))
139}
140
141fn config_dir() -> Option<PathBuf> {
142    if let Some(gh_config_dir) = env::var_os("GH_CONFIG_DIR") {
143        if !gh_config_dir.is_empty() {
144            return Some(PathBuf::from(gh_config_dir));
145        }
146    }
147
148    if let Some(xdg_config_home) = env::var_os("XDG_CONFIG_HOME") {
149        if !xdg_config_home.is_empty() {
150            return Some(Path::new(&xdg_config_home).join("gh"));
151        }
152    }
153
154    if cfg!(windows) {
155        if let Some(app_data) = env::var_os("AppData") {
156            if !app_data.is_empty() {
157                return Some(Path::new(&app_data).join("GitHub CLI"));
158            }
159        }
160    }
161
162    let home_dir = home::home_dir()?;
163    Some(home_dir.join(".config").join("gh"))
164}
165
166fn token_from_cli() -> Option<String> {
167    let output = Command::new("gh").arg("auth").arg("token").output().ok()?;
168    let mut token = String::from_utf8(output.stdout).ok()?;
169    // Trim the captured trailing newline from CLI output
170    let token_len = token.trim_end().len();
171    token.truncate(token_len);
172    Some(token)
173}