Skip to content
228 linesCodeBlameRaw
1//! Where the push's g1t token comes from: `G1T_TOKEN`, `--token-stdin`, or
2//! what `docker login` stored.
3
4use base64::Engine as _;
5use serde_json::Value;
6use std::io::{Read, Write};
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9
10/// A username and a g1t token, for the registry's token endpoint.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub 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.
18const USERNAME: &str = "g1t";
19
20pub 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
44fn 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
51fn 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`.
62pub 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.
103fn 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.
112fn 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
128pub 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)]
140mod 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}