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.

g1t.sh
354 linesCodeBlameRaw
1//! Hosts and their tokens.
2//!
3//! A host is a g1t: g1t.sh, or one run by its owner. Each has a site (git
4//! remotes, pages) and an API; g1t.sh's are built in, and any other is found
5//! from its `/.well-known/g1t.json` when signing in.
6//!
7//! A token is the first of:
8//!
9//! 1. `G1T_TOKEN`, for whatever host the command talks to;
10//! 2. the system keychain (macOS Keychain, Windows Credential Manager, the
11//! Secret Service on Linux), under the service `g1t:<host>`;
12//! 3. `config.json`, where a token goes only when there is no keychain
13//! (a server, a container) or `--insecure-storage` asked for it.
14//!
15//! `G1T_KEYRING=off` keeps the keychain out of it entirely.
16
17use serde_json::Value;
18
19use crate::config::{Config, DEFAULT_HOST, HostConfig, UserConfig};
20use crate::error::{Error, Result};
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Host {
24 /// How it is named: `g1t.sh`, `git.example.com`, `localhost:8787`.
25 pub name: String,
26 pub site: String,
27 pub api: String,
28}
29
30impl Host {
31 pub fn hosted() -> Host {
32 Host {
33 name: DEFAULT_HOST.to_owned(),
34 site: "https://g1t.sh".to_owned(),
35 api: "https://api.g1t.sh".to_owned(),
36 }
37 }
38}
39
40/// `git.example.com`, `https://git.example.com/` or `http://localhost:8787`:
41/// the host's name and its site's address.
42pub fn parse_hostname(input: &str) -> Result<(String, String)> {
43 let input = input.trim().trim_end_matches('/');
44 let (scheme, rest) = match input.split_once("://") {
45 Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
46 Some((scheme, _)) => {
47 return Err(Error::usage(format!(
48 "`{scheme}://` is not a g1t address: use https://."
49 )));
50 }
51 None => ("https", input),
52 };
53 let name = rest.split('/').next().unwrap_or(rest).to_lowercase();
54 if name.is_empty() || name.contains(char::is_whitespace) {
55 return Err(Error::usage(format!("`{input}` is not a hostname.")));
56 }
57 Ok((name.clone(), format!("{scheme}://{name}")))
58}
59
60/// The host named by `--hostname`/`G1T_HOST`, or the default one.
61pub fn resolve(config: &Config, hostname: Option<&str>) -> Result<Host> {
62 let wanted = match hostname {
63 Some(h) if !h.trim().is_empty() => h.to_owned(),
64 _ => match std::env::var("G1T_HOST")
65 .ok()
66 .filter(|h| !h.trim().is_empty())
67 {
68 Some(h) => h,
69 None => config
70 .default_host
71 .clone()
72 .unwrap_or_else(|| DEFAULT_HOST.to_owned()),
73 },
74 };
75 let (name, site) = parse_hostname(&wanted)?;
76 let mut host = match config.host(&name) {
77 Some(h) => Host {
78 name,
79 site: h.site.clone(),
80 api: h.api.clone(),
81 },
82 None if name == DEFAULT_HOST => Host::hosted(),
83 // Not signed in to it: guess the API's address the way g1t is set
84 // up (api.<host>); `auth login` asks the host instead.
85 None => {
86 let api = site.replacen("://", "://api.", 1);
87 Host { name, site, api }
88 }
89 };
90 if let Some(api) = std::env::var("G1T_API_URL")
91 .ok()
92 .filter(|a| !a.trim().is_empty())
93 {
94 host.api = api.trim_end_matches('/').to_owned();
95 }
96 Ok(host)
97}
98
99/// Asks `site` where its API is.
100pub fn discover(name: &str, site: &str) -> Result<Host> {
101 if name == DEFAULT_HOST {
102 return Ok(Host::hosted());
103 }
104 let url = format!("{site}/.well-known/g1t.json");
105 let text = ureq::get(&url)
106 .timeout(std::time::Duration::from_secs(15))
107 .call()
108 .map_err(|e| Error::failed(format!("{name} did not say where its API is ({url}): {e}")))?
109 .into_string()
110 .map_err(|e| Error::failed(format!("Could not read {url}: {e}")))?;
111 let found: Value = serde_json::from_str(&text)
112 .map_err(|e| Error::failed(format!("{url} is not g1t's: {e}")))?;
113 let api = found["api"]
114 .as_str()
115 .ok_or_else(|| Error::failed(format!("{url} names no API.")))?;
116 let site_said = found["site"].as_str().unwrap_or(site);
117 Ok(Host {
118 name: name.to_owned(),
119 site: site_said.trim_end_matches('/').to_owned(),
120 api: api.trim_end_matches('/').to_owned(),
121 })
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum Source {
126 Env,
127 Keyring,
128 File,
129}
130
131impl Source {
132 pub fn label(self) -> &'static str {
133 match self {
134 Source::Env => "G1T_TOKEN",
135 Source::Keyring => "keychain",
136 Source::File => "config file",
137 }
138 }
139}
140
141#[derive(Clone, Debug)]
142pub struct Token {
143 pub value: String,
144 pub source: Source,
145 /// Whose it is, when the store knows.
146 pub user: Option<String>,
147}
148
149fn keyring_enabled() -> bool {
150 !matches!(
151 std::env::var("G1T_KEYRING")
152 .ok()
153 .as_deref()
154 .map(str::to_lowercase)
155 .as_deref(),
156 Some("off" | "0" | "false" | "no")
157 )
158}
159
160fn service(host: &str) -> String {
161 format!("g1t:{host}")
162}
163
164fn keyring_get(host: &str, user: &str) -> Option<String> {
165 if !keyring_enabled() {
166 return None;
167 }
168 let entry = keyring::Entry::new(&service(host), user).ok()?;
169 entry.get_password().ok().filter(|t| !t.is_empty())
170}
171
172fn keyring_set(host: &str, user: &str, token: &str) -> std::result::Result<(), String> {
173 if !keyring_enabled() {
174 return Err("G1T_KEYRING is off".to_owned());
175 }
176 let entry = keyring::Entry::new(&service(host), user).map_err(|e| e.to_string())?;
177 entry.set_password(token).map_err(|e| e.to_string())?;
178 // Some stores accept a write they cannot read back (a locked keyring).
179 match entry.get_password() {
180 Ok(back) if back == token => Ok(()),
181 _ => Err("the keychain did not keep it".to_owned()),
182 }
183}
184
185fn keyring_delete(host: &str, user: &str) {
186 if keyring_enabled()
187 && let Ok(entry) = keyring::Entry::new(&service(host), user)
188 {
189 let _ = entry.delete_credential();
190 }
191}
192
193/// The token for `host`, and the account it is for when known.
194pub fn token(config: &Config, host: &str, user: Option<&str>) -> Option<Token> {
195 if user.is_none()
196 && let Some(value) = std::env::var("G1T_TOKEN")
197 .ok()
198 .map(|t| t.trim().to_owned())
199 .filter(|t| !t.is_empty())
200 {
201 return Some(Token {
202 value,
203 source: Source::Env,
204 user: None,
205 });
206 }
207 let entry = config.host(host)?;
208 let user = user.map(str::to_owned).or_else(|| entry.user.clone())?;
209 stored_token(host, &user, entry.users.get(&user))
210}
211
212fn stored_token(host: &str, user: &str, entry: Option<&UserConfig>) -> Option<Token> {
213 if let Some(value) = entry
214 .and_then(|u| u.token.clone())
215 .filter(|t| !t.is_empty())
216 {
217 return Some(Token {
218 value,
219 source: Source::File,
220 user: Some(user.to_owned()),
221 });
222 }
223 keyring_get(host, user).map(|value| Token {
224 value,
225 source: Source::Keyring,
226 user: Some(user.to_owned()),
227 })
228}
229
230/// Keeps `token` for `user` on `host` and makes that account the active one.
231/// Returns where it went.
232pub fn store(config: &mut Config, host: &Host, user: &str, token: &str, insecure: bool) -> Source {
233 let source = if !insecure && keyring_set(&host.name, user, token).is_ok() {
234 Source::Keyring
235 } else {
236 Source::File
237 };
238 let entry = config
239 .hosts
240 .entry(host.name.clone())
241 .or_insert_with(|| HostConfig {
242 site: host.site.clone(),
243 api: host.api.clone(),
244 ..HostConfig::default()
245 });
246 entry.site = host.site.clone();
247 entry.api = host.api.clone();
248 entry.user = Some(user.to_owned());
249 entry.users.insert(
250 user.to_owned(),
251 UserConfig {
252 token: (source == Source::File).then(|| token.to_owned()),
253 },
254 );
255 if config.default_host.is_none() {
256 config.default_host = Some(host.name.clone());
257 }
258 source
259}
260
261/// Forgets `user` on `host`; the next account there, if any, becomes active.
262pub fn forget(config: &mut Config, host: &str, user: &str) {
263 keyring_delete(host, user);
264 let Some(entry) = config.hosts.get_mut(host) else {
265 return;
266 };
267 entry.users.remove(user);
268 if entry.user.as_deref() == Some(user) {
269 entry.user = entry.users.keys().next().cloned();
270 }
271 if entry.users.is_empty() {
272 config.hosts.remove(host);
273 if config.default_host.as_deref() == Some(host) {
274 config.default_host = config.hosts.keys().next().cloned();
275 }
276 }
277}
278
279/// Every account's token on `host`, for `auth status`.
280pub fn accounts(config: &Config, host: &str) -> Vec<(String, Option<Token>, bool)> {
281 let Some(entry) = config.host(host) else {
282 return Vec::new();
283 };
284 entry
285 .users
286 .iter()
287 .map(|(user, u)| {
288 (
289 user.clone(),
290 stored_token(host, user, Some(u)),
291 entry.user.as_deref() == Some(user),
292 )
293 })
294 .collect()
295}
296
297/// The token `g1t push` signs in to a registry with, when one is stored for
298/// its host.
299pub fn registry_token(host: &str) -> Option<String> {
300 let config = Config::load().ok()?;
301 let (name, _) = parse_hostname(host).ok()?;
302 let entry = config.host(&name)?;
303 let user = entry.user.clone()?;
304 stored_token(&name, &user, entry.users.get(&user)).map(|t| t.value)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn hostnames_take_any_form() {
313 assert_eq!(
314 parse_hostname("g1t.sh").unwrap(),
315 ("g1t.sh".into(), "https://g1t.sh".into())
316 );
317 assert_eq!(
318 parse_hostname("https://Git.Example.com/").unwrap(),
319 ("git.example.com".into(), "https://git.example.com".into())
320 );
321 assert_eq!(
322 parse_hostname("http://localhost:8787").unwrap(),
323 ("localhost:8787".into(), "http://localhost:8787".into())
324 );
325 assert!(parse_hostname("ssh://g1t.sh").is_err());
326 assert!(parse_hostname("").is_err());
327 }
328
329 #[test]
330 fn file_tokens_are_kept_and_forgotten() {
331 // SAFETY: tests that touch G1T_KEYRING all set it to off.
332 unsafe { std::env::set_var("G1T_KEYRING", "off") };
333 let mut config = Config::default();
334 let host = Host::hosted();
335 assert_eq!(
336 store(&mut config, &host, "ana", "g1t_a", false),
337 Source::File
338 );
339 store(&mut config, &host, "bo", "g1t_b", true);
340 assert_eq!(config.host("g1t.sh").unwrap().user.as_deref(), Some("bo"));
341 let t = stored_token(
342 "g1t.sh",
343 "ana",
344 config.host("g1t.sh").unwrap().users.get("ana"),
345 )
346 .unwrap();
347 assert_eq!((t.value.as_str(), t.source), ("g1t_a", Source::File));
348 forget(&mut config, "g1t.sh", "bo");
349 assert_eq!(config.host("g1t.sh").unwrap().user.as_deref(), Some("ana"));
350 forget(&mut config, "g1t.sh", "ana");
351 assert!(config.hosts.is_empty());
352 assert_eq!(config.default_host, None);
353 }
354}