Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

234 linesCodeBlameRaw
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
5use base64::Engine as _;
6use serde_json::Value;
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::process::{Command, Stdio};
10
11/// A username and a g1t token, for the registry's token endpoint.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub 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.
19const USERNAME: &str = "g1t";
20
21pub 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
50fn 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
57fn 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`.
68pub 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.
109fn 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.
118fn 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
134pub 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)]
146mod 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}