zippa-db 0.1.1

A fast, lightweight, cross-platform database client for PostgreSQL, MySQL, and SQLite.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
//! What the user configures about a connection, before it is opened.
//!
//! Nothing here touches the network or sqlx: it is the saved shape of a
//! connection ([`ConnectionConfig`]), the engine it targets ([`Engine`]), and
//! how careful it is about writes ([`SafetyMode`]). [`store`](super::store)
//! persists it; [`Connection`](super::Connection) opens it.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Database engine a connection targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Engine {
    Postgres,
    MySql,
    Sqlite,
}

impl Engine {
    pub const ALL: [Engine; 3] = [Engine::Postgres, Engine::MySql, Engine::Sqlite];

    pub fn label(self) -> &'static str {
        match self {
            Engine::Postgres => "PostgreSQL",
            Engine::MySql => "MySQL",
            Engine::Sqlite => "SQLite",
        }
    }

    pub fn default_port(self) -> u16 {
        match self {
            Engine::Postgres => 5432,
            Engine::MySql => 3306,
            Engine::Sqlite => 0,
        }
    }

    /// SQLite connects to a file, so it has no host, port, or credentials.
    pub fn is_file_based(self) -> bool {
        matches!(self, Engine::Sqlite)
    }
}

/// How much ceremony a connection asks for before anything is written.
///
/// One scale, from the most careful to the least: refuse writes, ask about
/// each one, hold inline edits until they are applied, apply them as the user
/// moves on.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SafetyMode {
    /// Nothing may be written: the session is opened read-only and statements
    /// that do not plainly read are refused before they are sent.
    ReadOnly,
    /// Every write is shown to the user before it runs.
    ConfirmWrites,
    /// Inline edits wait in the grid until they are applied.
    #[default]
    Staged,
    /// Inline edits are written as soon as the selection leaves the row.
    AutoApply,
}

impl SafetyMode {
    pub const ALL: [SafetyMode; 4] = [
        SafetyMode::ReadOnly,
        SafetyMode::ConfirmWrites,
        SafetyMode::Staged,
        SafetyMode::AutoApply,
    ];

    pub fn label(self) -> &'static str {
        match self {
            SafetyMode::ReadOnly => "Read-only",
            SafetyMode::ConfirmWrites => "Confirm writes",
            SafetyMode::Staged => "Staged edits",
            SafetyMode::AutoApply => "Auto-apply",
        }
    }

    pub fn description(self) -> &'static str {
        match self {
            SafetyMode::ReadOnly => "Refuses anything that writes",
            SafetyMode::ConfirmWrites => "Shows every write before it runs",
            SafetyMode::Staged => "Edits wait until you apply them",
            SafetyMode::AutoApply => "Edits are written when you leave the row",
        }
    }

    pub fn is_read_only(self) -> bool {
        matches!(self, SafetyMode::ReadOnly)
    }

    pub fn confirms_writes(self) -> bool {
        matches!(self, SafetyMode::ConfirmWrites)
    }

    pub fn auto_applies(self) -> bool {
        matches!(self, SafetyMode::AutoApply)
    }
}

/// Whether, and how strictly, a server connection is encrypted.
///
/// One scale for both servers, named the way libpq names it; each engine maps
/// it onto its own driver's modes (MySQL calls `VerifyFull` "verify identity").
/// SQLite opens a file and ignores it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SslMode {
    /// Never encrypt.
    Disable,
    /// Encrypt when the server offers it, otherwise connect in the clear. What
    /// the drivers do when nothing is said, so a connection saved before the
    /// setting existed behaves as it always did.
    #[default]
    Prefer,
    /// Refuse to connect unencrypted, but trust whatever certificate the
    /// server presents.
    Require,
    /// Encrypt and check the server's certificate against a trusted
    /// authority.
    VerifyCa,
    /// As `VerifyCa`, and check that the certificate names the host.
    VerifyFull,
}

impl SslMode {
    pub const ALL: [SslMode; 5] = [
        SslMode::Disable,
        SslMode::Prefer,
        SslMode::Require,
        SslMode::VerifyCa,
        SslMode::VerifyFull,
    ];

    pub fn label(self) -> &'static str {
        match self {
            SslMode::Disable => "Disable",
            SslMode::Prefer => "Prefer",
            SslMode::Require => "Require",
            SslMode::VerifyCa => "Verify CA",
            SslMode::VerifyFull => "Verify full",
        }
    }

    pub fn description(self) -> &'static str {
        match self {
            SslMode::Disable => "Never encrypt the connection",
            SslMode::Prefer => "Encrypt when the server offers it",
            SslMode::Require => "Always encrypt, without checking the certificate",
            SslMode::VerifyCa => "Always encrypt, and check the certificate's authority",
            SslMode::VerifyFull => "Always encrypt, and check the authority and the host name",
        }
    }

    /// Whether the certificate files are used: only once encryption is
    /// insisted on. Under `Prefer` a connection may well end up in the clear,
    /// and the editor hides the files, so they are not sent either; a mode
    /// stepped down to keeps them saved for when it is stepped back up.
    pub fn uses_files(self) -> bool {
        matches!(
            self,
            SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
        )
    }
}

/// How a server connection is encrypted: the mode, plus the certificate files
/// it may need. Each path is empty when unused; none of them is a secret (a
/// client key's passphrase is not supported), so they ride along in
/// `connections.json` like the host does.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SslConfig {
    pub mode: SslMode,
    /// A PEM file of the authorities to trust, in place of the system's own.
    pub ca_cert: String,
    /// A PEM client certificate, for a server that asks the client to prove
    /// who it is.
    pub client_cert: String,
    /// The PEM private key that goes with `client_cert`.
    pub client_key: String,
}

impl SslConfig {
    /// Why these settings cannot be used, in words for the user.
    ///
    /// A client certificate is only half of an identity without its key, and
    /// the other way round; the drivers would otherwise ignore the half given
    /// without a word.
    pub fn invalid(&self) -> Option<&'static str> {
        if !self.mode.uses_files() {
            return None;
        }
        match (
            self.client_cert.trim().is_empty(),
            self.client_key.trim().is_empty(),
        ) {
            (false, true) => Some("A client certificate needs its private key too."),
            (true, false) => Some("A client key needs its certificate too."),
            _ => None,
        }
    }

    /// A path the user filled in, or `None` for an empty box.
    pub(crate) fn path(value: &str) -> Option<&str> {
        Some(value.trim()).filter(|value| !value.is_empty())
    }
}

/// How an SSH tunnel proves who it is to the jump host.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshAuth {
    /// Whatever keys the running SSH agent holds (`SSH_AUTH_SOCK`, or the
    /// OpenSSH agent's pipe on Windows): nothing to store.
    #[default]
    Agent,
    /// A private key file, with its passphrase in the keychain if it has one.
    PrivateKey,
    /// A password, kept in the keychain.
    Password,
}

impl SshAuth {
    pub const ALL: [SshAuth; 3] = [SshAuth::Agent, SshAuth::PrivateKey, SshAuth::Password];

    pub fn label(self) -> &'static str {
        match self {
            SshAuth::Agent => "SSH agent",
            SshAuth::PrivateKey => "Key file",
            SshAuth::Password => "Password",
        }
    }

    /// What the secret stored for this method is called, or `None` when it
    /// has none.
    pub fn secret_label(self) -> Option<&'static str> {
        match self {
            SshAuth::Agent => None,
            SshAuth::PrivateKey => Some("Key passphrase"),
            SshAuth::Password => Some("SSH password"),
        }
    }
}

/// An SSH tunnel a server connection is opened through: the database is
/// reached from the jump host, so `host`/`port` on the connection are as the
/// jump host sees them. The password or key passphrase is not here; it lives
/// in the keychain beside the database password.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SshConfig {
    pub enabled: bool,
    pub host: String,
    pub port: u16,
    pub username: String,
    pub auth: SshAuth,
    /// The private key file, for [`SshAuth::PrivateKey`]. A leading `~/` is
    /// the home directory.
    pub key_path: String,
}

impl Default for SshConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            host: String::new(),
            port: 22,
            username: String::new(),
            auth: SshAuth::default(),
            key_path: String::new(),
        }
    }
}

impl SshConfig {
    /// Why these settings cannot be used, in words for the user.
    pub fn invalid(&self) -> Option<&'static str> {
        if !self.enabled {
            return None;
        }
        if self.host.trim().is_empty() {
            return Some("Enter the SSH host to tunnel through.");
        }
        if self.port == 0 {
            return Some("The SSH port must be a number from 1 to 65535.");
        }
        if self.username.trim().is_empty() {
            return Some("Enter the SSH user.");
        }
        if self.auth == SshAuth::PrivateKey && self.key_path.trim().is_empty() {
            return Some("Enter the path to the SSH private key.");
        }
        None
    }
}

/// A fixed palette a connection can be coloured with.
///
/// A palette rather than arbitrary hex: a colour is resolved against the theme
/// for contrast in light and dark, and stays valid when a theme changes. The
/// colour's [`label`](TagColor::label) is what a screen reader hears in its
/// place.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TagColor {
    Red,
    Orange,
    Yellow,
    Green,
    Teal,
    Blue,
    Purple,
    Pink,
    Gray,
}

impl TagColor {
    pub const ALL: [TagColor; 9] = [
        TagColor::Red,
        TagColor::Orange,
        TagColor::Yellow,
        TagColor::Green,
        TagColor::Teal,
        TagColor::Blue,
        TagColor::Purple,
        TagColor::Pink,
        TagColor::Gray,
    ];

    pub fn label(self) -> &'static str {
        match self {
            TagColor::Red => "Red",
            TagColor::Orange => "Orange",
            TagColor::Yellow => "Yellow",
            TagColor::Green => "Green",
            TagColor::Teal => "Teal",
            TagColor::Blue => "Blue",
            TagColor::Purple => "Purple",
            TagColor::Pink => "Pink",
            TagColor::Gray => "Gray",
        }
    }

    /// The kebab-case key the palette colour serialises as.
    pub fn key(self) -> &'static str {
        match self {
            TagColor::Red => "red",
            TagColor::Orange => "orange",
            TagColor::Yellow => "yellow",
            TagColor::Green => "green",
            TagColor::Teal => "teal",
            TagColor::Blue => "blue",
            TagColor::Purple => "purple",
            TagColor::Pink => "pink",
            TagColor::Gray => "gray",
        }
    }
}

/// A saved connection as configured by the user.
///
/// The password is not part of this struct: it lives in the OS keychain, keyed
/// by [`ConnectionConfig::id`]. See [`store`](super::store).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
    pub id: Uuid,
    pub name: String,
    pub engine: Engine,
    pub host: String,
    pub port: u16,
    pub username: String,
    /// Database name, or the file path for SQLite.
    pub database: String,
    /// How careful this connection is about writes. Absent in files written
    /// before the setting existed, which read back as the safer mode.
    #[serde(default)]
    pub safety: SafetyMode,
    /// The palette colour the connection is marked with, if any: a dot in the
    /// title bar and a tint on its tab. Absent in files written before
    /// colouring existed. (Files from when a connection also had a free-text
    /// `tag` still read: serde skips the field.)
    #[serde(default)]
    pub color: Option<TagColor>,
    /// When this connection was last opened, for most-recent-first ordering.
    #[serde(default)]
    pub last_connected: Option<DateTime<Utc>>,
    /// How a server connection is encrypted. Absent in files written before
    /// the setting existed, which read back as `Prefer`: what the drivers did
    /// all along.
    #[serde(default)]
    pub ssl: SslConfig,
    /// The SSH tunnel to connect through, if any. Absent in files written
    /// before tunnels existed, which read back as off.
    #[serde(default)]
    pub ssh: SshConfig,
    /// How long, in seconds, the server lets one statement run before it
    /// cancels it; `None` (and files written before the setting existed) for
    /// no limit. Enforced by the server — `statement_timeout` on Postgres,
    /// `max_execution_time` (MariaDB: `max_statement_time`) on MySQL, which
    /// applies to reads only — and ignored by SQLite, which has no server to
    /// enforce it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub statement_timeout: Option<u32>,
}

impl ConnectionConfig {
    pub fn new(engine: Engine) -> Self {
        Self {
            id: Uuid::new_v4(),
            name: String::new(),
            engine,
            host: "localhost".into(),
            port: engine.default_port(),
            username: String::new(),
            database: String::new(),
            safety: SafetyMode::default(),
            color: None,
            last_connected: None,
            ssl: SslConfig::default(),
            ssh: SshConfig::default(),
            statement_timeout: None,
        }
    }

    /// Name to show when the user has not given the connection one.
    pub fn display_name(&self) -> String {
        if !self.name.trim().is_empty() {
            return self.name.clone();
        }

        // Showing the whole path is the target line's job; a name wants to be
        // short enough to sit in a tab.
        if self.engine.is_file_based() {
            return file_name(&self.database);
        }
        self.display_target()
    }

    /// `localhost:5432/app`, or the file's path for SQLite.
    pub fn display_target(&self) -> String {
        if self.engine.is_file_based() {
            return self.database.clone();
        }
        format!("{}:{}/{}", self.host, self.port, self.database)
    }

    /// Whether this connection is the footgun the colour exists to flag: one
    /// that reads as production and writes without asking. Shared by the
    /// connection editor's inline warning and the launcher's confirm-before-
    /// connect, so both draw the same line.
    pub fn is_risky_auto_apply(&self) -> bool {
        is_risky_auto_apply(&self.name, self.color, self.safety)
    }
}

/// The rule [`ConnectionConfig::is_risky_auto_apply`] applies, taken as plain
/// values so the connection editor can ask it about a name still being typed
/// — before there is a whole `ConnectionConfig` to ask.
///
/// A connection reads as production when it is coloured red or its name
/// mentions "prod" (case-insensitively).
pub fn is_risky_auto_apply(name: &str, color: Option<TagColor>, safety: SafetyMode) -> bool {
    safety.auto_applies() && (color == Some(TagColor::Red) || name.to_lowercase().contains("prod"))
}

