Compare commits
2 commits
ec84e86659
...
7bbeff1918
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bbeff1918 | |||
| a844ec48a4 |
10 changed files with 1422 additions and 36 deletions
859
Cargo.lock
generated
859
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
|
@ -7,8 +7,8 @@ edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0"
|
||||||
clap = { version = "4.5.56", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "git-identity"
|
name = "git-identity"
|
||||||
|
|
@ -18,10 +18,18 @@ license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
chrono = "0.4.43"
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
thiserror = "2.0.18"
|
console = "0.16"
|
||||||
tracing = "0.1.44"
|
dialoguer = { version = "0.12", features = ["fuzzy-select"] }
|
||||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
dirs = "6.0"
|
||||||
|
gpgme = "0.11"
|
||||||
|
itertools = "0.14.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
thiserror = "2.0"
|
||||||
|
toml = "0.9"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
21
TODO.md
21
TODO.md
|
|
@ -3,17 +3,7 @@
|
||||||
## import
|
## import
|
||||||
Import an identity from a gpg key.
|
Import an identity from a gpg key.
|
||||||
|
|
||||||
Relevant crates:
|
We still need to save it somewhere
|
||||||
```Cargo.toml
|
|
||||||
# For finding the cache directory in which to store the SSH keys exported from GPG and identities
|
|
||||||
dirs = "6.0.0"
|
|
||||||
# For importing GPG keys
|
|
||||||
gpgme = "0.11.0"
|
|
||||||
|
|
||||||
# For reading/writing identities' files
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
|
||||||
toml = "0.9.11"
|
|
||||||
```
|
|
||||||
|
|
||||||
## use
|
## use
|
||||||
Set the identity of the current git repo
|
Set the identity of the current git repo
|
||||||
|
|
@ -37,9 +27,12 @@ List all saved identities
|
||||||
Interactively edit a saved identity (or in a text editor when using a specific flag)
|
Interactively edit a saved identity (or in a text editor when using a specific flag)
|
||||||
|
|
||||||
# User input
|
# User input
|
||||||
[inquire](https://docs.rs/inquire/latest/inquire/) seems simple enough. But
|
Currently, dialoguer (which uses the console crate) doesn't reset the terminal state properly when
|
||||||
[dialoguer](https://docs.rs/dialoguer/latest/dialoguer/) is the one use by tauri which seems much
|
the user exists using ctrl-c. Most importantly, the cursor remains hidden even after the program
|
||||||
more complete.
|
exited.
|
||||||
|
Maybe switching to [inquire](https://docs.rs/inquire/latest/inquire/) would fix this. If not, we
|
||||||
|
might need to check if console emits `std::io::ErrorKind::Interrupted` like it seems to me it does
|
||||||
|
and whether or not we can react to it easily.
|
||||||
|
|
||||||
# Completions
|
# Completions
|
||||||
clap_complete does that statically. It will be able to do it dynamically once the
|
clap_complete does that statically. It will be able to do it dynamically once the
|
||||||
|
|
|
||||||
22
src/cli.rs
22
src/cli.rs
|
|
@ -1,6 +1,10 @@
|
||||||
//! The Command Line Interface definition to the git-identity subcommand
|
//! The Command Line Interface definition to the git-identity subcommand
|
||||||
|
|
||||||
|
use std::{borrow::Cow, sync::LazyLock};
|
||||||
|
|
||||||
use clap::{ArgAction, Args, Parser, Subcommand};
|
use clap::{ArgAction, Args, Parser, Subcommand};
|
||||||
|
use console::Term;
|
||||||
|
use dialoguer::theme::ColorfulTheme;
|
||||||
|
|
||||||
/// Manages multiple identities (e.g. personal, work, university, ...) in git repos for you.
|
/// Manages multiple identities (e.g. personal, work, university, ...) in git repos for you.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
|
|
@ -15,7 +19,7 @@ pub struct Cli {
|
||||||
impl Cli {
|
impl Cli {
|
||||||
pub fn run_subcommand(self) -> anyhow::Result<()> {
|
pub fn run_subcommand(self) -> anyhow::Result<()> {
|
||||||
match self.command {
|
match self.command {
|
||||||
Command::SayHello(cli) => crate::subcommands::say_hello::main(self.global, cli),
|
Command::Import(cli) => crate::subcommands::import::main(self.global, cli),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -32,11 +36,19 @@ pub struct GlobalArgs {
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
SayHello(SayHelloCli),
|
Import(ImportCli),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A temporary subcommand for testing the cli
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
pub struct SayHelloCli {
|
pub struct ImportCli {
|
||||||
pub name: String,
|
/// A list of patterns to filter GPG keys with, works the same way as patterns you use with
|
||||||
|
/// the gpg command
|
||||||
|
#[arg(id = "PATTERN")]
|
||||||
|
pub patterns: Vec<Cow<'static, str>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub static THEME: LazyLock<ColorfulTheme> = LazyLock::new(|| ColorfulTheme {
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static STDERR: LazyLock<Term> = LazyLock::new(Term::stderr);
|
||||||
|
|
|
||||||
140
src/data.rs
Normal file
140
src/data.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
|
fs::File,
|
||||||
|
io::{Read as _, Write as _},
|
||||||
|
path::PathBuf,
|
||||||
|
sync::LazyLock,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::cli::THEME;
|
||||||
|
|
||||||
|
pub static DATA_FOLDER: LazyLock<PathBuf> = LazyLock::new(|| {
|
||||||
|
let mut dir = dirs::data_dir().expect("");
|
||||||
|
dir.push("git-identity");
|
||||||
|
dir
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static IDENTITIES_FOLDER: LazyLock<PathBuf> = LazyLock::new(|| {
|
||||||
|
let mut dir = DATA_FOLDER.clone();
|
||||||
|
dir.push("identities");
|
||||||
|
dir
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub alias: String,
|
||||||
|
pub data: IdentityData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum IdentityOpenError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
IO(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("error while deserializing: {0}")]
|
||||||
|
Deserialize(#[from] toml::de::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identity {
|
||||||
|
pub fn open<A>(alias: &str) -> Result<Self, IdentityOpenError>
|
||||||
|
where
|
||||||
|
A: Into<String>,
|
||||||
|
{
|
||||||
|
let alias = String::from(alias);
|
||||||
|
|
||||||
|
let path = {
|
||||||
|
let mut path = DATA_FOLDER.clone();
|
||||||
|
path.push("identities");
|
||||||
|
path.push(&alias);
|
||||||
|
path
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = File::options()
|
||||||
|
.read(true)
|
||||||
|
.write(false)
|
||||||
|
.create(false)
|
||||||
|
.open(path)?;
|
||||||
|
|
||||||
|
let mut contents = String::new();
|
||||||
|
file.read_to_string(&mut contents)?;
|
||||||
|
|
||||||
|
let data = IdentityData::deserialize(toml::Deserializer::parse(&contents)?)?;
|
||||||
|
|
||||||
|
Ok(Self { alias, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_path(&self) -> PathBuf {
|
||||||
|
let mut path = IDENTITIES_FOLDER.clone();
|
||||||
|
path.push(&self.alias);
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> std::io::Result<()> {
|
||||||
|
let contents = toml::to_string(&self.data)
|
||||||
|
.expect("there should only be serializable values in the internal data structure");
|
||||||
|
|
||||||
|
let path = self.get_path();
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
File::options()
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.append(false)
|
||||||
|
.create(true)
|
||||||
|
.open(&path)?
|
||||||
|
.write_all(contents.as_bytes())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_interactively(&self) -> anyhow::Result<()> {
|
||||||
|
let path = self.get_path();
|
||||||
|
|
||||||
|
if std::fs::exists(&path)?
|
||||||
|
&& !dialoguer::Confirm::with_theme(&*THEME)
|
||||||
|
.with_prompt(format!("{} already exists. Ovewrite?", path.display()))
|
||||||
|
.interact()?
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Ok(self.save()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Identity {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "\"{}\":", self.alias)?;
|
||||||
|
|
||||||
|
if let Some(ref name) = self.data.name {
|
||||||
|
write!(f, " {name}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref email) = self.data.email {
|
||||||
|
write!(f, " <{email}>")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref sigkey) = self.data.sigkey {
|
||||||
|
write!(f, " (sigkey: {sigkey})")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref authkey) = self.data.authkey {
|
||||||
|
write!(f, " (authkey: {})", authkey.display())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct IdentityData {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub sigkey: Option<String>,
|
||||||
|
pub authkey: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
//! cargo xtasks
|
//! cargo xtasks
|
||||||
|
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod data;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod subcommands;
|
pub mod subcommands;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::Subscriber
|
||||||
pub const DEFAULT_LOG_LEVEL: Level = if cfg!(debug_assertions) {
|
pub const DEFAULT_LOG_LEVEL: Level = if cfg!(debug_assertions) {
|
||||||
Level::DEBUG // debug builds
|
Level::DEBUG // debug builds
|
||||||
} else {
|
} else {
|
||||||
Level::WARN // release builds
|
Level::INFO // release builds
|
||||||
};
|
};
|
||||||
|
|
||||||
fn level_to_index(level: Level) -> u8 {
|
fn level_to_index(level: Level) -> u8 {
|
||||||
|
|
|
||||||
386
src/subcommands/import.rs
Normal file
386
src/subcommands/import.rs
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
ffi::{CStr, CString},
|
||||||
|
fs::File,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::LazyLock,
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use dialoguer::theme::Theme as _;
|
||||||
|
use gpgme::Context;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::cli::{STDERR, THEME};
|
||||||
|
|
||||||
|
pub fn main(_global_cli: crate::cli::GlobalArgs, cli: crate::cli::ImportCli) -> anyhow::Result<()> {
|
||||||
|
let mut ctx = gpgme::Context::from_protocol(gpgme::Protocol::OpenPgp)?;
|
||||||
|
|
||||||
|
let key = select_key(&mut ctx, cli.patterns.iter().map(Cow::as_ref))?;
|
||||||
|
|
||||||
|
let fingerprint = key
|
||||||
|
.fingerprint_raw()
|
||||||
|
.context("querying the fingerprint of your key")?
|
||||||
|
.to_str()
|
||||||
|
.expect("this should always be valid UTF-8");
|
||||||
|
|
||||||
|
let alias = dialoguer::Input::<String>::with_theme(&*THEME)
|
||||||
|
.with_prompt("What should this identity be named?")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let authkey: Option<PathBuf> = select_authkey(&mut ctx, &key, &alias)?;
|
||||||
|
|
||||||
|
let (name, email) = select_name_and_email(&key)?;
|
||||||
|
|
||||||
|
let identity = crate::data::Identity {
|
||||||
|
alias,
|
||||||
|
data: crate::data::IdentityData {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
sigkey: Some(fingerprint.to_owned()),
|
||||||
|
authkey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
identity.save_interactively()?;
|
||||||
|
|
||||||
|
info!("Imported identity {identity}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_key<P>(ctx: &mut gpgme::Context, patterns: P) -> anyhow::Result<gpgme::Key>
|
||||||
|
where
|
||||||
|
P: IntoIterator,
|
||||||
|
Vec<u8>: From<<P as IntoIterator>::Item>,
|
||||||
|
{
|
||||||
|
let patterns: Vec<CString> = patterns
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| Ok(CString::new(p)?))
|
||||||
|
.collect::<anyhow::Result<_>>()
|
||||||
|
.context("Converting patterns to CStrings")?;
|
||||||
|
|
||||||
|
let mut keys = ctx
|
||||||
|
.find_secret_keys(patterns.iter())?
|
||||||
|
.inspect(|it| {
|
||||||
|
if let Err(err) = it {
|
||||||
|
error!("failed to query information about a key. Got err: {err}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect::<Vec<gpgme::Key>>();
|
||||||
|
|
||||||
|
match keys.len() {
|
||||||
|
0 => {
|
||||||
|
STDERR.write_line("Found no matching key")?;
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
1 => return Ok(keys.pop().expect("Vec is non empty")),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is some weird behavior when using FuzzySelect with items spanning multiple lines
|
||||||
|
STDERR.clear_screen()?;
|
||||||
|
|
||||||
|
let index = dialoguer::FuzzySelect::with_theme(&*THEME)
|
||||||
|
.with_prompt("Select a GPG key to import")
|
||||||
|
.items(keys.iter().map(format_key_for_select))
|
||||||
|
.highlight_matches(false)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
Ok(keys.swap_remove(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_key_for_select(key: &gpgme::Key) -> Cow<'static, str> {
|
||||||
|
let fingerprint: Cow<str> = key
|
||||||
|
.fingerprint_raw()
|
||||||
|
.map(CStr::to_string_lossy)
|
||||||
|
.unwrap_or(Cow::Borrowed(&FINGERPRINT_ERRSTR));
|
||||||
|
|
||||||
|
let primary_key: Cow<str> = key
|
||||||
|
.primary_key()
|
||||||
|
.and_then(|key| format_subkey(key, true))
|
||||||
|
.unwrap_or(Cow::Borrowed(&PRIMARY_KEY_ERRSTR));
|
||||||
|
|
||||||
|
let user_ids: String = itertools::intersperse(
|
||||||
|
key.user_ids()
|
||||||
|
.map(|uid| Cow::Owned(format!(" uid {}", format_user_id(&uid)))),
|
||||||
|
Cow::Borrowed("\n"),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let subkeys = itertools::intersperse(
|
||||||
|
key.subkeys().map(|key| {
|
||||||
|
Cow::Owned(format!(
|
||||||
|
" {}",
|
||||||
|
format_subkey(key, false).unwrap_or(Cow::Borrowed(&SUBKEY_ERRSTR))
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
Cow::Borrowed("\n"),
|
||||||
|
)
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
let formatted = format!("{primary_key}\n {fingerprint:40}\n{user_ids}\n{subkeys}\n");
|
||||||
|
|
||||||
|
Cow::Owned(formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_subkey(key: gpgme::Subkey, is_primary: bool) -> Option<Cow<'static, str>> {
|
||||||
|
let algorithm = key
|
||||||
|
.algorithm()
|
||||||
|
.name_raw()
|
||||||
|
.map(CStr::to_string_lossy)
|
||||||
|
.unwrap_or(Cow::Borrowed(&ALGORITHM_ERRSTR));
|
||||||
|
|
||||||
|
let creation_time: Cow<str> = key
|
||||||
|
.creation_time()
|
||||||
|
.map(|time| {
|
||||||
|
let creation_time = chrono::DateTime::UNIX_EPOCH
|
||||||
|
+ time
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or(Duration::ZERO);
|
||||||
|
|
||||||
|
let local_creation_time = creation_time.with_timezone(&chrono::Local::now().timezone());
|
||||||
|
|
||||||
|
let formatted = local_creation_time.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
Cow::Owned(formatted)
|
||||||
|
})
|
||||||
|
.unwrap_or(Cow::Borrowed(&CREATION_TIME_ERRSTR));
|
||||||
|
|
||||||
|
let capabilities = {
|
||||||
|
let mut caps = String::new();
|
||||||
|
|
||||||
|
if key.can_sign() {
|
||||||
|
caps.push('S');
|
||||||
|
}
|
||||||
|
if key.can_encrypt() {
|
||||||
|
caps.push('E')
|
||||||
|
}
|
||||||
|
if key.can_certify() {
|
||||||
|
caps.push('C');
|
||||||
|
}
|
||||||
|
if key.can_authenticate() {
|
||||||
|
caps.push('A');
|
||||||
|
}
|
||||||
|
|
||||||
|
caps
|
||||||
|
};
|
||||||
|
|
||||||
|
let formatted = format!(
|
||||||
|
"{:3} {algorithm:7} {creation_time} [{capabilities}]",
|
||||||
|
match (key.is_secret(), is_primary) {
|
||||||
|
(true, true) => "sec",
|
||||||
|
(false, true) => "pub",
|
||||||
|
(true, false) => "ssb",
|
||||||
|
(false, false) => "syb",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(Cow::Owned(formatted))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_user_id(uid: &gpgme::UserId) -> String {
|
||||||
|
let [name, comment, email] = [uid.name_raw(), uid.comment_raw(), uid.email_raw()]
|
||||||
|
.map(|attr| attr.map(CStr::to_string_lossy).unwrap_or(Cow::Borrowed("")));
|
||||||
|
|
||||||
|
format!("{name} ({comment}) <{email}>")
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! format_error_strings {
|
||||||
|
($($name:ident = $val:expr;)+) => {
|
||||||
|
$(
|
||||||
|
static $name: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
let mut out = String::new();
|
||||||
|
THEME
|
||||||
|
.format_error(&mut out, $val)
|
||||||
|
.expect("format failed");
|
||||||
|
out
|
||||||
|
});
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format_error_strings! {
|
||||||
|
PRIMARY_KEY_ERRSTR = "[failed to query primary key]";
|
||||||
|
SUBKEY_ERRSTR = "[failed to query subkey]";
|
||||||
|
FINGERPRINT_ERRSTR = "[failed to query fingerprint]";
|
||||||
|
ALGORITHM_ERRSTR = "[failed to query algorithm]";
|
||||||
|
CREATION_TIME_ERRSTR = "[failed to query creation time]";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_authkey(
|
||||||
|
ctx: &mut gpgme::Context,
|
||||||
|
key: &gpgme::Key,
|
||||||
|
identity_name: &str,
|
||||||
|
) -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
let mut authkey_prompt = dialoguer::Input::<String>::with_theme(&*THEME)
|
||||||
|
.with_prompt("Select an SSH key (leave empty for none)")
|
||||||
|
.validate_with(|input: &String| -> Result<(), Cow<str>> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::exists(input) {
|
||||||
|
Ok(true) => Ok(()),
|
||||||
|
Ok(false) => Err(Cow::Borrowed("file not found")),
|
||||||
|
Err(err) => Err(Cow::Owned(err.to_string())),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.allow_empty(true);
|
||||||
|
|
||||||
|
if let Some(authkey) = try_export_as_ssh_key(ctx, key, identity_name)? {
|
||||||
|
let default_authkey = authkey.to_string_lossy();
|
||||||
|
|
||||||
|
if default_authkey.contains(char::REPLACEMENT_CHARACTER) {
|
||||||
|
warn!(
|
||||||
|
"the path to the key you just imported contains non UTF-8 characters. They will be lost if you enter it as is"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
authkey_prompt = authkey_prompt.with_initial_text(default_authkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let authkey = authkey_prompt.interact_text()?;
|
||||||
|
|
||||||
|
Ok(if authkey.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(PathBuf::from(authkey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the key has authentication capabilities then handles exporting is as an SSH key
|
||||||
|
/// according to the user's input.
|
||||||
|
fn try_export_as_ssh_key(
|
||||||
|
ctx: &mut gpgme::Context,
|
||||||
|
key: &gpgme::Key,
|
||||||
|
identity_name: &str,
|
||||||
|
) -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
if !key.can_authenticate()
|
||||||
|
|| !dialoguer::Confirm::with_theme(&*THEME)
|
||||||
|
.with_prompt(
|
||||||
|
"This key can authenticate. Do you want to export it as an SSH key for later?",
|
||||||
|
)
|
||||||
|
.interact()?
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_destination = {
|
||||||
|
let mut path = crate::data::DATA_FOLDER.clone();
|
||||||
|
path.push("ssh");
|
||||||
|
path.push(format!("{identity_name}.pub"));
|
||||||
|
path
|
||||||
|
};
|
||||||
|
|
||||||
|
let destination: PathBuf = dialoguer::Input::<String>::with_theme(&*THEME)
|
||||||
|
.with_prompt("Save as")
|
||||||
|
// FIXME: we are screwed if for example the linux user's name contains non valid UTF-8
|
||||||
|
.with_initial_text(default_destination.to_string_lossy())
|
||||||
|
.interact_text()?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let fingerprint = key
|
||||||
|
.fingerprint_raw()
|
||||||
|
.context("querying your key's fingerprint")?;
|
||||||
|
|
||||||
|
save_ssh_key(ctx, fingerprint, &destination)?;
|
||||||
|
|
||||||
|
info!("succesfully saved your SSH key to disk");
|
||||||
|
|
||||||
|
Ok(Some(destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_ssh_key<P: AsRef<Path>>(
|
||||||
|
ctx: &mut Context,
|
||||||
|
fingerprint: &CStr,
|
||||||
|
path: P,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if std::fs::exists(&path).context("Checking if file already exists")?
|
||||||
|
&& !dialoguer::Confirm::with_theme(&*THEME)
|
||||||
|
.with_prompt(format!(
|
||||||
|
"{} already exists. Overwrite?",
|
||||||
|
path.as_ref().display()
|
||||||
|
))
|
||||||
|
.interact()?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = {
|
||||||
|
let mut path = PathBuf::from(path.as_ref());
|
||||||
|
path.pop();
|
||||||
|
path
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(parent).context("Creating parent directories")?;
|
||||||
|
|
||||||
|
let mut out = File::options()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.append(false)
|
||||||
|
.open(&path)?;
|
||||||
|
|
||||||
|
ctx.export([fingerprint], gpgme::ExportMode::SSH, &mut out)
|
||||||
|
.context("asking GPG to export your SSH key")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_name_and_email(key: &gpgme::Key) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||||
|
fn select<'key, G>(
|
||||||
|
key: &'key gpgme::Key,
|
||||||
|
item_kind: &str,
|
||||||
|
getter: G,
|
||||||
|
) -> anyhow::Result<Option<String>>
|
||||||
|
where
|
||||||
|
G: Fn(&gpgme::UserId<'key>) -> Option<&'key CStr>,
|
||||||
|
{
|
||||||
|
let mut items_and_uids = key
|
||||||
|
.user_ids()
|
||||||
|
.flat_map(|uid| Some((getter(&uid)?, format_user_id(&uid))))
|
||||||
|
.collect::<Vec<(&CStr, String)>>();
|
||||||
|
|
||||||
|
let picked: Option<String> = if items_and_uids.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let choice = dialoguer::FuzzySelect::with_theme(&*THEME)
|
||||||
|
.with_prompt(format!(
|
||||||
|
"From which user ID would you like to import your {item_kind}?"
|
||||||
|
))
|
||||||
|
.items(
|
||||||
|
std::iter::once("None (enter my own or leave empty)")
|
||||||
|
.chain(items_and_uids.iter().map(|pair| pair.1.as_str())),
|
||||||
|
)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
if choice == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let out = items_and_uids.swap_remove(choice - 1).0;
|
||||||
|
drop(items_and_uids);
|
||||||
|
Some(out.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if let Some(item) = picked {
|
||||||
|
Some(item)
|
||||||
|
} else {
|
||||||
|
let input = dialoguer::Input::<String>::with_theme(&*THEME)
|
||||||
|
.with_prompt(format!("Enter your {item_kind} (leave empty for none)"))
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
if input.is_empty() { None } else { Some(input) }
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = select(key, "name", gpgme::UserId::name_raw)?;
|
||||||
|
let email = select(key, "email", gpgme::UserId::email_raw)?;
|
||||||
|
|
||||||
|
Ok((name, email))
|
||||||
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
pub mod say_hello;
|
pub mod import;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
pub fn main(
|
|
||||||
_global_args: crate::cli::GlobalArgs,
|
|
||||||
cli: crate::cli::SayHelloCli,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
println!("Hello {}!", cli.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue