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
//! Persistence for saved connections.
//!
//! Connection settings go to a JSON file in the user's config directory;
//! passwords go to the OS credential store (Keychain on macOS, Credential
//! Manager on Windows, Secret Service on Linux) keyed by connection id.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};

use anyhow::{Context, Result};
use uuid::Uuid;

use super::{ConnectionConfig, Credentials, SshAuth};

#[cfg_attr(test, allow(dead_code))]
const SERVICE: &str = "zippa-db";
const FILE_NAME: &str = "connections.json";

#[cfg(test)]
std::thread_local! {
    static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
        const { std::cell::RefCell::new(None) };
}

/// Point [`config_dir`] at a scratch directory, so a test never reads or writes
/// the developer's real connections.
#[cfg(test)]
pub(crate) fn set_config_dir_for_test(path: PathBuf) {
    TEST_CONFIG_DIR.with(|dir| *dir.borrow_mut() = Some(path));
}

/// Where Zippa keeps its files: connections, settings, and user themes.
pub(crate) fn config_dir() -> Result<PathBuf> {
    // A test never sees the developer's real directory: one that has not
    // pointed this at its own scratch directory gets a per-process one, so a
    // test that forgets cannot overwrite the real files.
    #[cfg(test)]
    return Ok({
        static FALLBACK: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
        TEST_CONFIG_DIR
            .with(|dir| dir.borrow().clone())
            .unwrap_or_else(|| {
                FALLBACK
                    .get_or_init(|| {
                        std::env::temp_dir()
                            .join(format!("zippa-db-test-config-{}", std::process::id()))
                    })
                    .clone()
            })
    });

    #[cfg(not(test))]
    {
        let dir = dirs::config_dir().context("no config directory for this platform")?;
        Ok(dir.join(SERVICE))
    }
}

fn config_file() -> Result<PathBuf> {
    Ok(config_dir()?.join(FILE_NAME))
}

/// Read the saved connections. A missing file means "none saved yet"; an
/// unreadable one is moved aside (see [`load_json`]).
pub fn load() -> Result<Vec<ConnectionConfig>> {
    load_json(&config_file()?)
}

/// Write `contents` to `path`, restricted to the owner where the platform
/// supports it. These files can hold a pasted secret — a password typed into
/// a query buffer (`workspace.json`), or a database's host and username on a
/// shared machine whose other users have no business reading them
/// (`connections.json`, `settings.json`) — so the file is created with
/// restricted permissions from the first byte rather than tightened
/// afterward, which would leave a window where a default-permissions file is
/// briefly readable by everyone. Unix only: Windows has no equivalent this
/// simple, and its ACL model is out of scope here.
pub(crate) fn write_restricted(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::io::Write as _;
        use std::os::unix::fs::OpenOptionsExt as _;

        let mut file = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)?;
        file.write_all(contents.as_bytes())
    }
    #[cfg(not(unix))]
    {
        fs::write(path, contents)
    }
}

/// Replace the saved connections with `connections`.
///
/// `ticket` is taken when the list was decided (see [`ticket`]), so a write
/// that lands after a newer one is dropped rather than putting the older
/// list back.
pub fn save(connections: &[ConnectionConfig], ticket: Ticket) -> Result<()> {
    let contents = serde_json::to_string_pretty(connections)?;
    write_atomic(&config_file()?, &contents, ticket)
}

/// When a write was decided, in the order the app decided them.
///
/// Writes go to the background executor, which can run two of them at once
/// and in either order; taking the ticket on the thread that built the
/// contents is what keeps "last decided" and "last on disk" the same.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Ticket(u64);

