refactor: move entity into smaller module

This commit is contained in:
Himadri Bhattacharjee
2025-04-22 11:06:55 +05:30
parent f5182721ff
commit d6d41b52c6
4 changed files with 204 additions and 192 deletions

View File

@@ -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<AuthFile, Error> {
let handle = std::fs::File::open(path)?;
@@ -27,38 +12,13 @@ pub async fn read(path: &Path) -> Result<AuthFile, Error> {
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<Self, Self::Error> {
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<KeyData> {
entities.iter().map(|e| e.key_data()).collect()
}
@@ -68,109 +28,10 @@ pub struct AuthFile {
pub key_pool: HashSet<KeyData>,
}
#[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<RwLock<Persona>>;
#[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<RwLock<Persona>> {
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),
}

148
src/entity.rs Normal file
View File

@@ -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<RwLock<Persona>>;
#[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<RwLock<Persona>> {
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<Self, Self::Err> {
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),
}

41
src/lookup.rs Normal file
View File

@@ -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<Self, Self::Err> {
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<T: AsRef<Entity>>(&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
}
}

View File

@@ -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<TermionBackend<TerminalHandle>>;
@@ -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<Self, Self::Err> {
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<T: AsRef<Entity>>(&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<Option<Self>, Error> {
fn parse(text: &str, role: entity::Role, name: String) -> Result<Option<Self>, 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 {