| 1 | //! Where the push's g1t token comes from: `--token-stdin`, `G1T_TOKEN`, the |
| 2 | //! account `g1t auth login` signed in on the registry's host, or what |
| 3 | //! `docker login` stored. |
| 4 | |
| 5 | use base64::Engine as _; |
| 6 | use serde_json::Value; |
| 7 | use std::io::{Read, Write}; |
| 8 | use std::path::PathBuf; |
| 9 | use std::process::{Command, Stdio}; |
| 10 | |
| 11 | /// A username and a g1t token, for the registry's token endpoint. |
| 12 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 13 | pub struct Credentials { |
| 14 | pub username: String, |
| 15 | pub secret: String, |
| 16 | } |
| 17 | |
| 18 | /// Any username will do with a token; this is the one g1t sends. |
| 19 | const USERNAME: &str = "g1t"; |
| 20 | |
| 21 | pub fn find(host: &str, token_stdin: bool) -> Result<Credentials, String> { |
| 22 | if token_stdin { |
| 23 | let mut token = String::new(); |
| 24 | std::io::stdin() |
| 25 | .read_to_string(&mut token) |
| 26 | .map_err(|e| format!("Could not read the token from stdin: {e}"))?; |
| 27 | return token_credentials(token.trim()) |
| 28 | .ok_or_else(|| "--token-stdin read an empty token.".to_owned()); |
| 29 | } |
| 30 | if let Some(credentials) = std::env::var("G1T_TOKEN") |
| 31 | .ok() |
| 32 | .and_then(|t| token_credentials(t.trim())) |
| 33 | { |
| 34 | return Ok(credentials); |
| 35 | } |
| 36 | if let Some(credentials) = |
| 37 | crate::auth::registry_token(host).and_then(|t| token_credentials(t.trim())) |
| 38 | { |
| 39 | return Ok(credentials); |
| 40 | } |
| 41 | let config = docker_config_path().and_then(|path| std::fs::read_to_string(path).ok()); |
| 42 | if let Some(found) = config.and_then(|config| from_docker_config(&config, host, &run_helper)) { |
| 43 | return Ok(found); |
| 44 | } |
| 45 | Err(format!( |
| 46 | "No token for {host}. Sign in with `g1t auth login`, set G1T_TOKEN, or pass one with --token-stdin." |
| 47 | )) |
| 48 | } |
| 49 | |
| 50 | fn token_credentials(token: &str) -> Option<Credentials> { |
| 51 | (!token.is_empty()).then(|| Credentials { |
| 52 | username: USERNAME.to_owned(), |
| 53 | secret: token.to_owned(), |
| 54 | }) |
| 55 | } |
| 56 | |
| 57 | fn docker_config_path() -> Option<PathBuf> { |
| 58 | if let Some(dir) = std::env::var_os("DOCKER_CONFIG") { |
| 59 | return Some(PathBuf::from(dir).join("config.json")); |
| 60 | } |
| 61 | let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?; |
| 62 | Some(PathBuf::from(home).join(".docker").join("config.json")) |
| 63 | } |
| 64 | |
| 65 | /// Looks `host` up in a docker `config.json` the way docker does: its own |
| 66 | /// credential helper first, then the credential store, then a plain `auth`. |
| 67 | /// `helper` runs `docker-credential-<name> get`. |
| 68 | pub fn from_docker_config( |
| 69 | config: &str, |
| 70 | host: &str, |
| 71 | helper: &dyn Fn(&str, &str) -> Option<Credentials>, |
| 72 | ) -> Option<Credentials> { |
| 73 | let config: Value = serde_json::from_str(config).ok()?; |
| 74 | let servers = [host.to_owned(), format!("https://{host}")]; |
| 75 | if let Some(name) = config |
| 76 | .get("credHelpers") |
| 77 | .and_then(|h| h.get(host)) |
| 78 | .and_then(Value::as_str) |
| 79 | { |
| 80 | return servers.iter().find_map(|server| helper(name, server)); |
| 81 | } |
| 82 | if let Some(name) = config |
| 83 | .get("credsStore") |
| 84 | .and_then(Value::as_str) |
| 85 | .filter(|n| !n.is_empty()) |
| 86 | && let Some(found) = servers.iter().find_map(|server| helper(name, server)) |
| 87 | { |
| 88 | return Some(found); |
| 89 | } |
| 90 | let auths = config.get("auths")?.as_object()?; |
| 91 | auths |
| 92 | .iter() |
| 93 | .find(|(key, _)| server_host(key) == host) |
| 94 | .and_then(|(_, entry)| { |
| 95 | let auth = entry.get("auth")?.as_str()?; |
| 96 | let decoded = base64::engine::general_purpose::STANDARD |
| 97 | .decode(auth.trim()) |
| 98 | .ok()?; |
| 99 | let decoded = String::from_utf8(decoded).ok()?; |
| 100 | let (username, secret) = decoded.split_once(':')?; |
| 101 | (!secret.is_empty()).then(|| Credentials { |
| 102 | username: username.to_owned(), |
| 103 | secret: secret.to_owned(), |
| 104 | }) |
| 105 | }) |
| 106 | } |
| 107 | |
| 108 | /// `https://g1t.sh/v2/` and `g1t.sh` are the same server. |
| 109 | fn server_host(key: &str) -> &str { |
| 110 | let key = key |
| 111 | .strip_prefix("https://") |
| 112 | .or_else(|| key.strip_prefix("http://")) |
| 113 | .unwrap_or(key); |
| 114 | key.split('/').next().unwrap_or(key) |
| 115 | } |
| 116 | |
| 117 | /// `docker-credential-<name> get`, with the server on stdin. |
| 118 | fn run_helper(name: &str, server: &str) -> Option<Credentials> { |
| 119 | let mut child = Command::new(format!("docker-credential-{name}")) |
| 120 | .arg("get") |
| 121 | .stdin(Stdio::piped()) |
| 122 | .stdout(Stdio::piped()) |
| 123 | .stderr(Stdio::null()) |
| 124 | .spawn() |
| 125 | .ok()?; |
| 126 | child.stdin.take()?.write_all(server.as_bytes()).ok()?; |
| 127 | let output = child.wait_with_output().ok()?; |
| 128 | if !output.status.success() { |
| 129 | return None; |
| 130 | } |
| 131 | parse_helper_output(&output.stdout) |
| 132 | } |
| 133 | |
| 134 | pub fn parse_helper_output(stdout: &[u8]) -> Option<Credentials> { |
| 135 | let found: Value = serde_json::from_slice(stdout).ok()?; |
| 136 | let secret = found.get("Secret")?.as_str()?.to_owned(); |
| 137 | let username = found |
| 138 | .get("Username") |
| 139 | .and_then(Value::as_str) |
| 140 | .unwrap_or(USERNAME) |
| 141 | .to_owned(); |
| 142 | (!secret.is_empty()).then_some(Credentials { username, secret }) |
| 143 | } |
| 144 | |
| 145 | #[cfg(test)] |
| 146 | mod tests { |
| 147 | use super::*; |
| 148 | use std::cell::RefCell; |
| 149 | |
| 150 | fn no_helper(_: &str, _: &str) -> Option<Credentials> { |
| 151 | None |
| 152 | } |
| 153 | |
| 154 | fn creds(user: &str, secret: &str) -> Credentials { |
| 155 | Credentials { |
| 156 | username: user.into(), |
| 157 | secret: secret.into(), |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | #[test] |
| 162 | fn a_plain_auth_entry_is_decoded() { |
| 163 | // "syntaqx:g1t_secret" |
| 164 | let config = r#"{"auths":{"g1t.sh":{"auth":"c3ludGFxeDpnMXRfc2VjcmV0"}}}"#; |
| 165 | assert_eq!( |
| 166 | from_docker_config(config, "g1t.sh", &no_helper), |
| 167 | Some(creds("syntaqx", "g1t_secret")) |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | #[test] |
| 172 | fn auth_keys_may_be_urls() { |
| 173 | let config = r#"{"auths":{"https://g1t.sh/v2/":{"auth":"c3ludGFxeDpnMXRfc2VjcmV0"}}}"#; |
| 174 | assert_eq!( |
| 175 | from_docker_config(config, "g1t.sh", &no_helper), |
| 176 | Some(creds("syntaqx", "g1t_secret")) |
| 177 | ); |
| 178 | assert_eq!( |
| 179 | from_docker_config(config, "other.example", &no_helper), |
| 180 | None |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn a_credential_store_is_asked_for_the_host() { |
| 186 | let config = r#"{"auths":{"g1t.sh":{}},"credsStore":"desktop"}"#; |
| 187 | let asked = RefCell::new(Vec::new()); |
| 188 | let helper = |name: &str, server: &str| { |
| 189 | asked.borrow_mut().push(format!("{name} {server}")); |
| 190 | (server == "g1t.sh").then(|| creds("syntaqx", "from-store")) |
| 191 | }; |
| 192 | assert_eq!( |
| 193 | from_docker_config(config, "g1t.sh", &helper), |
| 194 | Some(creds("syntaqx", "from-store")) |
| 195 | ); |
| 196 | assert_eq!(asked.borrow().as_slice(), ["desktop g1t.sh"]); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn a_credential_store_without_the_host_falls_back_to_auths() { |
| 201 | let config = |
| 202 | r#"{"auths":{"g1t.sh":{"auth":"c3ludGFxeDpnMXRfc2VjcmV0"}},"credsStore":"desktop"}"#; |
| 203 | assert_eq!( |
| 204 | from_docker_config(config, "g1t.sh", &no_helper), |
| 205 | Some(creds("syntaqx", "g1t_secret")) |
| 206 | ); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn a_host_helper_comes_before_the_store() { |
| 211 | let config = r#"{"credsStore":"desktop","credHelpers":{"g1t.sh":"g1t"}}"#; |
| 212 | let helper = |name: &str, _: &str| (name == "g1t").then(|| creds("u", "from-helper")); |
| 213 | assert_eq!( |
| 214 | from_docker_config(config, "g1t.sh", &helper), |
| 215 | Some(creds("u", "from-helper")) |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn nothing_stored_is_none() { |
| 221 | assert_eq!( |
| 222 | from_docker_config(r#"{"auths":{}}"#, "g1t.sh", &no_helper), |
| 223 | None |
| 224 | ); |
| 225 | assert_eq!(from_docker_config("not json", "g1t.sh", &no_helper), None); |
| 226 | } |
| 227 | |
| 228 | #[test] |
| 229 | fn helper_output_is_read() { |
| 230 | let out = br#"{"ServerURL":"g1t.sh","Username":"syntaqx","Secret":"s"}"#; |
| 231 | assert_eq!(parse_helper_output(out), Some(creds("syntaqx", "s"))); |
| 232 | assert_eq!(parse_helper_output(b"credentials not found"), None); |
| 233 | } |
| 234 | } |