256 lines
13 KiB
Rust
256 lines
13 KiB
Rust
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 at top-left, live access mode at top-right, 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)
|
||
}
|