1use 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#[derive(Clone, PartialEq, Eq)]
20pub struct ShellCommand {
21 pub program: &'static str,
23 pub args: Vec<String>,
25 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 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 pub fn env_names(&self) -> impl Iterator<Item = &'static str> + '_ {
61 self.envs.iter().map(|(name, _)| *name)
62 }
63
64 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::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 let _ = parsed.set_password(None);
114 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
170fn 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}