From 8d533f13dc695b8115209a6149181b09e2bfe2e5 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 12:22:11 +0200 Subject: [PATCH] feat: add PostgreSQL connection form --- src/app.rs | 2 +- src/connection.rs | 241 ++++++++++++++++++++++++++++++++++++++++++---- src/db.rs | 5 +- src/main.rs | 9 +- 4 files changed, 236 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2105cf6..612ada3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::{Constraint, Layout}, diff --git a/src/connection.rs b/src/connection.rs index e7a9b75..90ab3e9 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -1,6 +1,7 @@ use std::{borrow::Cow, path::Path}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use postgres::{Config, config::SslMode}; use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout, Rect}, @@ -34,22 +35,26 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< if browser.is_none() { return Ok(()); } + form.clear_password(); show_form = false; } + KeyCode::Enter if form.focus == 0 => form.focus = 1, KeyCode::Enter => { terminal.draw(|frame| form.draw(frame, true))?; match form.connect().and_then(App::new) { Ok(connected) => { + form.clear_password(); browser = Some(connected); show_form = false; } Err(error) => form.error = Some(error_text(error.as_ref())), } } - _ => form.path.edit(key), + _ => form.edit(key), } } else if key.code == KeyCode::Char('c') { form.error = None; + form.focus = 0; show_form = true; } else if browser .as_mut() @@ -58,31 +63,116 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< return Ok(()); } } - Event::Paste(text) if show_form => form.path.paste(&text), + Event::Paste(text) if show_form => { + form.error = None; + if let Some(input) = form.input_mut() { + input.paste(&text); + } + } _ => {} } } } -#[derive(Default)] struct Form { + provider: usize, + focus: usize, + postgres: [Input; 5], + tls: usize, path: Input, error: Option, } +impl Default for Form { + fn default() -> Self { + let user = std::env::var("USER").unwrap_or_default(); + Self { + provider: 0, + focus: 0, + tls: 0, + postgres: ["localhost", "5432", "postgres", &user, ""].map(Input::from), + path: Input::default(), + error: None, + } + } +} + impl Form { fn connect(&self) -> Result { - if self.path.value.is_empty() { - return Err("Enter an existing SQLite database file path.".into()); + if self.provider == 1 { + if self.path.value.is_empty() { + return Err("Enter an existing SQLite database file path.".into()); + } + return Database::open_sqlite(Path::new(&self.path.value)); } - Database::open_sqlite(Path::new(&self.path.value)) + let [host, port, database, user, password] = &self.postgres; + if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() { + return Err("PostgreSQL requires a host, database, and user.".into()); + } + let port: u16 = port + .value + .parse() + .ok() + .filter(|port| *port > 0) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Port must be a number from 1 to 65535.", + ) + })?; + let mut config = Config::new(); + config + .host(&host.value) + .port(port) + .dbname(&database.value) + .user(&user.value) + .ssl_mode([SslMode::Require, SslMode::Prefer, SslMode::Disable][self.tls]); + if !password.value.is_empty() { + config.password(&password.value); + } + Database::connect_postgres(config) + } + + fn input_mut(&mut self) -> Option<&mut Input> { + if self.provider == 1 { + (self.focus == 1).then_some(&mut self.path) + } else { + self.postgres.get_mut(self.focus.checked_sub(1)?) + } + } + + fn clear_password(&mut self) { + self.postgres[4] = Input::default(); + } + + fn edit(&mut self, key: KeyEvent) { + let count = if self.provider == 0 { 7 } else { 2 }; + match key.code { + KeyCode::Tab => self.focus = (self.focus + 1) % count, + KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => { + self.provider = 1 - self.provider + } + KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 6 => { + self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3; + } + _ => { + if let Some(input) = self.input_mut() { + input.edit(key); + } + } + } + self.error = None; } fn draw(&self, frame: &mut Frame, connecting: bool) { let area = frame.area(); - if area.width < 70 || area.height < 12 { + let minimum_height = if self.provider == 0 { 16 } else { 12 }; + if area.width < 70 || area.height < minimum_height { frame.render_widget( - Paragraph::new("Enlarge terminal to at least 70×12. Ctrl-C to quit."), + Paragraph::new(format!( + "Enlarge terminal to at least 70×{minimum_height}. Ctrl-C to quit." + )), area, ); return; @@ -102,16 +192,87 @@ impl Form { let [providers, details] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); frame.render_widget( - List::new([ListItem::new(" SQLite · local file") - .style(Style::default().bg(Color::Blue).fg(Color::White))]) - .block(Block::bordered().title(" Database type ")), + List::new( + [" PostgreSQL · server", " SQLite · local file"] + .into_iter() + .enumerate() + .map(|(index, name)| { + ListItem::new(name).style(if index == self.provider { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default() + }) + }), + ) + .block(Block::bordered().title(" Database type ").border_style( + Style::default().fg(if self.focus == 0 { + Color::Cyan + } else { + Color::DarkGray + }), + )), providers, ); - let [path, description] = - Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details); - self.path - .draw(frame, path, " Database file path ", false, true); - frame.render_widget(Paragraph::new("Open an existing SQLite file.\nFiles are never created or modified.\n\nConnection details stay in this session.").wrap(Wrap { trim: false }), description); + if self.provider == 1 { + let [path, description] = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details); + self.path + .draw(frame, path, " Database file path ", false, self.focus == 1); + frame.render_widget(Paragraph::new("Open an existing SQLite file.\nFiles are never created or modified.\n\nConnection details stay in this session.").wrap(Wrap { trim: false }), description); + } else { + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(details); + let labels = [ + " Host / socket directory ", + " Port ", + " Database ", + " User ", + " Password ", + ]; + let pairs: [[Rect; 2]; 3] = std::array::from_fn(|index| { + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(rows[index]) + }); + for (index, input) in self.postgres.iter().enumerate() { + input.draw( + frame, + pairs[index / 2][index % 2], + labels[index], + index == 4, + self.focus == index + 1, + ); + } + frame.render_widget( + Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block( + Block::bordered().title(" TLS · ←/→ change ").border_style( + Style::default().fg(if self.focus == 6 { + Color::Cyan + } else { + Color::DarkGray + }), + ), + ), + pairs[2][1], + ); + let tls_notice = match self.tls { + 0 => "TLS required; certificates and hostnames are verified.", + 1 => "TLS preferred; the server may fall back to unencrypted traffic.", + _ => "TLS disabled: traffic is unencrypted. Use only on trusted networks.", + }; + frame.render_widget( + Paragraph::new(vec![ + Line::raw(tls_notice), + Line::raw("Passwords are masked and never saved to disk."), + ]) + .wrap(Wrap { trim: false }), + rows[3], + ); + } let message = if connecting { "Connecting…" } else { @@ -127,7 +288,7 @@ impl Form { })), status, ); - frame.render_widget(Paragraph::new(" Enter connect · Esc cancel/quit · Ctrl-C quit\n ←→ Home/End edit · Backspace/Delete erase · Ctrl-U clear").style(Style::default().fg(Color::DarkGray)), help); + frame.render_widget(Paragraph::new(" Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit").style(Style::default().fg(Color::DarkGray)), help); } } @@ -137,6 +298,15 @@ struct Input { cursor: usize, } +impl From<&str> for Input { + fn from(value: &str) -> Self { + Self { + value: value.into(), + cursor: value.len(), + } + } +} + impl Input { fn edit(&mut self, key: KeyEvent) { match key.code { @@ -248,4 +418,41 @@ mod tests { assert!(input.value.is_empty()); assert_eq!(input.cursor, 0); } + + #[test] + fn password_never_appears_in_the_connection_screen() -> Result<()> { + let mut form = Form { + focus: 5, + ..Form::default() + }; + form.postgres[4] = Input::from("test-secret-é"); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?; + terminal.draw(|frame| form.draw(frame, false))?; + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(!rendered.contains("test-secret-é")); + assert!(rendered.contains("•••••••••••••")); + Ok(()) + } + + #[test] + fn invalid_ports_are_rejected_before_connecting() { + for port in ["0", "65536", "not-a-port"] { + let mut form = Form::default(); + form.postgres[1] = Input::from(port); + form.postgres[3] = Input::from("inspector"); + let error = form.connect().err().unwrap(); + assert_eq!( + error + .downcast_ref::() + .map(std::io::Error::kind), + Some(std::io::ErrorKind::InvalidInput) + ); + } + } } diff --git a/src/db.rs b/src/db.rs index c77afd4..a6eef52 100644 --- a/src/db.rs +++ b/src/db.rs @@ -48,7 +48,10 @@ pub struct Page { impl Database { pub fn connect(connection: &str) -> Result { - let mut config: Config = connection.parse()?; + Self::connect_postgres(connection.parse()?) + } + + pub fn connect_postgres(mut config: Config) -> Result { if config.get_connect_timeout().is_none() { config.connect_timeout(Duration::from_secs(5)); } diff --git a/src/main.rs b/src/main.rs index 6a93bc1..c1e8c22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,8 @@ Usage: dbdash --sqlite PATH dbdash --help -With no argument or DATABASE_URL, the in-app connection screen opens. +Run without a connection argument and without DATABASE_URL to connect in-app. +Otherwise DATABASE_URL supplies the PostgreSQL connection when no argument is given. Prefer the environment variable to keep passwords out of process arguments: export DATABASE_URL='postgresql://user@localhost:5432/database' cargo run @@ -25,7 +26,7 @@ SQLite paths can be relative or absolute; quote paths containing spaces. PostgreSQL URLs and libpq-style key=value strings are accepted. Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=disable only for trusted local connections. -The terminal must be at least 70 columns wide and 12 lines high. +The inspector needs 70×12 terminal cells; the PostgreSQL form needs 70×16. Navigation: Tab / Esc Switch focus / return to relations @@ -37,6 +38,10 @@ Navigation: c Open the connection screen (Esc keeps the current database) r Refresh relations and the active relation q / Ctrl-C Quit + Connection screen: Up/Down chooses a provider; Enter starts editing. + Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused. + Enter connects; Esc cancels; Ctrl-U clears a field. Pasting is supported. + Form details are session-only. Passwords clear after connecting or cancelling. PostgreSQL reads are enforced read-only and have a 5-second query timeout. SQLite files are opened read-only, with a 5-second lock wait but no query timeout.