impl Default for ConnectionConfig {
    fn default() -> Self {
        Self::new(Engine::Postgres)
    }
}

/// The file's own name, or the whole path when it has none.
pub(crate) fn file_name(path: &str) -> String {
    std::path::Path::new(path)
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_client_certificate_needs_its_key() {
        let half = |cert: &str, key: &str| SslConfig {
            mode: SslMode::Require,
            client_cert: cert.into(),
            client_key: key.into(),
            ..SslConfig::default()
        };
        assert!(half("client.crt", "").invalid().is_some());
        assert!(half("", "client.key").invalid().is_some());
        assert_eq!(half("client.crt", "client.key").invalid(), None);
        assert_eq!(half("", "").invalid(), None);
        // Below `Require` the files are never read, so a half-filled pair
        // left over from an earlier mode is no reason to refuse.
        for mode in [SslMode::Disable, SslMode::Prefer] {
            assert_eq!(
                SslConfig {
                    mode,
                    ..half("client.crt", "")
                }
                .invalid(),
                None
            );
        }
    }

    #[test]
    fn a_connection_saved_before_ssl_settings_reads_back_as_prefer() {
        let config: ConnectionConfig = serde_json::from_str(
            r#"{"id":"6f1c1f0e-8a5c-4d36-9a49-1b1f3c0f2a10","name":"","engine":"Postgres",
                "host":"localhost","port":5432,"username":"","database":""}"#,
        )
        .unwrap();
        assert_eq!(config.ssl, SslConfig::default());
        assert_eq!(config.ssl.mode, SslMode::Prefer);
    }

    #[test]
    fn an_ssh_tunnel_needs_a_host_a_user_and_a_key_file_for_key_auth() {
        let tunnel = SshConfig {
            enabled: true,
            host: "bastion".into(),
            username: "deploy".into(),
            ..SshConfig::default()
        };
        assert_eq!(tunnel.invalid(), None);
        assert!(
            SshConfig {
                host: " ".into(),
                ..tunnel.clone()
            }
            .invalid()
            .is_some()
        );
        assert!(
            SshConfig {
                username: String::new(),
                ..tunnel.clone()
            }
            .invalid()
            .is_some()
        );
        assert!(
            SshConfig {
                auth: SshAuth::PrivateKey,
                ..tunnel.clone()
            }
            .invalid()
            .is_some()
        );
        // A tunnel that is off is never checked.
        assert_eq!(SshConfig::default().invalid(), None);
    }

    #[test]
    fn a_connection_saved_before_tunnels_reads_back_without_one() {
        let config: ConnectionConfig = serde_json::from_str(
            r#"{"id":"6f1c1f0e-8a5c-4d36-9a49-1b1f3c0f2a10","name":"","engine":"MySql",
                "host":"localhost","port":3306,"username":"","database":""}"#,
        )
        .unwrap();
        assert!(!config.ssh.enabled);
        assert_eq!(config.ssh.port, 22);
    }

    #[test]
    fn ssl_settings_round_trip_through_json() {
        let config = ConnectionConfig {
            ssl: SslConfig {
                mode: SslMode::VerifyFull,
                ca_cert: "/etc/ssl/ca.pem".into(),
                ..SslConfig::default()
            },
            ..ConnectionConfig::default()
        };
        let json = serde_json::to_string(&config).unwrap();
        assert!(json.contains(r#""mode":"verify-full""#), "{json}");
        let read: ConnectionConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(read.ssl, config.ssl);
    }

    #[test]
    fn a_production_connection_with_auto_apply_is_risky() {
        assert!(is_risky_auto_apply(
            "App",
            Some(TagColor::Red),
            SafetyMode::AutoApply
        ));
        // Case-insensitive, and matches a name that merely contains the word.
        assert!(is_risky_auto_apply(
            "prod-east",
            None,
            SafetyMode::AutoApply
        ));
    }

    #[test]
    fn a_production_connection_is_not_risky_under_a_safer_mode() {
        for safety in [
            SafetyMode::ReadOnly,
            SafetyMode::ConfirmWrites,
            SafetyMode::Staged,
        ] {
            assert!(!is_risky_auto_apply(
                "Production",
                Some(TagColor::Red),
                safety
            ));
        }
    }

    #[test]
    fn auto_apply_is_not_risky_without_a_production_marker() {
        assert!(!is_risky_auto_apply("", None, SafetyMode::AutoApply));
        assert!(!is_risky_auto_apply(
            "Staging",
            Some(TagColor::Orange),
            SafetyMode::AutoApply
        ));
    }
}