commit ec7d93eeada6e2b1163e1d6b588c9339a9265f37 Author: Gabriel Kaszewski Date: Thu Mar 13 02:42:31 2025 +0100 initialize new Rust project with Cargo configuration and dependencies diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..712f656 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,377 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" + +[[package]] +name = "once_cell" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde51589ab56b20a6f686b2c68f7a0bd6add753d697abf720d63f8db3ab7b1ad" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + +[[package]] +name = "rustix" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "ssh-profiles" +version = "0.1.0" +dependencies = [ + "dialoguer", + "dirs", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ee35f12 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ssh-profiles" +version = "0.1.0" +edition = "2024" + +[dependencies] +dialoguer = "0.11.0" +dirs = "6.0.0" + +[profile.release] +strip = true +opt-level = "z" +lto = true +codegen-units = 1 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1478a92 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,349 @@ +use dialoguer::{Input, Select}; +use std::ops::Not; +use std::path::PathBuf; +use std::{fs, io}; + +#[derive(Clone, Debug)] +struct SshProfile { + host: String, + hostname: String, + port: u16, + user: String, + identity_file: Option, + password_authentication: Option, +} + +fn get_ssh_config_path() -> PathBuf { + let home_dir = dirs::home_dir().expect("Failed to get home directory"); + home_dir.join(".ssh/config") +} + +fn ensure_config_exists() -> io::Result<()> { + let path = get_ssh_config_path(); + if !path.exists() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, "")?; + } + + Ok(()) +} + +fn read_ssh_config() -> io::Result { + ensure_config_exists()?; + + let path = get_ssh_config_path(); + fs::read_to_string(path) +} + +fn parse_ssh_profiles(config: &str) -> Vec { + let mut profiles = Vec::new(); + let mut lines = config.lines(); + while let Some(line) = lines.next() { + if line.starts_with("Host ") { + let host = line[5..].trim().to_string(); + let mut hostname = String::new(); + let mut user = String::new(); + let mut port = 22; + let mut identity_file = None; + let mut password_authentication = None; + + while let Some(next_line) = lines.next() { + let trimmed = next_line.trim(); + if trimmed.starts_with("HostName ") { + hostname = trimmed[9..].to_string(); + } else if trimmed.starts_with("User ") { + user = trimmed[5..].to_string(); + } else if trimmed.starts_with("Port ") { + port = trimmed[5..].parse::().unwrap(); + } else if trimmed.starts_with("IdentityFile ") { + identity_file = Some(trimmed[13..].to_string()); + } else if trimmed.starts_with("PasswordAuthentication ") { + let value = trimmed[23..].to_lowercase(); + match value.as_str() { + "yes" => password_authentication = Some(true), + "no" => password_authentication = Some(false), + _ => {} + } + } else if trimmed.is_empty() { + break; + } + } + + profiles.push(SshProfile { + host, + hostname, + port, + user, + identity_file, + password_authentication, + }); + } + } + + profiles +} + +fn write_ssh_config(profiles: &[SshProfile]) -> io::Result<()> { + let path = get_ssh_config_path(); + let mut content = String::new(); + + for profile in profiles { + match (&profile.identity_file, profile.password_authentication) { + (Some(_), Some(_)) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Profile cannot have both identity file and password authentication", + )); + } + (None, None) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Profile must have either identity file or password authentication", + )); + } + (Some(identity_file), None) => { + content.push_str(&format!( + "Host {}\n HostName {}\n User {}\n Port {}\n IdentityFile {}\n\n", + profile.host, profile.hostname, profile.user, profile.port, identity_file + )); + } + (None, Some(password_authentication)) => match password_authentication { + true => { + content.push_str(&format!( + "Host {}\n HostName {}\n User {}\n Port {}\n PasswordAuthentication yes\n\n", + profile.host, profile.hostname, profile.user, profile.port + )); + } + false => { + content.push_str(&format!( + "Host {}\n HostName {}\n User {}\n Port {}\n PasswordAuthentication no\n\n", + profile.host, profile.hostname, profile.user, profile.port + )); + } + }, + } + } + + fs::write(path, content) +} + +fn list_profiles(profiles: &[SshProfile]) { + println!("Available SSH profiles:"); + + if profiles.is_empty() { + println!("No profiles found"); + return; + } + + for profile in profiles { + println!( + "- {} ({}@{}:{})", + profile.host, profile.user, profile.hostname, profile.port + ); + } +} + +fn add_profile(profiles: &mut Vec) { + let host: String = Input::new() + .with_prompt("Enter Host name") + .interact_text() + .unwrap(); + let hostname: String = Input::new() + .with_prompt("Enter HostName") + .interact_text() + .unwrap(); + let user: String = Input::new() + .with_prompt("Enter User") + .interact_text() + .unwrap(); + let port: u16 = Input::new() + .with_prompt("Enter Port") + .default(22) + .interact() + .unwrap(); + let identity_file: Option = Input::new() + .with_prompt("Enter IdentityFile") + .allow_empty(true) + .default(String::new()) + .interact_text() + .ok() + .map(|s| s.is_empty().not().then(|| s)) + .flatten(); + let password_authentication: Option = Input::new() + .with_prompt("Enter PasswordAuthentication") + .allow_empty(true) + .interact() + .ok(); + + match (&identity_file, password_authentication) { + (Some(_), Some(_)) => { + println!("Profile cannot have both identity file and password authentication"); + return; + } + (None, None) => { + println!("Profile must have either identity file or password authentication"); + return; + } + _ => {} + } + + profiles.push(SshProfile { + host, + hostname, + user, + port, + identity_file, + password_authentication, + }); + + println!("Profile added successfully"); +} + +fn edit_profile(profiles: &mut Vec) { + let options = profiles + .iter() + .map(|profile| profile.host.clone()) + .collect::>(); + if let Ok(selection) = Select::new() + .with_prompt("Select a profile to edit") + .items(&options) + .interact() + { + let profile = &mut profiles[selection]; + let options = [ + "Host", + "HostName", + "User", + "Port", + "IdentityFile", + "PasswordAuthentication", + ]; + let choice = Select::new() + .with_prompt("Choose a field to edit") + .items(&options) + .interact() + .unwrap(); + + match choice { + 0 => { + let host: String = Input::new() + .with_prompt("Enter Host name") + .default(profile.host.clone()) + .interact_text() + .unwrap(); + profile.host = host; + } + 1 => { + let hostname: String = Input::new() + .with_prompt("Enter HostName") + .default(profile.hostname.clone()) + .interact_text() + .unwrap(); + profile.hostname = hostname; + } + 2 => { + let user: String = Input::new() + .with_prompt("Enter User") + .default(profile.user.clone()) + .interact_text() + .unwrap(); + profile.user = user; + } + 3 => { + let port: u16 = Input::new() + .with_prompt("Enter Port") + .default(profile.port) + .interact() + .unwrap(); + profile.port = port; + } + 4 => { + if profile.password_authentication.is_some() { + println!("Profile already has PasswordAuthentication set"); + return; + } + + let identity_file: Option = Input::new() + .with_prompt("Enter IdentityFile") + .default(profile.identity_file.clone().unwrap_or_default()) + .interact_text() + .ok() + .map(|s| s.is_empty().not().then(|| s)) + .flatten(); + profile.identity_file = identity_file; + } + 5 => { + if profile.identity_file.is_some() { + println!("Profile already has IdentityFile set"); + return; + } + + let value = profile.password_authentication.unwrap_or(false); + let password_authentication: Option = Input::new() + .with_prompt("Enter PasswordAuthentication") + .default(value) + .interact() + .ok(); + profile.password_authentication = password_authentication; + } + _ => unreachable!(), + } + + println!("Profile edited successfully"); + } +} + +fn remove_profile(profiles: &mut Vec) { + let options: Vec = profiles + .iter() + .map(|profile| profile.host.clone()) + .collect(); + if let Ok(selection) = Select::new() + .with_prompt("Select a profile to remove") + .items(&options) + .interact() + { + profiles.remove(selection); + println!("Profile removed successfully"); + } +} + +fn main() { + let config = read_ssh_config().expect("Failed to read SSH config"); + let mut profiles = parse_ssh_profiles(&config); + + loop { + let options = [ + "List Profiles", + "Add Profile", + "Edit Profile", + "Remove Profile", + "Exit", + ]; + let choice = Select::new() + .with_prompt("Choose an action") + .items(&options) + .interact() + .unwrap(); + + match choice { + 0 => list_profiles(&profiles), + 1 => { + add_profile(&mut profiles); + write_ssh_config(&profiles).unwrap(); + } + 2 => { + edit_profile(&mut profiles); + write_ssh_config(&profiles).unwrap(); + } + 3 => { + remove_profile(&mut profiles); + write_ssh_config(&profiles).unwrap(); + } + 4 => break, + _ => unreachable!(), + } + } +}