diff --git a/src/authfile.rs b/src/authfile.rs index d78b12f..5af5aad 100644 --- a/src/authfile.rs +++ b/src/authfile.rs @@ -1,25 +1,10 @@ +use crate::entity::Entity; +use russh::keys::ssh_key::public::KeyData; use std::collections::HashSet; -use std::fmt::Display; use std::io::{BufRead, BufReader}; use std::path::Path; use std::sync::Arc; use thiserror::Error; -use tokio::sync::RwLock; - -use russh::keys::PublicKey; -use russh::keys::ssh_key::public::KeyData; - -pub fn sanitize_name(s: &str) -> String { - let mut sanitized = String::with_capacity(s.len()); - for c in s.chars() { - let ok = c.is_ascii_alphanumeric() || "@_-.".contains(c); - if !ok { - continue; - } - sanitized.push(c); - } - sanitized -} pub async fn read(path: &Path) -> Result { let handle = std::fs::File::open(path)?; @@ -27,38 +12,13 @@ pub async fn read(path: &Path) -> Result { let mut entities = vec![]; for line in reader.lines() { let line = line?; - let entity: Entity = line.as_str().try_into()?; - entities.push(entity); + entities.push(line.parse()?); } let key_pool = build_key_data_pool(&entities); let entities = entities.into_iter().map(Arc::new).collect(); Ok(AuthFile { entities, key_pool }) } -impl TryFrom<&str> for Entity { - type Error = Error; - - fn try_from(value: &str) -> Result { - let key = PublicKey::from_openssh(value)?; - - let comment = key.comment(); - let (name, role) = match comment.rsplit_once(":") { - Some((name, "admin")) => (name, Role::Admin), - None => (comment, Role::Normal), - _ => { - return Err(Error::InvalidRole(comment.to_string())); - } - }; - - let persona = Persona { - name: sanitize_name(name), - role, - }; - let persona = Arc::new(RwLock::new(persona)); - Ok(Entity { persona, key }) - } -} - fn build_key_data_pool(entities: &[Entity]) -> HashSet { entities.iter().map(|e| e.key_data()).collect() } @@ -68,109 +28,10 @@ pub struct AuthFile { pub key_pool: HashSet, } -#[derive(Clone, Debug)] -pub struct Persona { - name: String, - role: Role, -} - -impl Persona { - pub fn title(&self) -> String { - format!("[{} {}]", self.name, self.role) - } - - pub fn name(&self) -> String { - self.name.to_owned() - } - - pub fn role(&self) -> Role { - self.role - } -} - -pub type ArcPersona = Arc>; - -#[derive(Clone, Debug)] -pub struct Entity { - // Requires interior mutability for changing name and role - persona: ArcPersona, - // The public key does not change for an entity over - // the lifetime of the app - key: PublicKey, -} - -impl Entity { - /// NOTE: interior mutation on persona - pub async fn set_role(&mut self, role: Role) { - self.persona.write().await.role = role; - } - - /// NOTE: interior mutation on persona - pub async fn set_name(&self, name: &str) { - self.persona.write().await.name = name.to_string(); - } - - pub async fn to_pubkey(&self) -> PublicKey { - let mut original_key = self.key.clone(); - let persona = self.persona.read().await; - let name = &persona.name; - let role = if persona.role == Role::Admin { - ":admin" - } else { - "" - }; - original_key.set_comment(format!("{name}{role}")); - original_key - } - - pub async fn name(&self) -> String { - self.persona.read().await.name.to_string() - } - pub async fn role(&self) -> Role { - self.persona.read().await.role - } - - pub async fn title(&self) -> String { - self.persona.read().await.title() - } - - pub fn key_data(&self) -> KeyData { - self.key.key_data().clone() - } - - pub fn fingerprint(&self) -> String { - self.key - .fingerprint(russh::keys::HashAlg::Sha256) - .to_string() - } - - pub fn persona(&self) -> Arc> { - self.persona.clone() - } -} - #[derive(Error, Debug)] pub enum Error { #[error("unable to read authorization file")] FileNotReadable(#[from] std::io::Error), - #[error("failed to parse public key")] - PublicKeyParsing(#[from] russh::keys::ssh_key::Error), - #[error("invalid role specified in authorization file at line: {0}")] - InvalidRole(String), -} - -#[derive(Clone, Debug, PartialEq, Copy)] -pub enum Role { - Admin, - Normal, -} - -impl Display for Role { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let role = match self { - Role::Admin => "admin", - Role::Normal => "normal", - }; - write!(f, "{role}") - } + #[error("failed to parse entity: {0}")] + PublicKeyParsing(#[from] crate::entity::Error), } diff --git a/src/entity.rs b/src/entity.rs new file mode 100644 index 0000000..cabbcb2 --- /dev/null +++ b/src/entity.rs @@ -0,0 +1,148 @@ +use std::fmt::Display; +use std::str::FromStr; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::RwLock; + +use russh::keys::PublicKey; +use russh::keys::ssh_key::public::KeyData; +#[derive(Clone, Debug, PartialEq, Copy)] +pub enum Role { + Admin, + Normal, +} + +impl Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let role = match self { + Role::Admin => "admin", + Role::Normal => "normal", + }; + write!(f, "{role}") + } +} + +#[derive(Clone, Debug)] +pub struct Persona { + name: String, + role: Role, +} + +impl Persona { + pub fn title(&self) -> String { + format!("[{} {}]", self.name, self.role) + } + + pub fn name(&self) -> String { + self.name.to_owned() + } + + pub fn role(&self) -> Role { + self.role + } +} + +pub type ArcPersona = Arc>; + +#[derive(Clone, Debug)] +pub struct Entity { + // Requires interior mutability for changing name and role + persona: ArcPersona, + // The public key does not change for an entity over + // the lifetime of the app + key: PublicKey, +} + +impl Entity { + /// NOTE: interior mutation on persona + pub async fn set_role(&mut self, role: Role) { + self.persona.write().await.role = role; + } + + /// NOTE: interior mutation on persona + pub async fn set_name(&self, name: &str) { + self.persona.write().await.name = sanitize_name(name); + } + + pub async fn to_pubkey(&self) -> PublicKey { + let mut original_key = self.key.clone(); + let persona = self.persona.read().await; + let name = &persona.name; + let role = if persona.role == Role::Admin { + ":admin" + } else { + "" + }; + original_key.set_comment(format!("{name}{role}")); + original_key + } + + pub async fn name(&self) -> String { + self.persona.read().await.name.to_string() + } + pub async fn role(&self) -> Role { + self.persona.read().await.role + } + + pub async fn title(&self) -> String { + self.persona.read().await.title() + } + + pub fn key_data(&self) -> KeyData { + self.key.key_data().clone() + } + + pub fn fingerprint(&self) -> String { + self.key + .fingerprint(russh::keys::HashAlg::Sha256) + .to_string() + } + + pub fn persona(&self) -> Arc> { + self.persona.clone() + } +} + +fn sanitize_name(s: &str) -> String { + let mut sanitized = String::with_capacity(s.len()); + for c in s.chars() { + let ok = c.is_ascii_alphanumeric() || "@_-.".contains(c); + if !ok { + continue; + } + sanitized.push(c); + } + sanitized +} + +impl FromStr for Entity { + type Err = Error; + + fn from_str(s: &str) -> Result { + let key = PublicKey::from_openssh(s)?; + + let comment = key.comment(); + let (name, role) = match comment.rsplit_once(":") { + Some((name, "admin")) => (name, Role::Admin), + None => (comment, Role::Normal), + _ => { + return Err(Error::InvalidRole(comment.to_string())); + } + }; + + let persona = Persona { + name: sanitize_name(name), + role, + }; + let persona = Arc::new(RwLock::new(persona)); + Ok(Entity { persona, key }) + } +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("failed to parse public key")] + PublicKeyParsing(#[from] russh::keys::ssh_key::Error), + #[error("invalid role specified in authorization file at line: {0}")] + InvalidRole(String), +} diff --git a/src/lookup.rs b/src/lookup.rs new file mode 100644 index 0000000..ebb69cb --- /dev/null +++ b/src/lookup.rs @@ -0,0 +1,41 @@ +use crate::Error; +use crate::entity::Entity; +use std::str::FromStr; +pub enum EntityLookup { + Name(String), + Sha256(String), +} + +impl FromStr for EntityLookup { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + let lookup = match s.split_once(':') { + Some(("SHA256", digest)) if !digest.is_empty() && !digest.contains(':') => { + EntityLookup::Sha256(s.to_string()) + } + None => EntityLookup::Name(s.to_string()), + _ => return Err(Error::EntityLookup(s.to_string())), + }; + Ok(lookup) + } +} + +impl EntityLookup { + pub async fn matches>(&self, entity: T) -> bool { + let entity = entity.as_ref(); + match self { + EntityLookup::Name(name) => { + if entity.name().await.eq(name.as_str()) { + return true; + } + } + EntityLookup::Sha256(digest) => { + if entity.fingerprint().eq(digest.as_str()) { + return true; + } + } + } + false + } +} diff --git a/src/main.rs b/src/main.rs index ebe0acd..0fabb98 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use std::str::FromStr; use std::sync::Arc; use anyhow::Result; @@ -20,9 +19,11 @@ use tokio::sync::RwLock; use tui_textarea::TextArea; mod authfile; +mod entity; +mod lookup; mod terminal_handle; mod ui; -use authfile::{ArcPersona, Entity}; +use entity::{ArcPersona, Entity}; use terminal_handle::TerminalHandle; type SshTerminal = Terminal>; @@ -100,6 +101,8 @@ pub enum Error { EntityLookup(String), #[error("user {0:?} is not an admin")] NotAnAdmin(String), + #[error("failed to parse SSH key string to an entity")] + EntityParsing(#[from] entity::Error), } pub struct Client { @@ -146,7 +149,7 @@ impl AppServer { } async fn check_role_and_reload(&mut self) -> Result<(), Error> { - if self.entity().await.role().await != authfile::Role::Admin { + if self.entity().await.role().await != entity::Role::Admin { return Err(Error::NotAnAdmin(self.entity().await.name().await)); } self.reload().await @@ -259,8 +262,6 @@ impl AppServer { key_data_to_user.insert(key_data, entity); } Command::Rename { from, to } => { - let to = authfile::sanitize_name(&to); - for ent in self.keychain.read().await.iter() { if ent.name().await != from { continue; @@ -681,66 +682,27 @@ impl Drop for AppServer { } } -pub enum EntityLookup { - Name(String), - Sha256(String), -} - -impl FromStr for EntityLookup { - type Err = Error; - - fn from_str(s: &str) -> std::result::Result { - let lookup = match s.split_once(':') { - Some(("SHA256", digest)) if !digest.is_empty() && !digest.contains(':') => { - EntityLookup::Sha256(s.to_string()) - } - None => EntityLookup::Name(s.to_string()), - _ => return Err(Error::EntityLookup(s.to_string())), - }; - Ok(lookup) - } -} - -impl EntityLookup { - pub async fn matches>(&self, entity: T) -> bool { - let entity = entity.as_ref(); - match self { - EntityLookup::Name(name) => { - if entity.name().await.eq(name.as_str()) { - return true; - } - } - EntityLookup::Sha256(digest) => { - if entity.fingerprint().eq(digest.as_str()) { - return true; - } - } - } - false - } -} - pub enum Command { Add(Entity), Rename { from: String, to: String }, Commit, - Info(EntityLookup), + Info(lookup::EntityLookup), } impl Command { - fn parse(text: &str, role: authfile::Role, name: String) -> Result, Error> { + fn parse(text: &str, role: entity::Role, name: String) -> Result, Error> { if text == "/commit" { return Ok(Some(Self::Commit)); } let split = text.split_once(char::is_whitespace); - let is_admin = role == authfile::Role::Admin; + let is_admin = role == entity::Role::Admin; Ok(Some(match split { Some(("/add", payload)) => { if !is_admin { return Err(Error::NotAnAdmin(name)); } - Self::Add(payload.try_into()?) + Self::Add(payload.parse()?) } Some(("/rename", payload)) => { if !is_admin {