Skip to main content

siderite_cli/
dbshell.rs

1//! `dbshell`: start the backend's native client (`sqlite3`, `psql`, `mysql`).
2//!
3//! [`ShellCommand::from_url`] builds the command without running it. Passwords
4//! never appear on the command line, where `ps` would show them, and are
5//! never printed: `psql` gets `PGPASSWORD` and `mysql` gets `MYSQL_PWD` in its
6//! environment. Only the connection parts of the URL are passed on; other
7//! query parameters are ignored, except that a PostgreSQL URL is handed to
8//! `psql` whole (minus its password, including a `?password=` parameter) so
9//! options such as `sslmode` apply.
10
11use crate::connect::{backend_kind, sqlite_path};
12use crate::error::CliError;
13use siderite_orm::BackendKind;
14use std::fmt;
15use std::process::Command;
16use url::Url;
17
18/// A native client invocation.
19#[derive(Clone, PartialEq, Eq)]
20pub struct ShellCommand {
21    /// Client executable.
22    pub program: &'static str,
23    /// Arguments. Never contains a password.
24    pub args: Vec<String>,
25    /// Environment variables to set (name, value); values may be secret.
26    envs: Vec<(&'static str, String)>,
27}
28
29impl fmt::Debug for ShellCommand {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.debug_struct("ShellCommand")
32            .field("program", &self.program)
33            .field("args", &self.args)
34            .field(
35                "env",
36                &self.envs.iter().map(|(name, _)| *name).collect::<Vec<_>>(),
37            )
38            .finish()
39    }
40}
41
42impl ShellCommand {
43    /// Build the client command for `url`.
44    ///
45    /// # Errors
46    /// A usage error for a URL that names no client (unknown scheme,
47    /// in-memory SQLite, malformed URL). Messages never contain the URL.
48    pub fn from_url(url: &str) -> Result<Self, CliError> {
49        match backend_kind(url) {
50            Some(BackendKind::Sqlite) => sqlite(url),
51            Some(BackendKind::Postgres) => postgres(url),
52            Some(BackendKind::MySql) => mysql(url),
53            _ => Err(CliError::usage(
54                "dbshell supports sqlite:, postgres:// and mysql:// databases",
55            )),
56        }
57    }
58
59    /// Names of the environment variables the client is started with.
60    pub fn env_names(&self) -> impl Iterator<Item = &'static str> + '_ {
61        self.envs.iter().map(|(name, _)| *name)
62    }
63
64    /// Run the client attached to the terminal and return its exit code.
65    ///
66    /// # Errors
67    /// [`CliError::Shell`] when the client is not installed or cannot start.
68    pub fn run(&self) -> Result<u8, CliError> {
69        let status = Command::new(self.program)
70            .args(&self.args)
71            .envs(self.envs.iter().map(|(name, value)| (name, value)))
72            .status()
73            .map_err(|err| CliError::Shell {
74                program: self.program,
75                reason: if err.kind() == std::io::ErrorKind::NotFound {
76                    "not found on PATH".to_owned()
77                } else {
78                    err.kind().to_string()
79                },
80            })?;
81        Ok(status
82            .code()
83            .and_then(|c| u8::try_from(c).ok())
84            .unwrap_or(1))
85    }
86}
87
88fn sqlite(url: &str) -> Result<ShellCommand, CliError> {
89    let Some(path) = sqlite_path(url) else {
90        return Err(CliError::usage(
91            "an in-memory SQLite database has no file to open in sqlite3",
92        ));
93    };
94    Ok(ShellCommand {
95        program: "sqlite3",
96        args: vec![path.to_owned()],
97        envs: Vec::new(),
98    })
99}
100
101fn parse(url: &str) -> Result<Url, CliError> {
102    // `url::ParseError` describes the problem without echoing the input.
103    Url::parse(url).map_err(|err| CliError::usage(format!("invalid database URL: {err}")))
104}
105
106fn postgres(url: &str) -> Result<ShellCommand, CliError> {
107    let mut parsed = parse(url)?;
108    let mut envs = Vec::new();
109    if let Some(password) = parsed.password() {
110        envs.push(("PGPASSWORD", percent_decode(password)));
111    }
112    // A malformed URL cannot carry a password, so this cannot fail on one.
113    let _ = parsed.set_password(None);
114    // libpq also accepts `?password=`; move it to the environment too.
115    let mut query = Vec::new();
116    for (name, value) in parsed.query_pairs() {
117        match name.as_ref() {
118            "password" => envs.push(("PGPASSWORD", value.into_owned())),
119            "sslpassword" => {
120                return Err(CliError::usage(
121                    "dbshell cannot pass `sslpassword` safely; remove it from the URL",
122                ));
123            }
124            _ => query.push((name.into_owned(), value.into_owned())),
125        }
126    }
127    if query.is_empty() {
128        parsed.set_query(None);
129    } else {
130        parsed.query_pairs_mut().clear().extend_pairs(&query);
131    }
132    Ok(ShellCommand {
133        program: "psql",
134        args: vec![parsed.to_string()],
135        envs,
136    })
137}
138
139fn mysql(url: &str) -> Result<ShellCommand, CliError> {
140    let parsed = parse(url)?;
141    let mut args = Vec::new();
142    if let Some(host) = parsed.host_str() {
143        args.push("--host".to_owned());
144        args.push(host.trim_matches(['[', ']']).to_owned());
145    }
146    if let Some(port) = parsed.port() {
147        args.push("--port".to_owned());
148        args.push(port.to_string());
149    }
150    if !parsed.username().is_empty() {
151        args.push("--user".to_owned());
152        args.push(percent_decode(parsed.username()));
153    }
154    let database = percent_decode(parsed.path().trim_start_matches('/'));
155    if !database.is_empty() {
156        args.push("--database".to_owned());
157        args.push(database);
158    }
159    let envs = parsed
160        .password()
161        .map(|password| vec![("MYSQL_PWD", percent_decode(password))])
162        .unwrap_or_default();
163    Ok(ShellCommand {
164        program: "mysql",
165        args,
166        envs,
167    })
168}
169
170/// Decode `%XX` escapes; malformed escapes are kept as written.
171fn percent_decode(input: &str) -> String {
172    let bytes = input.as_bytes();
173    let mut out = Vec::with_capacity(bytes.len());
174    let mut i = 0;
175    while i < bytes.len() {
176        if bytes[i] == b'%'
177            && let Some(hex) = bytes.get(i + 1..i + 3)
178            && let Some(byte) = std::str::from_utf8(hex)
179                .ok()
180                .and_then(|h| u8::from_str_radix(h, 16).ok())
181        {
182            out.push(byte);
183            i += 3;
184            continue;
185        }
186        out.push(bytes[i]);
187        i += 1;
188    }
189    String::from_utf8_lossy(&out).into_owned()
190}
191
192#[cfg(test)]
193mod tests {
194    #![allow(clippy::unwrap_used)]
195
196    use super::*;
197
198    fn env_value<'a>(cmd: &'a ShellCommand, name: &str) -> Option<&'a str> {
199        cmd.envs
200            .iter()
201            .find(|(n, _)| *n == name)
202            .map(|(_, v)| v.as_str())
203    }
204
205    #[test]
206    fn sqlite_strips_scheme_and_query() {
207        for (url, path) in [
208            ("sqlite://todo.db?mode=rwc", "todo.db"),
209            ("sqlite:todo.db", "todo.db"),
210            ("sqlite:///var/data/app.db", "/var/data/app.db"),
211        ] {
212            let cmd = ShellCommand::from_url(url).unwrap();
213            assert_eq!(cmd.program, "sqlite3");
214            assert_eq!(cmd.args, [path], "{url}");
215            assert_eq!(cmd.env_names().count(), 0);
216        }
217    }
218
219    #[test]
220    fn sqlite_memory_has_nothing_to_open() {
221        for url in ["sqlite::memory:", "sqlite://", "sqlite://?mode=memory"] {
222            assert!(ShellCommand::from_url(url).is_err(), "{url}");
223        }
224    }
225
226    #[test]
227    fn postgres_password_moves_to_the_environment() {
228        let cmd =
229            ShellCommand::from_url("postgres://ann:p%40ss@db.example:5433/app?sslmode=require")
230                .unwrap();
231        assert_eq!(cmd.program, "psql");
232        assert_eq!(
233            cmd.args,
234            ["postgres://ann@db.example:5433/app?sslmode=require"]
235        );
236        assert_eq!(env_value(&cmd, "PGPASSWORD"), Some("p@ss"));
237        assert!(
238            cmd.args
239                .iter()
240                .all(|a| !a.contains("p%40ss") && !a.contains("p@ss"))
241        );
242    }
243
244    #[test]
245    fn postgres_query_password_moves_to_the_environment() {
246        let cmd = ShellCommand::from_url("postgres://ann@db/app?password=s3cret&sslmode=require")
247            .unwrap();
248        assert_eq!(cmd.args, ["postgres://ann@db/app?sslmode=require"]);
249        assert_eq!(env_value(&cmd, "PGPASSWORD"), Some("s3cret"));
250        let only = ShellCommand::from_url("postgres://ann@db/app?password=s3cret").unwrap();
251        assert_eq!(only.args, ["postgres://ann@db/app"]);
252        assert!(ShellCommand::from_url("postgres://ann@db/app?sslpassword=x").is_err());
253    }
254
255    #[test]
256    fn postgres_without_password_sets_no_environment() {
257        let cmd = ShellCommand::from_url("postgresql://ann@localhost/app").unwrap();
258        assert_eq!(cmd.args, ["postgresql://ann@localhost/app"]);
259        assert_eq!(cmd.env_names().count(), 0);
260    }
261
262    #[test]
263    fn mysql_uses_flags_and_environment() {
264        let cmd = ShellCommand::from_url("mysql://bo%20b:s3%2Fcret@127.0.0.1:3307/shop").unwrap();
265        assert_eq!(cmd.program, "mysql");
266        assert_eq!(
267            cmd.args,
268            [
269                "--host",
270                "127.0.0.1",
271                "--port",
272                "3307",
273                "--user",
274                "bo b",
275                "--database",
276                "shop"
277            ]
278        );
279        assert_eq!(env_value(&cmd, "MYSQL_PWD"), Some("s3/cret"));
280        assert!(cmd.args.iter().all(|a| !a.contains("cret")));
281    }
282
283    #[test]
284    fn mysql_minimal_url() {
285        let cmd = ShellCommand::from_url("mysql://localhost").unwrap();
286        assert_eq!(cmd.args, ["--host", "localhost"]);
287        assert_eq!(cmd.env_names().count(), 0);
288        let v6 = ShellCommand::from_url("mysql://[::1]:3306/db").unwrap();
289        assert_eq!(&v6.args[..2], ["--host", "::1"]);
290    }
291
292    #[test]
293    fn debug_shows_env_names_but_never_values() {
294        let cmd = ShellCommand::from_url("postgres://ann:hunter2@h/db").unwrap();
295        let shown = format!("{cmd:?}");
296        assert!(shown.contains("PGPASSWORD"));
297        assert!(!shown.contains("hunter2"), "{shown}");
298    }
299
300    #[test]
301    fn unsupported_and_malformed_urls_are_usage_errors_without_the_url() {
302        let err = ShellCommand::from_url("ftp://u:hunter2@h/x").unwrap_err();
303        assert_eq!(err.exit_code(), 2);
304        assert!(!err.to_string().contains("hunter2"));
305        let err = ShellCommand::from_url("postgres://u:hunter2@[bad/x").unwrap_err();
306        assert!(!err.to_string().contains("hunter2"), "{err}");
307        let err = ShellCommand::from_url("mysql://u:hunter2@h:notaport/x").unwrap_err();
308        assert!(!err.to_string().contains("hunter2"), "{err}");
309    }
310
311    #[test]
312    fn percent_decoding_keeps_malformed_escapes() {
313        assert_eq!(percent_decode("a%2Fb%20c"), "a/b c");
314        assert_eq!(percent_decode("100%"), "100%");
315        assert_eq!(percent_decode("%zz%4"), "%zz%4");
316        assert_eq!(percent_decode("%C3%A9"), "é");
317    }
318}