Skip to main content

gkit_core/
key.rs

1//! `key` — ssh key/identity management. Pure, testable core: rendering and
2//! regenerating the gkit-owned `~/.ssh/git_users` file (the disposable, `Include`d
3//! ssh config), ensuring the `Include` line, and listing hosts. Side effects
4//! (ssh-keygen, ssh-add, clipboard, file IO) live in the CLI layer.
5//!
6//! The single convention is the **alias**: it is the ssh `Host`, and the key is
7//! `~/.ssh/id_<alias>`. gkit OWNS `git_users` and rebuilds blocks (dedup), never
8//! blind-appends. macOS blocks include `UseKeychain yes`; Linux/Windows omit it.
9//!
10//! Cross-OS: the home directory and clipboard tool differ per platform —
11//! [`home_from_env`] and [`clipboard_candidates`] are the pure, testable pieces;
12//! the CLI layer wires them to the real environment and processes.
13
14use std::path::PathBuf;
15
16/// Resolve the user's home directory from an env lookup, across OSes:
17/// `HOME` (Unix/macOS) → `USERPROFILE` (Windows) → `HOMEDRIVE`+`HOMEPATH`
18/// (older Windows). Empty values are ignored. Returns `None` if none are set.
19pub fn home_from_env(get: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
20    let nonempty = |k: &str| get(k).filter(|v| !v.is_empty());
21    if let Some(h) = nonempty("HOME") {
22        return Some(PathBuf::from(h));
23    }
24    if let Some(up) = nonempty("USERPROFILE") {
25        return Some(PathBuf::from(up));
26    }
27    if let (Some(d), Some(p)) = (nonempty("HOMEDRIVE"), nonempty("HOMEPATH")) {
28        return Some(PathBuf::from(format!("{d}{p}")));
29    }
30    None
31}
32
33/// Ordered clipboard programs to try for a target OS (pass
34/// `std::env::consts::OS`: `"macos"`, `"windows"`, else treated as Linux/Unix).
35/// Each is `(program, args)`; the CLI runs them in order and the first that
36/// spawns and accepts the public key on stdin wins (else it prints the key).
37pub fn clipboard_candidates(os: &str) -> Vec<(&'static str, Vec<&'static str>)> {
38    match os {
39        "macos" => vec![("pbcopy", vec![])],
40        "windows" => vec![("clip", vec![])],
41        // Linux/BSD: Wayland first, then X11 (xclip, then xsel).
42        _ => vec![
43            ("wl-copy", vec![]),
44            ("xclip", vec!["-selection", "clipboard"]),
45            ("xsel", vec!["--clipboard", "--input"]),
46        ],
47    }
48}
49
50/// Standard git hosts offered by `key add`'s interactive provider menu, in menu
51/// order. The first is the default; a final "other" entry (index `len + 1` in the
52/// menu) lets the user type a custom/private hostname.
53pub const PROVIDERS: &[&str] = &["github.com", "bitbucket.org", "gitlab.com"];
54
55/// Outcome of a provider-menu selection (the raw line the user typed).
56#[derive(Debug, PartialEq, Eq)]
57pub enum ProviderChoice {
58    /// A resolved hostname: empty input → the default, a standard pick, or a
59    /// hostname typed verbatim (so power users can skip the menu).
60    Host(String),
61    /// The "other" entry — the caller should prompt for a custom hostname.
62    Custom,
63    /// A bare number outside the menu range — the caller should re-ask.
64    Invalid,
65}
66
67/// Map a provider-menu line to a [`ProviderChoice`]. Empty → default (first
68/// provider). A bare number selects a menu entry (`len + 1` = "other"); an
69/// out-of-range number is [`ProviderChoice::Invalid`]. Any non-numeric input is
70/// taken as a literal hostname.
71pub fn provider_choice(raw: &str) -> ProviderChoice {
72    let t = raw.trim();
73    if t.is_empty() {
74        return ProviderChoice::Host(PROVIDERS[0].to_string());
75    }
76    if let Ok(n) = t.parse::<usize>() {
77        if (1..=PROVIDERS.len()).contains(&n) {
78            return ProviderChoice::Host(PROVIDERS[n - 1].to_string());
79        }
80        if n == PROVIDERS.len() + 1 {
81            return ProviderChoice::Custom;
82        }
83        return ProviderChoice::Invalid;
84    }
85    ProviderChoice::Host(t.to_string())
86}
87
88/// Render the ssh `Host` block for an alias.
89pub fn host_block(alias: &str, hostname: &str, port: Option<u16>, macos: bool) -> String {
90    let mut s = String::new();
91    s.push_str(&format!("Host {alias}\n"));
92    s.push_str(&format!("  HostName {hostname}\n"));
93    s.push_str("  User git\n");
94    s.push_str("  AddKeysToAgent yes\n");
95    if macos {
96        s.push_str("  UseKeychain yes\n");
97    }
98    s.push_str("  IdentitiesOnly yes\n");
99    s.push_str(&format!("  IdentityFile ~/.ssh/id_{alias}\n"));
100    if let Some(p) = port {
101        s.push_str(&format!("  Port {p}\n"));
102    }
103    s
104}
105
106/// Regenerate `git_users` with `alias`'s block upserted: remove any existing block
107/// for that alias, then append the new one. Never blind-appends duplicates.
108pub fn upsert_block(existing: &str, alias: &str, block: &str) -> String {
109    let kept = remove_host_block(existing, alias);
110    let mut out = kept.trim_end().to_string();
111    if !out.is_empty() {
112        out.push_str("\n\n");
113    }
114    out.push_str(block.trim_end());
115    out.push('\n');
116    out
117}
118
119/// Remove the `Host <alias>` block (from its `Host` line to the next `Host` line
120/// or EOF), keeping everything else.
121fn remove_host_block(content: &str, alias: &str) -> String {
122    let mut out = String::new();
123    let mut skipping = false;
124    for line in content.lines() {
125        if let Some(rest) = line.trim_start().strip_prefix("Host ") {
126            skipping = rest.split_whitespace().next() == Some(alias);
127        }
128        if !skipping {
129            out.push_str(line);
130            out.push('\n');
131        }
132    }
133    out
134}
135
136/// Ensure `~/.ssh/config` `Include`s `git_users`. Returns the updated content if a
137/// change is needed, else `None`.
138pub fn ensure_include(ssh_config: &str) -> Option<String> {
139    if ssh_config.lines().any(|l| l.trim() == "Include git_users") {
140        return None;
141    }
142    let mut s = String::from("Include git_users\n");
143    if !ssh_config.trim().is_empty() {
144        s.push('\n');
145        s.push_str(ssh_config);
146        if !ssh_config.ends_with('\n') {
147            s.push('\n');
148        }
149    }
150    Some(s)
151}
152
153/// List `(alias, identity_file)` pairs parsed from `git_users`.
154pub fn list_hosts(git_users: &str) -> Vec<(String, String)> {
155    let mut hosts: Vec<(String, String)> = Vec::new();
156    for line in git_users.lines() {
157        let t = line.trim_start();
158        if let Some(rest) = t.strip_prefix("Host ") {
159            if let Some(a) = rest.split_whitespace().next() {
160                hosts.push((a.to_string(), String::new()));
161            }
162        } else if let Some(idf) = t.strip_prefix("IdentityFile ") {
163            if let Some(last) = hosts.last_mut() {
164                last.1 = idf.trim().to_string();
165            }
166        }
167    }
168    hosts
169}
170
171/// The `HostName` of the `Host <alias>` block in `git_users`, or `None` if the alias
172/// has no block / no `HostName`. Lets `gkit clone` resolve an ssh alias (e.g. `tlbb`)
173/// to its real host (`bitbucket.org`) so it can write the namespace-scoped
174/// `url."<alias>:<ns>/".insteadOf "git@<hostname>:<ns>/"` rewrite — keeping the alias
175/// out of checked-in URLs while still routing through the per-alias key.
176pub fn hostname_for(git_users: &str, alias: &str) -> Option<String> {
177    let mut in_block = false;
178    for line in git_users.lines() {
179        let t = line.trim_start();
180        if let Some(rest) = t.strip_prefix("Host ") {
181            in_block = rest.split_whitespace().next() == Some(alias);
182        } else if in_block {
183            if let Some(h) = t.strip_prefix("HostName ") {
184                return Some(h.trim().to_string());
185            }
186        }
187    }
188    None
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn provider_menu_maps_input() {
197        // Empty → default (first provider).
198        assert_eq!(
199            provider_choice(""),
200            ProviderChoice::Host("github.com".into())
201        );
202        assert_eq!(
203            provider_choice("  "),
204            ProviderChoice::Host("github.com".into())
205        );
206        // Standard numbered picks.
207        assert_eq!(
208            provider_choice("1"),
209            ProviderChoice::Host("github.com".into())
210        );
211        assert_eq!(
212            provider_choice("2"),
213            ProviderChoice::Host("bitbucket.org".into())
214        );
215        assert_eq!(
216            provider_choice("3"),
217            ProviderChoice::Host("gitlab.com".into())
218        );
219        // The "other" entry (len + 1) → Custom.
220        assert_eq!(provider_choice("4"), ProviderChoice::Custom);
221        // Out-of-range number → Invalid (re-ask).
222        assert_eq!(provider_choice("9"), ProviderChoice::Invalid);
223        assert_eq!(provider_choice("0"), ProviderChoice::Invalid);
224        // Non-numeric → literal hostname (power users skip the menu).
225        assert_eq!(
226            provider_choice("git.mycorp.com"),
227            ProviderChoice::Host("git.mycorp.com".into())
228        );
229    }
230
231    #[test]
232    fn block_is_os_aware() {
233        let mac = host_block("acme", "github.com", None, true);
234        assert!(mac.contains("Host acme"));
235        assert!(mac.contains("IdentityFile ~/.ssh/id_acme"));
236        assert!(mac.contains("UseKeychain yes"));
237        let linux = host_block("acme", "github.com", None, false);
238        assert!(!linux.contains("UseKeychain"));
239    }
240
241    #[test]
242    fn block_includes_port_when_set() {
243        assert!(host_block("a", "h", Some(2222), false).contains("Port 2222"));
244        assert!(!host_block("a", "h", None, false).contains("Port"));
245    }
246
247    #[test]
248    fn upsert_replaces_existing_alias_keeps_others() {
249        let existing = "Include project_config\n\nHost acme\n  HostName old\n  IdentityFile ~/.ssh/id_acme\n\nHost other\n  HostName github.com\n";
250        let new_block = host_block("acme", "github.com", None, true);
251        let out = upsert_block(existing, "acme", &new_block);
252        assert_eq!(
253            out.matches("Host acme").count(),
254            1,
255            "exactly one acme block:\n{out}"
256        );
257        assert!(out.contains("HostName github.com")); // new value
258        assert!(!out.contains("HostName old")); // old acme block gone
259        assert!(out.contains("Host other")); // unrelated block preserved
260        assert!(out.contains("Include project_config")); // preamble preserved
261    }
262
263    #[test]
264    fn ensure_include_adds_only_when_missing() {
265        assert!(ensure_include("Host x\n")
266            .unwrap()
267            .starts_with("Include git_users"));
268        assert_eq!(ensure_include("Include git_users\nHost x\n"), None);
269    }
270
271    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
272        let m: std::collections::HashMap<String, String> = pairs
273            .iter()
274            .map(|(k, v)| (k.to_string(), v.to_string()))
275            .collect();
276        move |k: &str| m.get(k).cloned()
277    }
278
279    #[test]
280    fn home_resolves_home_then_userprofile_then_homedrive() {
281        // HOME wins (Unix/macOS).
282        assert_eq!(
283            home_from_env(env_of(&[
284                ("HOME", "/home/u"),
285                ("USERPROFILE", "C:\\Users\\u")
286            ])),
287            Some(PathBuf::from("/home/u"))
288        );
289        // Windows: no HOME → USERPROFILE.
290        assert_eq!(
291            home_from_env(env_of(&[("USERPROFILE", "C:\\Users\\u")])),
292            Some(PathBuf::from("C:\\Users\\u"))
293        );
294        // Empty HOME is ignored, falls through.
295        assert_eq!(
296            home_from_env(env_of(&[("HOME", ""), ("USERPROFILE", "C:\\Users\\u")])),
297            Some(PathBuf::from("C:\\Users\\u"))
298        );
299        // Older Windows: HOMEDRIVE + HOMEPATH.
300        assert_eq!(
301            home_from_env(env_of(&[("HOMEDRIVE", "C:"), ("HOMEPATH", "\\Users\\u")])),
302            Some(PathBuf::from("C:\\Users\\u"))
303        );
304        // Nothing set → None (CLI then falls back to ".").
305        assert_eq!(home_from_env(env_of(&[])), None);
306    }
307
308    #[test]
309    fn clipboard_candidates_are_os_specific() {
310        let names = |os| {
311            clipboard_candidates(os)
312                .into_iter()
313                .map(|(p, _)| p)
314                .collect::<Vec<_>>()
315        };
316        assert_eq!(names("macos"), vec!["pbcopy"]);
317        assert_eq!(names("windows"), vec!["clip"]);
318        assert_eq!(names("linux"), vec!["wl-copy", "xclip", "xsel"]);
319    }
320
321    #[test]
322    fn lists_hosts_with_identity() {
323        let g =
324            "Host acme\n  IdentityFile ~/.ssh/id_acme\nHost work\n  IdentityFile ~/.ssh/id_work\n";
325        assert_eq!(
326            list_hosts(g),
327            vec![
328                ("acme".into(), "~/.ssh/id_acme".into()),
329                ("work".into(), "~/.ssh/id_work".into())
330            ]
331        );
332    }
333
334    #[test]
335    fn hostname_for_resolves_per_block() {
336        // Two aliases on the same host with different keys (the multi-client case):
337        // each resolves its OWN HostName, scoped to its block.
338        let g = "Host ltlgh\n  HostName github.com\n  IdentityFile ~/.ssh/id_ltlgh\n\
339                 Host tlbb\n  HostName bitbucket.org\n  IdentityFile ~/.ssh/id_tlbb\n";
340        assert_eq!(hostname_for(g, "ltlgh").as_deref(), Some("github.com"));
341        assert_eq!(hostname_for(g, "tlbb").as_deref(), Some("bitbucket.org"));
342        // unknown alias, or a block with no HostName → None
343        assert_eq!(hostname_for(g, "nope"), None);
344        assert_eq!(
345            hostname_for("Host x\n  IdentityFile ~/.ssh/id_x\n", "x"),
346            None
347        );
348    }
349}