/// A ticket for a write about to be handed to the background.
pub(crate) fn ticket() -> Ticket {
    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
    Ticket(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}

/// Replace `path` with `contents`, whole or not at all.
///
/// Written beside the real file and renamed into place, so a kill part-way
/// through cannot leave a truncated file the next launch would refuse to
/// parse. Writes are taken one at a time (two writers sharing the scratch file
/// could interleave their bytes), and one whose `ticket` is older than the
/// last write to the same path is skipped: it holds what the app has since
/// changed its mind about.
pub(crate) fn write_atomic(path: &Path, contents: &str, ticket: Ticket) -> Result<()> {
    static WRITTEN: Mutex<BTreeMap<PathBuf, Ticket>> = Mutex::new(BTreeMap::new());
    let mut written = WRITTEN.lock().unwrap_or_else(PoisonError::into_inner);
    if written.get(path).is_some_and(|last| *last > ticket) {
        return Ok(());
    }

    let dir = path.parent().context("no config directory")?;
    fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
    let name = path
        .file_name()
        .context("no file name")?
        .to_string_lossy()
        .into_owned();
    let temporary = dir.join(format!("{name}.tmp"));
    write_restricted(&temporary, contents)
        .with_context(|| format!("could not write {}", temporary.display()))?;

    if let Err(error) = fs::rename(&temporary, path) {
        // Windows will not replace an existing file with a rename, so fall back
        // to writing in place rather than leaving the file unsaved.
        write_restricted(path, contents)
            .with_context(|| format!("could not write {}: {error:#}", path.display()))?;
        let _ = fs::remove_file(&temporary);
    }
    written.insert(path.to_path_buf(), ticket);
    Ok(())
}

/// Parse `path` as JSON, or `T::default()` when there is no file yet.
///
/// A file that is there but cannot be parsed — written by a newer build, edited
/// by hand, or cut short — is moved aside to `<name>.unreadable` before the
/// error is returned. The caller carries on from the default, and its next
/// save would otherwise replace the user's only copy with an empty one.
pub(crate) fn load_json<T>(path: &Path) -> Result<T>
where
    T: serde::de::DeserializeOwned + Default,
{
    if !path.exists() {
        return Ok(T::default());
    }
    let contents =
        fs::read_to_string(path).with_context(|| format!("could not read {}", path.display()))?;
    match serde_json::from_str(&contents) {
        Ok(value) => Ok(value),
        Err(error) => {
            let mut aside = path.as_os_str().to_owned();
            aside.push(".unreadable");
            let aside = PathBuf::from(aside);
            let kept = match fs::rename(path, &aside) {
                Ok(()) => format!("it was kept as {}", aside.display()),
                Err(rename) => format!("it could not be moved aside: {rename}"),
            };
            Err(anyhow::Error::new(error)
                .context(format!("could not parse {}; {kept}", path.display())))
        }
    }
}

#[cfg(not(test))]
fn entry(id: &Uuid) -> Result<keyring::Entry> {
    keyring::Entry::new(SERVICE, &id.to_string()).context("no OS credential store available")
}

/// The stored password for a connection, if the user saved one.
pub fn password(id: &Uuid) -> Result<Option<String>> {
    #[cfg(not(test))]
    {
        match entry(id)?.get_password() {
            Ok(password) => Ok(Some(password)),
            Err(keyring::Error::NoEntry) => Ok(None),
            Err(error) => Err(error.into()),
        }
    }
    #[cfg(test)]
    {
        let _ = id;
        Ok(None)
    }
}

pub fn set_password(id: &Uuid, password: &str) -> Result<()> {
    #[cfg(not(test))]
    {
        if password.is_empty() {
            return delete_password(id);
        }
        entry(id)?
            .set_password(password)
            .context("could not save the password to the OS credential store")
    }
    #[cfg(test)]
    {
        let _ = (id, password);
        Ok(())
    }
}

pub fn delete_password(id: &Uuid) -> Result<()> {
    #[cfg(not(test))]
    {
        match entry(id)?.delete_credential() {
            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
            Err(error) => Err(error.into()),
        }
    }
    #[cfg(test)]
    {
        let _ = id;
        Ok(())
    }
}

/// What the connection editor's secret boxes mean for the keychain: `None`
/// for a box the user never touched (keep what is stored), `Some("")` for one
/// they emptied (forget it), anything else a new secret.
#[derive(Clone, Default)]
pub struct SecretEdits {
    pub password: Option<String>,
    pub ssh: Option<String>,
}

impl SecretEdits {
    /// Write the boxes the user touched to the keychain, leaving the rest.
    pub fn save(&self, id: &Uuid) -> Result<()> {
        if let Some(password) = &self.password {
            set_password(id, password)?;
        }
        if let Some(secret) = &self.ssh {
            set_ssh_secret(id, secret)?;
        }
        Ok(())
    }
}

/// The secrets to open `config` with: what the user typed, or for a box left
/// untouched on a `saved` connection, what the keychain holds. Only the
/// secrets the connection can use are looked up, since asking the keychain for
/// one it never stored can prompt the user for nothing.
///
/// Blocking: the keychain can prompt, so call it off the UI thread.
pub fn credentials(
    config: &ConnectionConfig,
    edits: &SecretEdits,
    saved: bool,
) -> Result<Credentials> {
    let typed = |secret: &str| Some(secret.to_string()).filter(|secret| !secret.is_empty());
    let server = !config.engine.is_file_based();

    let password = match &edits.password {
        Some(password) => typed(password),
        None if saved && server => password(&config.id)?,
        None => None,
    };
    let ssh = if server && config.ssh.enabled && config.ssh.auth != SshAuth::Agent {
        match &edits.ssh {
            Some(secret) => typed(secret),
            None if saved => ssh_secret(&config.id)?,
            None => None,
        }
    } else {
        None
    };
    Ok(Credentials { password, ssh })
}

/// The keychain account an SSH tunnel's password or key passphrase is kept
/// under: beside the database password, under the same connection id.
#[cfg(not(test))]
fn ssh_entry(id: &Uuid) -> Result<keyring::Entry> {
    keyring::Entry::new(SERVICE, &format!("{id}/ssh")).context("no OS credential store available")
}

/// The stored SSH password or key passphrase for a connection, if any.
pub fn ssh_secret(id: &Uuid) -> Result<Option<String>> {
    #[cfg(not(test))]
    {
        match ssh_entry(id)?.get_password() {
            Ok(secret) => Ok(Some(secret)),
            Err(keyring::Error::NoEntry) => Ok(None),
            Err(error) => Err(error.into()),
        }
    }
    #[cfg(test)]
    {
        let _ = id;
        Ok(None)
    }
}

/// Store the SSH password or key passphrase; an empty one forgets it.
pub fn set_ssh_secret(id: &Uuid, secret: &str) -> Result<()> {
    #[cfg(not(test))]
    {
        if secret.is_empty() {
            return delete_ssh_secret(id);
        }
        ssh_entry(id)?
            .set_password(secret)
            .context("could not save the SSH secret to the OS credential store")
    }
    #[cfg(test)]
    {
        let _ = (id, secret);
        Ok(())
    }
}

pub fn delete_ssh_secret(id: &Uuid) -> Result<()> {
    #[cfg(not(test))]
    {
        match ssh_entry(id)?.delete_credential() {
            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
            Err(error) => Err(error.into()),
        }
    }
    #[cfg(test)]
    {
        let _ = id;
        Ok(())
    }
}

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

    struct ScratchDir(PathBuf);

    impl ScratchDir {
        fn new() -> Self {
            let path = std::env::temp_dir().join(format!("zippa-db-store-test-{}", Uuid::new_v4()));
            fs::create_dir_all(&path).expect("could not create the scratch directory");
            Self(path)
        }
    }

    impl Drop for ScratchDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    /// `connections.json` can carry a database's host, port, and username —
    /// a shared machine's other users have no business reading that, even
    /// though the password itself lives in the OS credential store.
    #[test]
    #[cfg(unix)]
    fn saved_connections_are_restricted_to_the_owner() {
        use std::os::unix::fs::PermissionsExt as _;

        let dir = ScratchDir::new();
        set_config_dir_for_test(dir.0.clone());

        save(&[], ticket()).expect("could not save the connections");

        let mode = fs::metadata(dir.0.join(FILE_NAME))
            .expect("the file should exist")
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600, "the file should be owner-only");
    }

    /// The Windows fallback path (`save`'s in-place write when `rename`
    /// fails) goes through the same `write_restricted`, so it gets the same
    /// permissions rather than the platform default.
    #[test]
    #[cfg(unix)]
    fn write_restricted_creates_an_owner_only_file() {
        use std::os::unix::fs::PermissionsExt as _;

        let dir = ScratchDir::new();
        let path = dir.0.join("owner-only.json");

        write_restricted(&path, "{}").expect("could not write the file");

        let mode = fs::metadata(&path)
            .expect("the file should exist")
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600, "the file should be owner-only");
    }
}