Files
dbdash/src/main.rs
T
2026-10-20 14:11:54 +02:00

256 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod app;
mod connection;
mod db;
mod sql;
use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode};
use crossterm::event::{
DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in SQL writes
Usage:
dbdash [--read-write] [POSTGRES_CONNECTION_STRING]
dbdash [--read-write] --ca-bundle PATH POSTGRES_CONNECTION_STRING
dbdash [--read-write] --sqlite PATH
dbdash --help
Run without a connection argument to open the saved database list:
cargo run
Use the in-app password field to keep credentials out of process arguments.
Open an existing SQLite database file (read-only; missing files are not created):
cargo run -- --sqlite ./database.sqlite
SQLite paths can be relative or absolute; quote paths containing spaces.
SQLite paths expand leading ~, $VAR, and ${VAR}; undefined variables are errors.
Expansion never runs shell commands. Quote CLI paths to let dbdash expand them.
Connections default to READ ONLY, including existing saved configurations.
Choose Access in the connection window to save READ ONLY or READ WRITE per database.
For an unsaved CLI session, opt in explicitly:
cargo run -- --read-write --sqlite ./database.sqlite
--read-write requires a direct connection; it does not change saved profiles.
The header and connected sidebar badge reflect the live session, not saved edits.
Changing saved Access requires reconnecting (R on the saved database or a child).
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.
For private CAs (including Amazon RDS), put PEM bundles (.pem or .crt) in
$XDG_CONFIG_HOME/dbdash (default: ~/.config/dbdash), then launch normally:
cargo run
In the connection window, Tab to CA bundle and use Left/Right to choose a file
or System trust. The selection is saved per connection and shown in database info.
Selecting a bundle forces Require TLS; choose System trust before changing TLS mode.
The selector rescans on each change; missing saved files are not silently replaced.
For CLI PostgreSQL connections, use --ca-bundle PATH before the connection string
and specify sslmode=require. Unset PGSSLROOTCERT; legacy launchers are rejected
instead of silently losing their custom CA verification.
Only the selected bundle is trusted for that connection; system trust is unchanged.
Explicit CA bundles use OpenSSL verification for compatibility with RDS certificates
that omit Extended Key Usage; incompatible EKU, invalid chains, dates, and hostnames
are still rejected. System trust uses the native platform TLS verifier.
Certificates and hostnames are verified before authentication; no plaintext fallback
with a CA bundle. Missing, empty, or invalid selected files block connection.
Use a bundle from the trusted issuer, not an unverified server's certificate.
For RDS us-east-1, download AWS's official regional bundle:
https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem
Enter passwords in the in-app field; putting them in CLI arguments exposes them.
The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×28/74×21.
Navigation:
? Show keybindings in Normal mode (Esc/Enter/? closes)
Tab/Shift-Tab Cycle explorer, query editor, and rows/schema
Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
Home/End, g/G First / last item
s Toggle rows and column schema
Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too)
n/p, PgDn/Up Next / previous 100-row relation page
c Add a configured database in a floating window
i Show configured database info (Esc/Enter closes; passwords omitted)
Works on a saved database entry or any of its table/view children.
r Refresh relations and the active relation
Databases always show [RO]/[RW], even when their labels truncate; connected ones
have a trailing * and show their tables/views beneath them. R reconnects a saved database.
Unnamed SQLite entries show only the filename; the info window shows the full path.
Enter on the connected database collapses/expands it without reconnecting.
Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump.
Query Insert mode and connection/password fields keep ? as literal text.
Full-width bars show dbdash/live READ ONLY or READ WRITE above and mode/database below.
Saved PostgreSQL connections show database · user@host:port beside the mode,
regardless of custom sidebar names; passwords are never included.
The database provider sits immediately left of the bottom-right position badge.
Only badges have colored backgrounds; the remaining bar area uses terminal defaults.
Focused inputs show one-based line:column (Unicode characters, not screen cells).
The position badge shows N/A outside focused inputs, including in info/help popups.
The query editor sits above rows/results; the explorer spans both panes.
The SQL editor has a one-based line-number gutter that follows vertical scrolling;
horizontal scrolling moves only SQL, not the numbers or logical cursor position.
Explorer and rows/schema bottom borders align; table names appear in the
top-right border, with page/column status below. Read errors appear above the query editor.
Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move.
NORMAL also supports w/b/e (word motions), 0/^/$ (line motions), and
gg/G (first/last line). Words distinguish identifiers from punctuation.
In INSERT, type SQL; Enter inserts a newline. Arrow keys, Home/End,
Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
Esc leaves INSERT first; Esc in NORMAL returns to the explorer.
Connection/password fields remain direct-entry; query paste requires INSERT.
SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect,
including quoted identifiers, literals, and multiline comments/strings as you type.
F5 / Ctrl-Enter executes one SQL statement on the current connection, showing up
to 100 result rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
READ ONLY accepts SELECT/CTE reads; READ WRITE also supports SQL writes.
Each successful statement commits atomically; failures roll back. Explicit transaction
control and multi-statement scripts are rejected. Writes report affected-row counts;
RETURNING displays result rows (up to 100) without limiting the rows written.
Cells are never edited inline: use INSERT, UPDATE, DELETE, etc. in the SQL editor.
Ctrl-Enter needs terminal support for enhanced keyboard reporting; use F5 otherwise.
Failed queries keep the previous results. Opening a relation restores browsing
without clearing the query text. Query text is not saved across connections.
q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT)
New database window: Up/Down chooses a provider; Enter starts editing.
Tab/Shift-Tab changes fields; Left/Right changes TLS, CA bundle, or Access when focused.
Enter saves without connecting, including when the database is offline.
Optional Name controls the sidebar label; leave it blank for the default label.
e on a saved database or its child edits the configuration without connecting.
Enter saves edits; Esc discards them. Connected sessions keep their actual identity
and access mode until R reconnects; Enter on an active database still only expands it.
d / Delete on a saved database asks for y to remove its configuration (n/Esc cancels).
Database files are never deleted. Removing the active profile keeps its session open.
Select a saved database and press Enter to connect. PostgreSQL opens an editable
connection window, initially focused on the password. Tab/Shift-Tab changes fields;
Left/Right changes TLS, CA bundle, or Access when focused. Enter connects and updates that saved entry
only on success; failures keep the previous settings and connection. Esc discards edits.
Passwords are masked, never saved, and clear on success or cancellation.
Owned input buffers are zeroized on clear/drop; profile saving does not copy passwords.
This does not erase driver copies, prior input allocations, or protect a compromised host.
Ctrl-U clears a field; pasting is supported.
Enter the database user explicitly; the operating-system username is not used.
Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling
back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute.
CLI connections are not saved. Failed switches keep the current database open.
PostgreSQL statements have a 5-second timeout. READ ONLY sessions enforce read-only
transactions; READ WRITE statements commit on success. SQLite READ ONLY opens files
read-only; READ WRITE permits writes to existing files. Missing files are never created.
SQLite has a 5-second lock wait but no query timeout. The interface waits for statements
to complete. NULL cells are dimmed; SQLite BLOBs are shown as X'hex'.
Only SQLite's main database is inspected.
Pages use primary-key order, or physical row order for keyless tables when available.
Keyless views have no guaranteed order; concurrent changes can shift pages.
"#;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("dbdash: {}", error_text(error.as_ref()));
ExitCode::FAILURE
}
}
}
fn run() -> Result<()> {
let mut args = std::env::args_os().skip(1);
let mut argument = args.next();
if argument
.as_ref()
.is_some_and(|arg| arg == "--help" || arg == "-h")
{
print!("{HELP}");
return Ok(());
}
if std::env::var_os("PGSSLROOTCERT").is_some() {
return Err("Unset PGSSLROOTCERT and select a CA bundle per connection, or use --ca-bundle PATH on the CLI.".into());
}
let mut access = db::AccessMode::ReadOnly;
let mut root_cert = None;
loop {
match argument.as_deref().and_then(|arg| arg.to_str()) {
Some("--read-write") => access = db::AccessMode::ReadWrite,
Some("--ca-bundle") => {
if root_cert.is_some() {
return Err("--ca-bundle may only be specified once".into());
}
root_cert = Some(args.next().ok_or("--ca-bundle requires a PEM file path")?);
}
_ => break,
}
argument = args.next();
}
if root_cert.is_some() && argument.is_none() {
return Err("--ca-bundle requires a PostgreSQL connection string".into());
}
if access == db::AccessMode::ReadWrite && argument.is_none() {
return Err("--read-write requires a direct database connection; change saved Access in the connection window".into());
}
let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
if sqlite && root_cert.is_some() {
return Err("--ca-bundle is only supported for PostgreSQL".into());
}
let connection = if sqlite {
Some(
args.next()
.ok_or("--sqlite requires a database file path; see dbdash --help")?,
)
} else {
argument
};
if args.next().is_some() {
return Err("unexpected extra arguments; see dbdash --help".into());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err("an interactive terminal is required; see dbdash --help".into());
}
let database = connection
.map(|connection| {
if sqlite {
db::Database::open_sqlite(Path::new(&connection), access)
} else {
db::Database::connect(
connection
.to_str()
.ok_or("connection string must be UTF-8")?,
root_cert.as_deref().map(Path::new),
access,
)
}
})
.transpose()?;
let result = (|| {
let mut terminal = ratatui::try_init()?;
crossterm::execute!(
std::io::stdout(),
EnableBracketedPaste,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
connection::run(&mut terminal, database)
})();
let restore_result = crossterm::execute!(
std::io::stdout(),
PopKeyboardEnhancementFlags,
DisableBracketedPaste
);
ratatui::restore();
result?;
restore_result?;
Ok(())
}
fn error_text(error: &(dyn Error + 'static)) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(error) = source {
text.push_str(": ");
text.push_str(&error.to_string());
source = error.source();
}
db::display_text(&text)
}