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