Skip to main content

db_keystore/
rekey.rs

1//! Out-of-place rekey (DEK rotation) with exact verification, safe destination
2//! creation, and typed errors.
3//!
4//! [`DbKeyStore::rekey`] copies every credential from a source keystore into a
5//! freshly created destination keystore, re-encrypting with the destination
6//! key (or writing plaintext when no destination key is given). On success the
7//! destination has been:
8//!
9//! - created safely: `O_CREAT | O_EXCL | O_NOFOLLOW`, mode `0600` from the
10//!   instant of creation (the WAL/SHM sidecars are pre-created `0600` too),
11//!   parent directory pinned by descriptor (on Linux the database is opened
12//!   through `/proc/self/fd/<dir>/<name>` so a substituted parent directory
13//!   cannot redirect the write);
14//! - verified exactly: every record's `service`, `user`, `uuid`, `comment`,
15//!   and secret bytes are compared between source and destination, streaming
16//!   one record at a time (a matching row count alone is not accepted);
17//! - durably closed: WAL checkpointed (`TRUNCATE`), file-synced, sidecar
18//!   WAL/SHM files removed, directory synced, and every directory entry
19//!   (source, destination, and sidecars) re-verified to still be the inode
20//!   that was validated or created at the start.
21//!
22//! Substitution resistance has one caveat: turso opens files only by path,
23//! so a swap of the *final* path component in the window between
24//! creation/validation and turso's own open cannot be prevented, only
25//! detected. The inode re-verification above closes that window after the
26//! fact: a swapped source, destination, or sidecar entry causes an error
27//! ([`RekeyError::SourceReplaced`] / [`RekeyError::UnsafeDestination`])
28//! instead of success. Preventing the swap entirely requires the database
29//! layer to accept an already-opened descriptor, which turso does not
30//! currently support (see todo notes; out of scope here).
31//!
32//! On failure a typed [`RekeyError`] is returned without panicking, the source
33//! is left unchanged, and partially written destination files are removed,
34//! but only files whose directory entries still match the inodes this
35//! operation created; pre-existing or substituted files are never deleted.
36//!
37//! Secrets never leave zeroizing owners on the db-keystore side. The
38//! remaining exposure is the turso boundary: turso owns row values and
39//! parameter blobs as ordinary `Vec<u8>`/`String` and the encryption key as
40//! an ordinary `String`, all freed without wiping. During the copy, each
41//! secret travels only inside the `turso::Value` read from the source row and
42//! bound directly to the destination insert, both turso-owned allocations;
43//! this module adds no copies of its own. Extending zeroization into turso
44//! requires a turso API change and is out of scope here.
45//!
46//! No digest of credential secrets (keyed or not) is exposed by this API;
47//! comparison is exact and internal, so low-entropy secrets cannot be attacked
48//! offline through the verification machinery. The same streaming comparison
49//! is available on its own, without copying anything, as
50//! [`DbKeyStore::verify`] (and, descriptor-relative on Linux, `verify_at`),
51//! for re-verifying a rekeyed candidate or comparing two existing keystores.
52//!
53//! # Caller obligations and runtime notes
54//!
55//! - **Quiescence is the caller's job.** Rekey and verify do not lock out
56//!   concurrent writers. A source mutated mid-operation is caught fail-closed
57//!   by the exact verification ([`RekeyError::VerificationMismatch`]);
58//!   serialize these operations against your own writers and treat the
59//!   fail-closed error as only a backstop.
60//! - **Panic containment presumes `panic = "unwind"`.** The `catch_unwind`
61//!   conversion to [`RekeyError::Panicked`] cannot run in a binary built with
62//!   `panic = "abort"`; in that profile a database-layer panic aborts the
63//!   process instead of returning an error.
64//! - **Synchronous API, internal executor.** These functions drive turso's
65//!   async API to completion with a blocking executor on the calling thread,
66//!   and transient file-lock errors are retried internally (up to 60 retries
67//!   with exponential backoff from 20 ms capped at 250 ms; worst case
68//!   roughly 15 seconds per database open or connect). Plan timeouts
69//!   accordingly; there is no async variant.
70
71use std::{
72    fmt,
73    panic::{AssertUnwindSafe, catch_unwind},
74    path::Path,
75    time::Duration,
76};
77
78use futures::executor::block_on;
79use turso::{Builder, Connection, Database, Value};
80use zeroize::Zeroizing;
81
82use crate::{DbKeyStore, EncryptionOpts};
83
84#[cfg(unix)]
85use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
86
87#[cfg(target_os = "linux")]
88use std::os::fd::AsRawFd;
89
90#[cfg(unix)]
91use rustix::fs::{AtFlags, FileType, Mode, OFlags};
92
93/// The sidecar files turso 0.7 creates next to a database file.
94///
95/// This is the single source of truth for the suffix set: destination
96/// pre-creation (mode `0600`), failure cleanup, post-rekey re-verification,
97/// and verify's sidecar hygiene all iterate this list. Verified against turso
98/// 0.7.2 (`coordination_path_for_wal_path`); `tests/sidecar_pin.rs` pins the
99/// turso version together with this set so a dependency bump fails CI until
100/// the set is re-verified.
101const SIDECAR_SUFFIXES: [&str; 2] = ["-wal", "-tshm"];
102
103/// The WAL member of [`SIDECAR_SUFFIXES`], singled out for the
104/// checkpoint-leaves-empty-WAL assertion.
105const WAL_SUFFIX: &str = "-wal";
106
107/// Result of a successful [`DbKeyStore::rekey`] operation.
108///
109/// Success itself means "exactly verified": rekey does not return this value
110/// until every source record has been compared byte-for-byte against the
111/// destination and the destination has been durably closed.
112///
113/// Contains no secret material, so it is safe to log or format.
114#[derive(Debug, Clone, Copy, Eq, PartialEq)]
115pub struct RekeyOutcome {
116    /// Number of credentials copied (and verified) from source to destination.
117    pub copied: u64,
118}
119
120/// Typed error for [`DbKeyStore::rekey`] and [`rekey_at`].
121///
122/// Every malformed-database and wrong-key case returns one of these variants;
123/// the operation never unwinds or aborts (a panic escaping the underlying
124/// database layer is caught and reported as [`RekeyError::Panicked`]).
125/// Messages never contain secret material.
126#[derive(Debug)]
127#[non_exhaustive]
128pub enum RekeyError {
129    /// The source database could not be decrypted with the supplied key/cipher.
130    WrongSourceKey,
131    /// The destination database could not be decrypted with the supplied
132    /// key/cipher. Produced by [`DbKeyStore::verify`] (and `verify_at`),
133    /// which re-open an existing destination candidate; rekey itself always
134    /// creates a fresh destination and cannot produce this.
135    WrongDestinationKey,
136    /// The source directory entry stopped referring to the file that was
137    /// validated at the start of the operation (it was replaced mid-rekey).
138    SourceReplaced(String),
139    /// The source database file does not exist or is not a regular file.
140    SourceNotFound(String),
141    /// The source is not a readable db-keystore database (corrupt, not a
142    /// database, or missing the credentials table). An encrypted source opened
143    /// without any key also lands here, since it is indistinguishable from a
144    /// non-database file.
145    CorruptSource(String),
146    /// Source and destination records did not compare exactly equal.
147    VerificationMismatch(String),
148    /// The destination path already exists (including as a symlink).
149    DestinationExists(String),
150    /// The destination database does not exist or is not a regular file.
151    /// Produced only by verification, which requires an existing destination
152    /// (rekey requires the opposite; see [`RekeyError::DestinationExists`]).
153    DestinationNotFound(String),
154    /// The destination is not a readable db-keystore database (corrupt, not a
155    /// database, or missing the credentials table). Produced only by
156    /// verification; rekey always creates its destination.
157    CorruptDestination(String),
158    /// The destination directory entry stopped referring to the file that was
159    /// validated at the start of a verification (it was replaced mid-verify).
160    /// The rekey entry points report the same condition as
161    /// [`RekeyError::UnsafeDestination`].
162    DestinationReplaced(String),
163    /// The destination could not be created safely, or the directory entry no
164    /// longer refers to the file that was created.
165    UnsafeDestination(String),
166    /// A key or cipher parameter was malformed.
167    InvalidKey(String),
168    /// Filesystem error.
169    Io(std::io::Error),
170    /// Other database-layer error.
171    Database(String),
172    /// A panic escaped the database layer and was converted into an error.
173    ///
174    /// The payload is best-effort diagnostic text with no stable format: it
175    /// is captured from whatever code panicked, length-bounded, and stripped
176    /// of control characters at capture time. Do not parse it.
177    Panicked(String),
178}
179
180impl fmt::Display for RekeyError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        match self {
183            RekeyError::WrongSourceKey => {
184                write!(
185                    f,
186                    "source database could not be decrypted with the supplied key"
187                )
188            }
189            RekeyError::WrongDestinationKey => write!(
190                f,
191                "destination database could not be decrypted with the supplied key"
192            ),
193            RekeyError::SourceNotFound(msg) => write!(f, "source database not found: {msg}"),
194            RekeyError::SourceReplaced(msg) => {
195                write!(f, "source file was replaced during rekey: {msg}")
196            }
197            RekeyError::CorruptSource(msg) => write!(f, "source is not a usable database: {msg}"),
198            RekeyError::VerificationMismatch(msg) => {
199                write!(f, "source/destination verification failed: {msg}")
200            }
201            RekeyError::DestinationExists(msg) => {
202                write!(f, "destination already exists: {msg}")
203            }
204            RekeyError::DestinationNotFound(msg) => {
205                write!(f, "destination database not found: {msg}")
206            }
207            RekeyError::CorruptDestination(msg) => {
208                write!(f, "destination is not a usable database: {msg}")
209            }
210            RekeyError::DestinationReplaced(msg) => {
211                write!(
212                    f,
213                    "destination file was replaced during verification: {msg}"
214                )
215            }
216            RekeyError::UnsafeDestination(msg) => {
217                write!(f, "destination could not be created safely: {msg}")
218            }
219            RekeyError::InvalidKey(msg) => write!(f, "invalid key: {msg}"),
220            RekeyError::Io(err) => write!(f, "i/o error: {err}"),
221            RekeyError::Database(msg) => write!(f, "database error: {msg}"),
222            RekeyError::Panicked(msg) => {
223                write!(f, "database layer panicked (caught): {msg}")
224            }
225        }
226    }
227}
228
229impl std::error::Error for RekeyError {
230    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
231        match self {
232            RekeyError::Io(err) => Some(err),
233            _ => None,
234        }
235    }
236}
237
238impl From<std::io::Error> for RekeyError {
239    fn from(err: std::io::Error) -> Self {
240        RekeyError::Io(err)
241    }
242}
243
244/// A fixed-size, zeroizing container for a database encryption key (DEK).
245///
246/// Supports 128-bit and 256-bit keys (the sizes turso's ciphers use). The key
247/// bytes live in a `Zeroizing<[u8; 32]>` that is wiped on drop; no ordinary
248/// heap copy of the key is made by this type. Constructors borrow the caller's
249/// buffer, so the caller keeps ownership (and wiping responsibility) of its
250/// own copy. No particular zeroizing library is required of callers.
251#[derive(Clone)]
252pub struct SensitiveKey {
253    bytes: Zeroizing<[u8; 32]>,
254    len: usize,
255}
256
257impl SensitiveKey {
258    /// Decode a hex-encoded key (32 or 64 hex chars) into zeroizing storage.
259    pub fn from_hex(hexkey: &str) -> Result<Self, RekeyError> {
260        if hexkey.len() != 32 && hexkey.len() != 64 {
261            return Err(RekeyError::InvalidKey(
262                "hex key must be 32 or 64 hex characters (128- or 256-bit key)".to_string(),
263            ));
264        }
265        let mut bytes = Zeroizing::new([0u8; 32]);
266        for (i, pair) in hexkey.as_bytes().chunks_exact(2).enumerate() {
267            let hi = hex_nibble(pair[0])?;
268            let lo = hex_nibble(pair[1])?;
269            bytes[i] = (hi << 4) | lo;
270        }
271        Ok(Self {
272            bytes,
273            len: hexkey.len() / 2,
274        })
275    }
276
277    /// Copy a raw 16- or 32-byte key into zeroizing storage.
278    pub fn from_bytes(key: &[u8]) -> Result<Self, RekeyError> {
279        if key.len() != 16 && key.len() != 32 {
280            return Err(RekeyError::InvalidKey(
281                "key must be 16 or 32 bytes".to_string(),
282            ));
283        }
284        let mut bytes = Zeroizing::new([0u8; 32]);
285        bytes[..key.len()].copy_from_slice(key);
286        Ok(Self {
287            bytes,
288            len: key.len(),
289        })
290    }
291
292    /// Borrow the raw key bytes.
293    pub fn as_bytes(&self) -> &[u8] {
294        &self.bytes[..self.len]
295    }
296
297    /// Key length in bytes (16 or 32).
298    pub fn len(&self) -> usize {
299        self.len
300    }
301
302    /// Always false; a key is never empty. Present for API completeness.
303    pub fn is_empty(&self) -> bool {
304        false
305    }
306
307    /// Hex-encode into a zeroizing string. Capacity is preallocated exactly so
308    /// the buffer is never reallocated (no stray heap copies).
309    pub(crate) fn to_hex(&self) -> Zeroizing<String> {
310        const HEX: &[u8; 16] = b"0123456789abcdef";
311        let mut out = String::with_capacity(self.len * 2);
312        for byte in self.as_bytes() {
313            out.push(HEX[usize::from(byte >> 4)] as char);
314            out.push(HEX[usize::from(byte & 0x0f)] as char);
315        }
316        Zeroizing::new(out)
317    }
318}
319
320impl fmt::Debug for SensitiveKey {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(f, "SensitiveKey(<redacted>, {} bytes)", self.len)
323    }
324}
325
326fn hex_nibble(c: u8) -> Result<u8, RekeyError> {
327    match c {
328        b'0'..=b'9' => Ok(c - b'0'),
329        b'a'..=b'f' => Ok(c - b'a' + 10),
330        b'A'..=b'F' => Ok(c - b'A' + 10),
331        _ => Err(RekeyError::InvalidKey(
332            "hex key contains a non-hex character".to_string(),
333        )),
334    }
335}
336
337/// Which database an error came from, for error attribution.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339enum Side {
340    Source,
341    Destination,
342}
343
344/// Map a turso error to a typed [`RekeyError`], attributing wrong-key and
345/// corruption cases to the given side.
346fn db_err(err: &turso::Error, side: Side) -> RekeyError {
347    let text = err.to_string();
348    if text.to_ascii_lowercase().contains("decryption failed") {
349        return match side {
350            Side::Source => RekeyError::WrongSourceKey,
351            Side::Destination => RekeyError::WrongDestinationKey,
352        };
353    }
354    match (err, side) {
355        (turso::Error::NotAdb(_) | turso::Error::Corrupt(_), Side::Source) => {
356            RekeyError::CorruptSource(text)
357        }
358        (turso::Error::NotAdb(_) | turso::Error::Corrupt(_), Side::Destination) => {
359            RekeyError::CorruptDestination(text)
360        }
361        _ => RekeyError::Database(text),
362    }
363}
364
365fn keyring_err(err: &keyring_core::Error, side: Side) -> RekeyError {
366    match side {
367        Side::Source => RekeyError::CorruptSource(err.to_string()),
368        Side::Destination => RekeyError::Database(err.to_string()),
369    }
370}
371
372impl DbKeyStore {
373    /// Rekey a keystore out-of-place with exact verification: read every
374    /// credential from the source database, write it into a freshly and safely
375    /// created destination database, then compare all source and destination
376    /// records (service, user, uuid, comment, and secret bytes) before
377    /// checkpointing and durably closing the destination.
378    ///
379    /// This is used to add, remove, or rotate the on-disk encryption key (a
380    /// DEK rotation): pass `dest_opts = Some(..)` to add or rotate encryption,
381    /// or `dest_opts = None` to write an unencrypted copy. `source_opts` must
382    /// supply the cipher/key the source was written with (or `None` if the
383    /// source is unencrypted).
384    ///
385    /// Success means "exactly verified": if this function returns `Ok`, the
386    /// destination contains a byte-exact copy of every source credential, has
387    /// been checkpointed and file-synced, and no WAL/SHM sidecar files remain.
388    /// On every failure the source is left unchanged, partially written
389    /// destination files are removed, and a typed [`RekeyError`] is returned
390    /// without panicking.
391    ///
392    /// The destination is created `O_CREAT | O_EXCL | O_NOFOLLOW` with mode
393    /// `0600` relative to a pinned parent directory descriptor; an existing
394    /// file or symlink at `dest_path` (or at its WAL/SHM sidecar names) is
395    /// rejected and never deleted. Missing destination parent directories are
396    /// created. Whether the source enforced `(service, user)` uniqueness is
397    /// detected from the source schema and mirrored on the destination so
398    /// ambiguous keystores round-trip unchanged. Callers needing full
399    /// directory-descriptor control should use [`rekey_at`] (Linux); see the
400    /// [module docs](self) for the substitution-resistance caveat shared by
401    /// both entry points.
402    ///
403    /// # Choosing an entry point
404    ///
405    /// Security-sensitive callers on Linux should prefer [`rekey_at`]. This
406    /// path-based entry point creates missing destination parent directories
407    /// with `create_dir_all` (umask-default modes) and follows
408    /// symlinks when canonicalizing the source path; `rekey_at` does neither,
409    /// pins both directories by descriptor for the whole operation, and
410    /// returns the created destination's file descriptor so custody extends
411    /// through the caller's subsequent swap.
412    ///
413    /// See the [module docs](self) for caller obligations: quiescence,
414    /// `panic = "unwind"`, and the internal executor/retry behavior.
415    ///
416    /// No secret material is logged or included in any returned value.
417    pub fn rekey(
418        source_path: impl AsRef<Path>,
419        source_opts: Option<&EncryptionOpts>,
420        dest_path: impl AsRef<Path>,
421        dest_opts: Option<&EncryptionOpts>,
422    ) -> Result<RekeyOutcome, RekeyError> {
423        let source_path = source_path.as_ref();
424        let dest_path = dest_path.as_ref();
425        catch_panics(|| rekey_paths(source_path, source_opts, dest_path, dest_opts))
426    }
427
428    /// Verify that two existing keystores contain exactly equal credential
429    /// records, without copying or modifying anything.
430    ///
431    /// This is the same streaming comparison [`DbKeyStore::rekey`] runs
432    /// before returning success: every record's `service`, `user`, `uuid`,
433    /// `comment`, and secret bytes compared byte- and storage-class-exact,
434    /// one record at a time (bounded memory), with no digest of secrets
435    /// computed and no secret material in any error. Returns the number of
436    /// records verified; any divergence (differing field, missing record,
437    /// extra record) is a [`RekeyError::VerificationMismatch`].
438    ///
439    /// Use it to re-verify a rekeyed candidate before or after an
440    /// atomic-rename swap, or to compare any two keystores. Unlike `rekey`,
441    /// both databases must already exist ([`RekeyError::SourceNotFound`] /
442    /// [`RekeyError::DestinationNotFound`] otherwise); nothing is created and
443    /// no schema is initialized or written on either side. A destination that
444    /// cannot be decrypted with `dest_opts` returns
445    /// [`RekeyError::WrongDestinationKey`]; a destination that is not a
446    /// keystore database returns [`RekeyError::CorruptDestination`].
447    ///
448    /// One side effect is unavoidable at the database layer: opening a
449    /// database creates an empty WAL sidecar if none exists. Sidecar files
450    /// that this verification's own open created, and that are still empty,
451    /// are removed before returning; pre-existing sidecar files are never
452    /// touched (a source with uncheckpointed WAL frames verifies fine and
453    /// keeps its WAL).
454    ///
455    /// As with rekey, quiescence is the caller's job; see the
456    /// [module docs](self).
457    pub fn verify(
458        source_path: impl AsRef<Path>,
459        source_opts: Option<&EncryptionOpts>,
460        dest_path: impl AsRef<Path>,
461        dest_opts: Option<&EncryptionOpts>,
462    ) -> Result<u64, RekeyError> {
463        let source_path = source_path.as_ref();
464        let dest_path = dest_path.as_ref();
465        catch_panics(|| verify_paths(source_path, source_opts, dest_path, dest_opts))
466    }
467}
468
469/// Descriptor-relative rekey (Linux).
470///
471/// Like [`DbKeyStore::rekey`], but the source and destination are named
472/// relative to caller-owned directory descriptors, so the caller controls
473/// exactly which directories are used and no *directory* component can be
474/// substituted underneath the operation:
475///
476/// - the source is opened with `openat(source_dir, ..., O_NOFOLLOW)` and must
477///   be a regular file;
478/// - the destination (and its WAL/SHM sidecars) are created with
479///   `openat(dest_dir, ..., O_CREAT | O_EXCL | O_NOFOLLOW)`, mode `0600` from
480///   the instant of creation (an `fchmod` pins the mode against the umask);
481/// - the databases are opened through `/proc/self/fd/<dirfd>/<name>`, so every
482///   file turso touches (including WAL sidecars) resolves through the pinned
483///   directory descriptors;
484/// - before success, every directory entry (source, destination, and
485///   sidecars) is re-checked (`O_NOFOLLOW`) to confirm it is still the inode
486///   validated or created at the start; otherwise
487///   [`RekeyError::SourceReplaced`] or [`RekeyError::UnsafeDestination`] is
488///   returned. As the [module docs](self) explain, these checks detect a
489///   final-component swap in the window before turso's own by-path open but
490///   cannot prevent it; the directory itself can never be substituted.
491///
492/// `source_name` and `dest_name` must be single path components (no `/`).
493/// Directory descriptors should be opened with read access (`O_RDONLY |
494/// O_DIRECTORY`) so the destination directory can be fsynced.
495///
496/// On success, returns the [`RekeyOutcome`] together with the `OwnedFd` of
497/// the created destination file. The descriptor is the same one the
498/// destination was created and verified through, so the pinned-inode chain
499/// of custody extends past this call: a caller performing its own
500/// `renameat`-style swap can re-check the directory entry against this
501/// descriptor (`fstat` dev/ino) instead of re-resolving by name. Dropping it
502/// is harmless: the destination is already durably closed.
503///
504/// See the [module docs](self) for caller obligations: quiescence,
505/// `panic = "unwind"`, and the internal executor/retry behavior.
506#[cfg(target_os = "linux")]
507pub fn rekey_at(
508    source_dir: impl AsFd,
509    source_name: &str,
510    source_opts: Option<&EncryptionOpts>,
511    dest_dir: impl AsFd,
512    dest_name: &str,
513    dest_opts: Option<&EncryptionOpts>,
514) -> Result<(RekeyOutcome, OwnedFd), RekeyError> {
515    let source_dir = source_dir.as_fd();
516    let dest_dir = dest_dir.as_fd();
517    catch_panics(|| {
518        rekey_fds(
519            source_dir,
520            source_name,
521            None,
522            source_opts,
523            dest_dir,
524            dest_name,
525            None,
526            dest_opts,
527        )
528    })
529}
530
531/// Descriptor-relative verification (Linux).
532///
533/// Like [`DbKeyStore::verify`], but the source and destination are named
534/// relative to caller-owned directory descriptors and opened through
535/// `/proc/self/fd/<dirfd>/<name>`, exactly as [`rekey_at`] does. See that
536/// function for the descriptor conventions and [`DbKeyStore::verify`] for
537/// the verification contract (returned count, typed errors, sidecar
538/// hygiene). Both names must exist as regular files (`O_NOFOLLOW`; symlinks
539/// rejected). After the comparison, both directory entries are re-checked
540/// against the inodes validated at the start; a swapped entry returns
541/// [`RekeyError::SourceReplaced`] or [`RekeyError::DestinationReplaced`]
542/// instead of success.
543#[cfg(target_os = "linux")]
544pub fn verify_at(
545    source_dir: impl AsFd,
546    source_name: &str,
547    source_opts: Option<&EncryptionOpts>,
548    dest_dir: impl AsFd,
549    dest_name: &str,
550    dest_opts: Option<&EncryptionOpts>,
551) -> Result<u64, RekeyError> {
552    let source_dir = source_dir.as_fd();
553    let dest_dir = dest_dir.as_fd();
554    catch_panics(|| {
555        verify_fds(
556            source_dir,
557            source_name,
558            source_opts,
559            dest_dir,
560            dest_name,
561            dest_opts,
562        )
563    })
564}
565
566/// Run `f`, converting an escaped panic into `RekeyError::Panicked`.
567/// Turso 0.7 returns errors (not panics) for wrong-key and corrupt-database
568/// cases; this is defense in depth so rekey itself never unwinds.
569fn catch_panics<T>(f: impl FnOnce() -> Result<T, RekeyError>) -> Result<T, RekeyError> {
570    match catch_unwind(AssertUnwindSafe(f)) {
571        Ok(result) => result,
572        Err(payload) => {
573            let msg = payload
574                .downcast_ref::<&str>()
575                .map(ToString::to_string)
576                .or_else(|| payload.downcast_ref::<String>().cloned())
577                .unwrap_or_else(|| "unknown panic".to_string());
578            Err(RekeyError::Panicked(sanitize_panic_payload(&msg)))
579        }
580    }
581}
582
583/// Upper bound on the panic payload text preserved in [`RekeyError::Panicked`].
584const PANIC_PAYLOAD_MAX_CHARS: usize = 256;
585
586/// The payload originates in whatever code panicked, so it is untrusted: a
587/// hypothetical database-layer panic message could embed buffer contents.
588/// Bound its length and replace control characters before it enters the
589/// error value.
590fn sanitize_panic_payload(msg: &str) -> String {
591    let mut out: String = msg
592        .chars()
593        .take(PANIC_PAYLOAD_MAX_CHARS)
594        .map(|c| if c.is_control() { ' ' } else { c })
595        .collect();
596    if msg.chars().nth(PANIC_PAYLOAD_MAX_CHARS).is_some() {
597        out.push_str("… (truncated)");
598    }
599    out
600}
601
602/// Path-based entry point: resolve parents, pin them with directory
603/// descriptors, and delegate to the descriptor-relative implementation.
604#[cfg(unix)]
605fn rekey_paths(
606    source_path: &Path,
607    source_opts: Option<&EncryptionOpts>,
608    dest_path: &Path,
609    dest_opts: Option<&EncryptionOpts>,
610) -> Result<RekeyOutcome, RekeyError> {
611    // Follow symlinks deliberately for the *source* path (the caller may
612    // legitimately reference the keystore through a symlink), then pin the
613    // resolved parent directory.
614    let source_canon = source_path
615        .canonicalize()
616        .map_err(|e| RekeyError::SourceNotFound(format!("{}: {e}", source_path.display())))?;
617    let (source_parent, source_name) = split_parent_name(&source_canon)
618        .ok_or_else(|| RekeyError::SourceNotFound(format!("{}", source_path.display())))?;
619    let source_dir = open_dir(source_parent)?;
620
621    let (dest_parent, dest_name) = split_parent_name(dest_path).ok_or_else(|| {
622        RekeyError::UnsafeDestination(format!(
623            "destination path '{}' has no file name",
624            dest_path.display()
625        ))
626    })?;
627    // Create missing destination parents (as pre-0.5 rekey did), then pin the
628    // parent directory; everything after this resolves relative to the fd.
629    std::fs::create_dir_all(dest_parent)?;
630    let dest_dir = open_dir(dest_parent)?;
631
632    rekey_fds(
633        source_dir.as_fd(),
634        &source_name,
635        Some(source_parent),
636        source_opts,
637        dest_dir.as_fd(),
638        &dest_name,
639        Some(dest_parent),
640        dest_opts,
641    )
642    .map(|(outcome, _dest_fd)| outcome)
643}
644
645/// Portable fallback for non-unix targets: no descriptor pinning or unix
646/// permission control is available, so creation safety is limited to
647/// `create_new` (exclusive, symlink-refusing) semantics. The durability
648/// contract is honored: the WAL is verified empty after the checkpoint,
649/// sidecar files are removed, and the destination is synced before success.
650#[cfg(not(unix))]
651fn rekey_paths(
652    source_path: &Path,
653    source_opts: Option<&EncryptionOpts>,
654    dest_path: &Path,
655    dest_opts: Option<&EncryptionOpts>,
656) -> Result<RekeyOutcome, RekeyError> {
657    if !source_path.is_file() {
658        return Err(RekeyError::SourceNotFound(format!(
659            "{}",
660            source_path.display()
661        )));
662    }
663    let source_str = source_path
664        .to_str()
665        .ok_or_else(|| RekeyError::SourceNotFound("path must be valid UTF-8".to_string()))?;
666    let dest_str = dest_path
667        .to_str()
668        .ok_or_else(|| RekeyError::UnsafeDestination("path must be valid UTF-8".to_string()))?;
669    if let Some(parent) = dest_path.parent()
670        && !parent.as_os_str().is_empty()
671    {
672        std::fs::create_dir_all(parent)?;
673    }
674    let dest_file = std::fs::OpenOptions::new()
675        .write(true)
676        .create_new(true)
677        .open(dest_path)
678        .map_err(|e| {
679            if e.kind() == std::io::ErrorKind::AlreadyExists {
680                RekeyError::DestinationExists(dest_path.display().to_string())
681            } else {
682                RekeyError::Io(e)
683            }
684        })?;
685    let sidecar_paths: Vec<String> = SIDECAR_SUFFIXES
686        .iter()
687        .map(|suffix| format!("{dest_str}{suffix}"))
688        .collect();
689    let wal_path = format!("{dest_str}{WAL_SUFFIX}");
690    // pre-existing sidecars are rejected (and never deleted): the database
691    // layer would otherwise adopt a stale WAL for the fresh destination
692    for sidecar in &sidecar_paths {
693        if Path::new(sidecar).exists() {
694            let _ = std::fs::remove_file(dest_path);
695            return Err(RekeyError::DestinationExists(sidecar.clone()));
696        }
697    }
698    let result = (|| {
699        let copied = run_rekey(source_str, source_opts, dest_str, dest_opts)?;
700        // committed credentials must not be stranded in the WAL
701        match std::fs::metadata(&wal_path) {
702            Ok(meta) if meta.len() > 0 => {
703                return Err(RekeyError::Database(format!(
704                    "destination WAL '{wal_path}' still contains {} bytes after checkpoint",
705                    meta.len()
706                )));
707            }
708            _ => {}
709        }
710        dest_file.sync_all()?;
711        // remove the (empty) sidecar files so the candidate is cleanly closed
712        for sidecar in &sidecar_paths {
713            match std::fs::remove_file(sidecar) {
714                Ok(()) => {}
715                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
716                Err(e) => return Err(RekeyError::Io(e)),
717            }
718        }
719        Ok(RekeyOutcome { copied })
720    })();
721    if result.is_err() {
722        // best-effort cleanup of the partial destination
723        let _ = std::fs::remove_file(dest_path);
724        for sidecar in &sidecar_paths {
725            let _ = std::fs::remove_file(sidecar);
726        }
727    }
728    result
729}
730
731/// Existence snapshot of a database's sidecar files, taken before the
732/// database is opened, so that sidecars created as a side effect of the open
733/// (turso creates an empty WAL if none exists) can be removed afterwards,
734/// and only those: a pre-existing sidecar, or one that is no longer empty,
735/// is never touched.
736struct SidecarSnapshot {
737    /// (sidecar path, existed before our open)
738    entries: Vec<(std::path::PathBuf, bool)>,
739}
740
741impl SidecarSnapshot {
742    fn take(db_path: &Path) -> Self {
743        let entries = SIDECAR_SUFFIXES
744            .iter()
745            .map(|suffix| {
746                let mut name = db_path.file_name().unwrap_or_default().to_os_string();
747                name.push(suffix);
748                let path = db_path.with_file_name(name);
749                let existed = path.symlink_metadata().is_ok();
750                (path, existed)
751            })
752            .collect();
753        Self { entries }
754    }
755
756    /// Best-effort removal of sidecars our open created that are still empty
757    /// regular files.
758    fn remove_created_empty(&self) {
759        for (path, existed) in &self.entries {
760            if *existed {
761                continue;
762            }
763            if let Ok(meta) = path.symlink_metadata()
764                && meta.is_file()
765                && meta.len() == 0
766            {
767                let _ = std::fs::remove_file(path);
768            }
769        }
770    }
771}
772
773/// Path-based verification shared by all platforms: both databases must
774/// already exist; nothing is created, initialized, or written.
775fn verify_paths(
776    source_path: &Path,
777    source_opts: Option<&EncryptionOpts>,
778    dest_path: &Path,
779    dest_opts: Option<&EncryptionOpts>,
780) -> Result<u64, RekeyError> {
781    if !source_path.is_file() {
782        return Err(RekeyError::SourceNotFound(format!(
783            "{}",
784            source_path.display()
785        )));
786    }
787    if !dest_path.is_file() {
788        return Err(RekeyError::DestinationNotFound(format!(
789            "{}",
790            dest_path.display()
791        )));
792    }
793    let source_str = source_path
794        .to_str()
795        .ok_or_else(|| RekeyError::SourceNotFound("path must be valid UTF-8".to_string()))?;
796    let dest_str = dest_path
797        .to_str()
798        .ok_or_else(|| RekeyError::DestinationNotFound("path must be valid UTF-8".to_string()))?;
799    let source_sidecars = SidecarSnapshot::take(source_path);
800    let dest_sidecars = SidecarSnapshot::take(dest_path);
801    let result = run_verify(source_str, source_opts, dest_str, dest_opts);
802    // hygiene runs on failure too: a failed verify must not leave behind
803    // sidecars its own open created
804    source_sidecars.remove_created_empty();
805    dest_sidecars.remove_created_empty();
806    result
807}
808
809#[cfg(unix)]
810fn split_parent_name(path: &Path) -> Option<(&Path, String)> {
811    let name = path.file_name()?.to_str()?.to_string();
812    if name.is_empty() || name == "." || name == ".." {
813        return None;
814    }
815    let parent = path.parent()?;
816    let parent = if parent.as_os_str().is_empty() {
817        Path::new(".")
818    } else {
819        parent
820    };
821    Some((parent, name))
822}
823
824#[cfg(unix)]
825fn open_dir(path: &Path) -> Result<OwnedFd, RekeyError> {
826    let fd = rustix::fs::open(
827        path,
828        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
829        Mode::empty(),
830    )
831    .map_err(|e| {
832        RekeyError::Io(std::io::Error::new(
833            std::io::Error::from(e).kind(),
834            format!("open directory '{}': {e}", path.display()),
835        ))
836    })?;
837    Ok(fd)
838}
839
840/// Reject names that are not a single, non-trivial path component.
841#[cfg(unix)]
842fn validate_name(name: &str, what: &str) -> Result<(), RekeyError> {
843    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
844        return Err(RekeyError::UnsafeDestination(format!(
845            "{what} name '{name}' must be a single path component"
846        )));
847    }
848    Ok(())
849}
850
851/// Compute the path turso should open for `name` inside the pinned directory.
852/// On Linux this goes through `/proc/self/fd/<dirfd>/<name>`, so path
853/// resolution cannot escape the pinned directory even if the directory is
854/// renamed or substituted. Elsewhere the caller-supplied directory path is
855/// used (pinning is then limited to creation and post-verification).
856#[cfg(unix)]
857fn pinned_turso_path(
858    dir: BorrowedFd<'_>,
859    name: &str,
860    dir_path: Option<&Path>,
861) -> Result<String, RekeyError> {
862    #[cfg(target_os = "linux")]
863    {
864        if Path::new("/proc/self/fd").exists() {
865            return Ok(format!("/proc/self/fd/{}/{name}", dir.as_raw_fd()));
866        }
867    }
868    let _ = dir;
869    match dir_path {
870        Some(dir_path) => {
871            let joined = dir_path.join(name);
872            joined.to_str().map(ToString::to_string).ok_or_else(|| {
873                RekeyError::UnsafeDestination("database path must be valid UTF-8".to_string())
874            })
875        }
876        None => Err(RekeyError::UnsafeDestination(
877            "descriptor-relative rekey requires /proc/self/fd".to_string(),
878        )),
879    }
880}
881
882/// Owns the safely-created destination file and its pre-created sidecar
883/// files, and removes them on failure (when dropped uncommitted). Every
884/// unlink (main file and sidecars alike) first checks that the directory
885/// entry still refers to the inode this guard created, so a file substituted
886/// by someone else is never deleted.
887#[cfg(unix)]
888struct DestGuard<'a> {
889    dir: BorrowedFd<'a>,
890    name: &'a str,
891    /// `Some` until [`DestGuard::commit`] transfers ownership to the caller.
892    file: Option<OwnedFd>,
893    /// Sidecar files we created (name, created fd), e.g. `dst.db-wal`.
894    sidecars: Vec<(String, OwnedFd)>,
895    committed: bool,
896}
897
898#[cfg(unix)]
899impl DestGuard<'_> {
900    /// The descriptor of the created destination file.
901    fn fd(&self) -> &OwnedFd {
902        self.file
903            .as_ref()
904            .expect("destination fd present until commit")
905    }
906
907    /// Mark the destination as successfully written (disabling cleanup) and
908    /// release its descriptor to the caller.
909    fn commit(mut self) -> OwnedFd {
910        self.committed = true;
911        self.file
912            .take()
913            .expect("destination fd present until commit")
914    }
915
916    /// Best-effort unlink of the created sidecar files, each only if its
917    /// directory entry is still the inode we created.
918    fn unlink_created_sidecars(&mut self) {
919        for (name, fd) in self.sidecars.drain(..) {
920            if entry_matches(self.dir, &name, &fd) {
921                let _ = rustix::fs::unlinkat(self.dir, name.as_str(), AtFlags::empty());
922            }
923        }
924    }
925}
926
927/// True if the directory entry `name` (not following symlinks) still refers
928/// to the same inode as the open descriptor `fd`.
929#[cfg(unix)]
930fn entry_matches(dir: BorrowedFd<'_>, name: &str, fd: &OwnedFd) -> bool {
931    match (
932        rustix::fs::statat(dir, name, AtFlags::SYMLINK_NOFOLLOW),
933        rustix::fs::fstat(fd),
934    ) {
935        (Ok(entry), Ok(created)) => {
936            entry.st_dev == created.st_dev && entry.st_ino == created.st_ino
937        }
938        _ => false,
939    }
940}
941
942#[cfg(unix)]
943impl Drop for DestGuard<'_> {
944    fn drop(&mut self) {
945        if self.committed {
946            return;
947        }
948        self.unlink_created_sidecars();
949        if let Some(file) = &self.file
950            && entry_matches(self.dir, self.name, file)
951        {
952            let _ = rustix::fs::unlinkat(self.dir, self.name, AtFlags::empty());
953        }
954    }
955}
956
957/// Create the destination database file (and its WAL/SHM sidecars) safely:
958/// directory-relative, `O_CREAT | O_EXCL | O_NOFOLLOW`, mode `0600` pinned
959/// with `fchmod` so the umask cannot widen it. Pre-creating the sidecars with
960/// mode `0600` matters because the WAL briefly holds credential plaintext and
961/// turso would otherwise create it with default (usually `0644`) permissions.
962#[cfg(unix)]
963fn create_destination<'a>(dir: BorrowedFd<'a>, name: &'a str) -> Result<DestGuard<'a>, RekeyError> {
964    validate_name(name, "destination")?;
965    let mode = Mode::RUSR | Mode::WUSR; // 0600
966    let flags = OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::RDWR;
967    let file = rustix::fs::openat(dir, name, flags, mode).map_err(|e| match e {
968        rustix::io::Errno::EXIST => RekeyError::DestinationExists(name.to_string()),
969        other => RekeyError::Io(std::io::Error::from(other)),
970    })?;
971    let mut guard = DestGuard {
972        dir,
973        name,
974        file: Some(file),
975        sidecars: Vec::new(),
976        committed: false,
977    };
978    // Pin the mode to exactly 0600 regardless of the process umask.
979    rustix::fs::fchmod(guard.fd(), mode).map_err(|e| RekeyError::Io(e.into()))?;
980    let st = rustix::fs::fstat(guard.fd()).map_err(|e| RekeyError::Io(e.into()))?;
981    if !FileType::from_raw_mode(st.st_mode).is_file() {
982        return Err(RekeyError::UnsafeDestination(format!(
983            "created destination '{name}' is not a regular file"
984        )));
985    }
986    // Pre-create the sidecar files turso will use, with owner-only mode, so
987    // credential plaintext in the WAL is never world-readable. Turso opens
988    // existing files without changing their mode. A pre-existing sidecar is
989    // rejected (and, having not been created by us, is never deleted).
990    for suffix in SIDECAR_SUFFIXES {
991        let sidecar = format!("{name}{suffix}");
992        let sidecar_fd =
993            rustix::fs::openat(dir, sidecar.as_str(), flags, mode).map_err(|e| match e {
994                rustix::io::Errno::EXIST => RekeyError::DestinationExists(sidecar.clone()),
995                other => RekeyError::Io(std::io::Error::from(other)),
996            })?;
997        guard.sidecars.push((sidecar, sidecar_fd));
998    }
999    Ok(guard)
1000}
1001
1002/// Open and validate an existing database file: directory-relative,
1003/// `O_NOFOLLOW`, must be a regular file. Returns the (kept-open) fd pinning
1004/// the verified inode. Not-found and symlink cases are attributed to `side`
1005/// ([`RekeyError::SourceNotFound`] / [`RekeyError::DestinationNotFound`]).
1006#[cfg(unix)]
1007fn open_existing_checked(
1008    dir: BorrowedFd<'_>,
1009    name: &str,
1010    side: Side,
1011) -> Result<OwnedFd, RekeyError> {
1012    let what = match side {
1013        Side::Source => "source",
1014        Side::Destination => "destination",
1015    };
1016    let not_found = |msg: String| match side {
1017        Side::Source => RekeyError::SourceNotFound(msg),
1018        Side::Destination => RekeyError::DestinationNotFound(msg),
1019    };
1020    validate_name(name, what)?;
1021    let fd = rustix::fs::openat(
1022        dir,
1023        name,
1024        OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
1025        Mode::empty(),
1026    )
1027    .map_err(|e| match e {
1028        rustix::io::Errno::NOENT => not_found(name.to_string()),
1029        rustix::io::Errno::LOOP => not_found(format!(
1030            "{what} '{name}' is a symlink (descriptor-relative access requires a regular file)"
1031        )),
1032        other => RekeyError::Io(std::io::Error::from(other)),
1033    })?;
1034    let st = rustix::fs::fstat(&fd).map_err(|e| RekeyError::Io(e.into()))?;
1035    if !FileType::from_raw_mode(st.st_mode).is_file() {
1036        return Err(not_found(format!("{what} '{name}' is not a regular file")));
1037    }
1038    Ok(fd)
1039}
1040
1041/// Descriptor-relative implementation shared by `rekey` and `rekey_at`.
1042#[cfg(unix)]
1043#[allow(clippy::too_many_arguments)]
1044fn rekey_fds(
1045    source_dir: BorrowedFd<'_>,
1046    source_name: &str,
1047    source_dir_path: Option<&Path>,
1048    source_opts: Option<&EncryptionOpts>,
1049    dest_dir: BorrowedFd<'_>,
1050    dest_name: &str,
1051    dest_dir_path: Option<&Path>,
1052    dest_opts: Option<&EncryptionOpts>,
1053) -> Result<(RekeyOutcome, OwnedFd), RekeyError> {
1054    // Pin and validate the source inode; the descriptor is kept so the
1055    // directory entry can be re-verified against it after the copy.
1056    let source_fd = open_existing_checked(source_dir, source_name, Side::Source)?;
1057    let source_turso_path = pinned_turso_path(source_dir, source_name, source_dir_path)?;
1058
1059    // Create the destination safely; the guard removes it on any failure.
1060    let mut dest = create_destination(dest_dir, dest_name)?;
1061    let dest_turso_path = pinned_turso_path(dest_dir, dest_name, dest_dir_path)?;
1062
1063    let copied = run_rekey(&source_turso_path, source_opts, &dest_turso_path, dest_opts)?;
1064
1065    // The source entry must still be the inode validated above; otherwise
1066    // what was copied and verified is not the file the caller named.
1067    if !entry_matches(source_dir, source_name, &source_fd) {
1068        return Err(RekeyError::SourceReplaced(source_name.to_string()));
1069    }
1070
1071    // Every destination directory entry (main file and sidecars) must still
1072    // be the inode created above; otherwise the destination was substituted
1073    // while we were writing and turso may have written through a swapped-in
1074    // entry.
1075    if !entry_matches(dest_dir, dest_name, dest.fd())
1076        || !rustix::fs::statat(dest_dir, dest_name, AtFlags::SYMLINK_NOFOLLOW)
1077            .is_ok_and(|st| FileType::from_raw_mode(st.st_mode).is_file())
1078    {
1079        return Err(RekeyError::UnsafeDestination(format!(
1080            "destination '{dest_name}' was replaced during rekey"
1081        )));
1082    }
1083    for (sidecar, fd) in &dest.sidecars {
1084        match rustix::fs::statat(dest_dir, sidecar.as_str(), AtFlags::SYMLINK_NOFOLLOW) {
1085            // already removed (e.g. by the database layer on close): nothing
1086            // a substituted entry could have captured
1087            Err(rustix::io::Errno::NOENT) => {}
1088            Ok(entry) => {
1089                let created = rustix::fs::fstat(fd).map_err(|e| RekeyError::Io(e.into()))?;
1090                if entry.st_dev != created.st_dev || entry.st_ino != created.st_ino {
1091                    return Err(RekeyError::UnsafeDestination(format!(
1092                        "destination sidecar '{sidecar}' was replaced during rekey"
1093                    )));
1094                }
1095            }
1096            Err(e) => return Err(RekeyError::Io(e.into())),
1097        }
1098    }
1099
1100    // The WAL was checkpointed with TRUNCATE inside run_rekey, so the created
1101    // WAL inode must be empty (committed credentials all live in the main
1102    // file). fstat on our own descriptor, so a swapped entry cannot spoof it.
1103    let wal_name = format!("{dest_name}{WAL_SUFFIX}");
1104    if let Some((_, wal_fd)) = dest.sidecars.iter().find(|(name, _)| *name == wal_name) {
1105        let st = rustix::fs::fstat(wal_fd).map_err(|e| RekeyError::Io(e.into()))?;
1106        if st.st_size > 0 {
1107            return Err(RekeyError::Database(format!(
1108                "destination WAL '{wal_name}' still contains {} bytes after checkpoint",
1109                st.st_size
1110            )));
1111        }
1112    }
1113
1114    // Durability: sync file contents through the created descriptor, remove
1115    // the (empty) sidecar files we created, then sync the directory.
1116    rustix::fs::fsync(dest.fd()).map_err(|e| RekeyError::Io(e.into()))?;
1117    dest.unlink_created_sidecars();
1118    rustix::fs::fsync(dest_dir).map_err(|e| RekeyError::Io(e.into()))?;
1119
1120    let dest_fd = dest.commit();
1121    Ok((RekeyOutcome { copied }, dest_fd))
1122}
1123
1124/// Descriptor-relative implementation behind [`verify_at`]: pin and validate
1125/// both inodes, run the streaming comparison through `/proc/self/fd` paths,
1126/// clean up sidecars the open created, and re-check both directory entries.
1127#[cfg(target_os = "linux")]
1128fn verify_fds(
1129    source_dir: BorrowedFd<'_>,
1130    source_name: &str,
1131    source_opts: Option<&EncryptionOpts>,
1132    dest_dir: BorrowedFd<'_>,
1133    dest_name: &str,
1134    dest_opts: Option<&EncryptionOpts>,
1135) -> Result<u64, RekeyError> {
1136    let source_fd = open_existing_checked(source_dir, source_name, Side::Source)?;
1137    let dest_fd = open_existing_checked(dest_dir, dest_name, Side::Destination)?;
1138    let source_turso_path = pinned_turso_path(source_dir, source_name, None)?;
1139    let dest_turso_path = pinned_turso_path(dest_dir, dest_name, None)?;
1140
1141    let source_sidecars = snapshot_sidecars_at(source_dir, source_name);
1142    let dest_sidecars = snapshot_sidecars_at(dest_dir, dest_name);
1143    let result = run_verify(&source_turso_path, source_opts, &dest_turso_path, dest_opts);
1144    // hygiene runs on failure too: a failed verify must not leave behind
1145    // sidecars its own open created
1146    remove_created_empty_sidecars_at(source_dir, &source_sidecars);
1147    remove_created_empty_sidecars_at(dest_dir, &dest_sidecars);
1148    let verified = result?;
1149
1150    // Both entries must still be the inodes validated above; otherwise what
1151    // was compared is not what the caller named.
1152    if !entry_matches(source_dir, source_name, &source_fd) {
1153        return Err(RekeyError::SourceReplaced(source_name.to_string()));
1154    }
1155    if !entry_matches(dest_dir, dest_name, &dest_fd) {
1156        return Err(RekeyError::DestinationReplaced(dest_name.to_string()));
1157    }
1158    Ok(verified)
1159}
1160
1161/// Directory-relative analog of [`SidecarSnapshot::take`].
1162#[cfg(target_os = "linux")]
1163fn snapshot_sidecars_at(dir: BorrowedFd<'_>, name: &str) -> Vec<(String, bool)> {
1164    SIDECAR_SUFFIXES
1165        .iter()
1166        .map(|suffix| {
1167            let sidecar = format!("{name}{suffix}");
1168            let existed =
1169                rustix::fs::statat(dir, sidecar.as_str(), AtFlags::SYMLINK_NOFOLLOW).is_ok();
1170            (sidecar, existed)
1171        })
1172        .collect()
1173}
1174
1175/// Directory-relative analog of [`SidecarSnapshot::remove_created_empty`]:
1176/// best-effort unlink of sidecars our open created that are still empty
1177/// regular files.
1178#[cfg(target_os = "linux")]
1179fn remove_created_empty_sidecars_at(dir: BorrowedFd<'_>, snapshot: &[(String, bool)]) {
1180    for (name, existed) in snapshot {
1181        if *existed {
1182            continue;
1183        }
1184        if let Ok(st) = rustix::fs::statat(dir, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)
1185            && FileType::from_raw_mode(st.st_mode).is_file()
1186            && st.st_size == 0
1187        {
1188            let _ = rustix::fs::unlinkat(dir, name.as_str(), AtFlags::empty());
1189        }
1190    }
1191}
1192
1193/// Open a turso database, retrying transient locking errors, mapping failures
1194/// to typed errors attributed to `side`.
1195fn open_turso_db(
1196    path: &str,
1197    opts: Option<&EncryptionOpts>,
1198    side: Side,
1199) -> Result<Database, RekeyError> {
1200    let mut retries = crate::OPEN_LOCK_RETRIES;
1201    let mut backoff_ms = crate::OPEN_LOCK_BACKOFF_MS;
1202    loop {
1203        let mut builder = Builder::new_local(path);
1204        if let Some(opts) = opts {
1205            // key stays zeroizing on our side; see turso_encryption_opts for
1206            // the turso boundary note
1207            builder = builder
1208                .experimental_encryption(true)
1209                .with_encryption(crate::turso_encryption_opts(opts));
1210        }
1211        match block_on(builder.build()) {
1212            Ok(db) => return Ok(db),
1213            Err(err) => {
1214                if retries == 0 || !crate::is_turso_locking_error(&err) {
1215                    return Err(db_err(&err, side));
1216                }
1217                retries -= 1;
1218                std::thread::sleep(Duration::from_millis(backoff_ms));
1219                backoff_ms = (backoff_ms * 2).min(crate::OPEN_LOCK_BACKOFF_MAX_MS);
1220            }
1221        }
1222    }
1223}
1224
1225fn connect(db: &Database, side: Side) -> Result<Connection, RekeyError> {
1226    // retry transient locking errors (another process may briefly hold the
1227    // file lock), mirroring the store's own connect behavior
1228    let mut retries = crate::OPEN_LOCK_RETRIES;
1229    let mut backoff_ms = crate::OPEN_LOCK_BACKOFF_MS;
1230    let conn = loop {
1231        match db.connect() {
1232            Ok(conn) => break conn,
1233            Err(err) => {
1234                if retries == 0 || !crate::is_turso_locking_error(&err) {
1235                    return Err(db_err(&err, side));
1236                }
1237                retries -= 1;
1238                std::thread::sleep(Duration::from_millis(backoff_ms));
1239                backoff_ms = (backoff_ms * 2).min(crate::OPEN_LOCK_BACKOFF_MAX_MS);
1240            }
1241        }
1242    };
1243    conn.busy_timeout(Duration::from_millis(u64::from(crate::BUSY_TIMEOUT_MS)))
1244        .map_err(|e| db_err(&e, side))?;
1245    Ok(conn)
1246}
1247
1248/// The complete database-level rekey: open both databases, copy all records
1249/// streaming, verify every record exactly, checkpoint and close the
1250/// destination. Returns the verified record count.
1251fn run_rekey(
1252    source_path: &str,
1253    source_opts: Option<&EncryptionOpts>,
1254    dest_path: &str,
1255    dest_opts: Option<&EncryptionOpts>,
1256) -> Result<u64, RekeyError> {
1257    let source_db = open_turso_db(source_path, source_opts, Side::Source)?;
1258    let source_conn = connect(&source_db, Side::Source)?;
1259    // Note: the source connection gets no journal-mode pragma so an existing
1260    // source is never modified; only reads are performed against it.
1261    ensure_schema(&source_conn, Side::Source)?;
1262    let allow_ambiguity = !block_on(crate::schema_has_unique_service_user(&source_conn))
1263        .map_err(|e| db_err(&e, Side::Source))?;
1264
1265    let dest_db = open_turso_db(dest_path, dest_opts, Side::Destination)?;
1266    let dest_conn = connect(&dest_db, Side::Destination)?;
1267    crate::configure_connection(&dest_conn).map_err(|e| keyring_err(&e, Side::Destination))?;
1268    crate::init_schema(&dest_conn, allow_ambiguity, false)
1269        .map_err(|e| keyring_err(&e, Side::Destination))?;
1270
1271    let copied = copy_records(&source_conn, &dest_conn)?;
1272    let verified = verify_records(&source_conn, &dest_conn)?;
1273    if verified != copied {
1274        return Err(RekeyError::VerificationMismatch(format!(
1275            "copied {copied} records but verified {verified}"
1276        )));
1277    }
1278    checkpoint_truncate(&dest_conn)?;
1279    Ok(copied)
1280}
1281
1282/// The database must already be a db-keystore database with a supported
1283/// schema version; nothing is created or written in it (unlike opening it as
1284/// a store, which would initialize missing schema). Errors are attributed to
1285/// `side` ([`RekeyError::CorruptSource`] / [`RekeyError::CorruptDestination`]).
1286fn ensure_schema(conn: &Connection, side: Side) -> Result<(), RekeyError> {
1287    let what = match side {
1288        Side::Source => "source",
1289        Side::Destination => "destination",
1290    };
1291    let corrupt = |msg: String| match side {
1292        Side::Source => RekeyError::CorruptSource(msg),
1293        Side::Destination => RekeyError::CorruptDestination(msg),
1294    };
1295    block_on(async {
1296        let mut tables = std::collections::HashSet::new();
1297        let mut rows = conn
1298            .query(
1299                "SELECT name FROM sqlite_master WHERE type = 'table' \
1300                 AND name IN ('credentials', 'keystore_meta')",
1301                (),
1302            )
1303            .await
1304            .map_err(|e| db_err(&e, side))?;
1305        while let Some(row) = rows.next().await.map_err(|e| db_err(&e, side))? {
1306            let value = row.get_value(0).map_err(|e| db_err(&e, side))?;
1307            tables.insert(value_text(&value, "table name")?.to_string());
1308        }
1309        if !tables.contains("credentials") {
1310            return Err(corrupt(format!("no credentials table in {what} database")));
1311        }
1312        // Reject unsupported schema versions so an incompatible database
1313        // cannot be silently accepted. A missing keystore_meta table is
1314        // tolerated (nothing is ever written).
1315        if tables.contains("keystore_meta") {
1316            let mut rows = conn
1317                .query(
1318                    "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
1319                    (),
1320                )
1321                .await
1322                .map_err(|e| db_err(&e, side))?;
1323            if let Some(row) = rows.next().await.map_err(|e| db_err(&e, side))? {
1324                let value = row.get_value(0).map_err(|e| db_err(&e, side))?;
1325                let version = value_text(&value, "schema_version")?
1326                    .parse::<u32>()
1327                    .map_err(|_| corrupt(format!("invalid schema_version in {what}")))?;
1328                if version != crate::SCHEMA_VERSION {
1329                    return Err(corrupt(format!(
1330                        "unsupported {what} schema version: {version}"
1331                    )));
1332                }
1333            }
1334        }
1335        Ok(())
1336    })
1337}
1338
1339/// The complete database-level verification behind [`DbKeyStore::verify`]
1340/// and [`verify_at`]: open both databases, validate both schemas, and stream
1341/// the exact record comparison. Read-only on both sides: no pragma
1342/// configuration, no schema initialization, no writes.
1343fn run_verify(
1344    source_path: &str,
1345    source_opts: Option<&EncryptionOpts>,
1346    dest_path: &str,
1347    dest_opts: Option<&EncryptionOpts>,
1348) -> Result<u64, RekeyError> {
1349    let source_db = open_turso_db(source_path, source_opts, Side::Source)?;
1350    let source_conn = connect(&source_db, Side::Source)?;
1351    ensure_schema(&source_conn, Side::Source)?;
1352    let dest_db = open_turso_db(dest_path, dest_opts, Side::Destination)?;
1353    let dest_conn = connect(&dest_db, Side::Destination)?;
1354    ensure_schema(&dest_conn, Side::Destination)?;
1355    verify_records(&source_conn, &dest_conn)
1356}
1357
1358/// Stream every credential from source to destination, one record at a time,
1359/// inside a single destination transaction.
1360///
1361/// Every column is copied as the raw value read from the source with no
1362/// normalization, so the destination is a byte- and storage-class-exact copy
1363/// and the streaming verification (which orders and compares both sides
1364/// identically) cannot be tripped by a lossy rewrite. Values are validated
1365/// (types, lengths, uuid syntax) but never altered.
1366///
1367/// The secret travels inside the `turso::Value` produced by the row read and
1368/// consumed by the parameter bind; both allocations are owned by turso, which
1369/// frees without wiping (see the module docs on the turso boundary). No
1370/// additional copy of the secret is made here.
1371fn copy_records(source: &Connection, dest: &Connection) -> Result<u64, RekeyError> {
1372    block_on(async {
1373        let mut rows = source
1374            .query(
1375                "SELECT service, user, uuid, secret, comment FROM credentials",
1376                (),
1377            )
1378            .await
1379            .map_err(|e| db_err(&e, Side::Source))?;
1380        dest.execute("BEGIN IMMEDIATE", ())
1381            .await
1382            .map_err(|e| db_err(&e, Side::Destination))?;
1383        let mut copied = 0u64;
1384        let result = async {
1385            loop {
1386                let Some(row) = rows.next().await.map_err(|e| db_err(&e, Side::Source))? else {
1387                    break;
1388                };
1389                let mut values = Vec::with_capacity(5);
1390                for idx in 0..5 {
1391                    values.push(row.get_value(idx).map_err(|e| db_err(&e, Side::Source))?);
1392                }
1393
1394                // validate without altering: text-ness and lengths of the
1395                // identity columns, uuid syntax (any case), secret length,
1396                // comment type
1397                {
1398                    let service = value_text(&values[0], "service")?;
1399                    let user = value_text(&values[1], "user")?;
1400                    crate::validate_service_user(service, user)
1401                        .map_err(|e| RekeyError::CorruptSource(e.to_string()))?;
1402                    let uuid = value_text(&values[2], "uuid")?;
1403                    uuid::Uuid::try_parse(uuid).map_err(|_| {
1404                        RekeyError::CorruptSource(format!(
1405                            "invalid uuid for record {service}/{user}"
1406                        ))
1407                    })?;
1408                    let secret_len = match &values[3] {
1409                        Value::Blob(bytes) => bytes.len(),
1410                        Value::Text(text) => text.len(),
1411                        _ => {
1412                            return Err(RekeyError::CorruptSource(format!(
1413                                "unexpected secret type for record {service}/{user}/{uuid}"
1414                            )));
1415                        }
1416                    };
1417                    crate::validate_secret_len(secret_len)
1418                        .map_err(|e| RekeyError::CorruptSource(e.to_string()))?;
1419                    match &values[4] {
1420                        Value::Null | Value::Text(_) => {}
1421                        Value::Blob(bytes) if std::str::from_utf8(bytes).is_ok() => {}
1422                        _ => {
1423                            return Err(RekeyError::CorruptSource(format!(
1424                                "unexpected comment type for record {service}/{user}/{uuid}"
1425                            )));
1426                        }
1427                    }
1428                }
1429
1430                let mut values = values.into_iter();
1431                let params = (
1432                    values.next().expect("service value"),
1433                    values.next().expect("user value"),
1434                    values.next().expect("uuid value"),
1435                    values.next().expect("secret value"),
1436                    values.next().expect("comment value"),
1437                );
1438                dest.execute(
1439                    "INSERT INTO credentials (service, user, uuid, secret, comment) \
1440                     VALUES (?1, ?2, ?3, ?4, ?5)",
1441                    params,
1442                )
1443                .await
1444                .map_err(|e| db_err(&e, Side::Destination))?;
1445                copied += 1;
1446            }
1447            Ok(())
1448        }
1449        .await;
1450        match result {
1451            Ok(()) => {
1452                dest.execute("COMMIT", ())
1453                    .await
1454                    .map_err(|e| db_err(&e, Side::Destination))?;
1455                Ok(copied)
1456            }
1457            Err(err) => {
1458                let _ = dest.execute("ROLLBACK", ()).await;
1459                Err(err)
1460            }
1461        }
1462    })
1463}
1464
1465/// Borrow a value as text (TEXT, or BLOB holding valid UTF-8) without
1466/// converting or copying it.
1467fn value_text<'v>(value: &'v Value, field: &str) -> Result<&'v str, RekeyError> {
1468    match value {
1469        Value::Text(text) => Ok(text.as_str()),
1470        Value::Blob(bytes) => std::str::from_utf8(bytes)
1471            .map_err(|e| RekeyError::CorruptSource(format!("invalid utf8 for {field}: {e}"))),
1472        other => Err(RekeyError::CorruptSource(format!(
1473            "unexpected value for {field}: {}",
1474            value_type_name(other)
1475        ))),
1476    }
1477}
1478
1479/// Returns the type name only, because value content may be sensitive.
1480fn value_type_name(value: &Value) -> &'static str {
1481    match value {
1482        Value::Null => "NULL",
1483        Value::Integer(_) => "INTEGER",
1484        Value::Real(_) => "REAL",
1485        Value::Text(_) => "TEXT",
1486        Value::Blob(_) => "BLOB",
1487    }
1488}
1489
1490fn read_text(row: &turso::Row, idx: usize, field: &str, side: Side) -> Result<String, RekeyError> {
1491    let value = row.get_value(idx).map_err(|e| db_err(&e, side))?;
1492    value_text(&value, field).map(ToString::to_string)
1493}
1494
1495/// Deterministic total order over all record fields (including comment and
1496/// secret bytes) so that equal multisets, and only equal multisets, compare
1497/// equal record-by-record. Ordering by the secret as well is what lets two
1498/// records with identical metadata but different secrets be detected.
1499const VERIFY_SQL: &str = "SELECT service, user, uuid, comment, secret FROM credentials \
1500     ORDER BY service, user, uuid, comment, secret";
1501
1502/// Compare every source record against every destination record, streaming
1503/// one record from each side at a time (bounded memory). Returns the number
1504/// of records verified. Mismatch messages identify records by service, user,
1505/// and uuid; they never include secret content, and no digest of secrets is
1506/// computed or exposed.
1507fn verify_records(source: &Connection, dest: &Connection) -> Result<u64, RekeyError> {
1508    block_on(async {
1509        let mut source_rows = source
1510            .query(VERIFY_SQL, ())
1511            .await
1512            .map_err(|e| db_err(&e, Side::Source))?;
1513        let mut dest_rows = dest
1514            .query(VERIFY_SQL, ())
1515            .await
1516            .map_err(|e| db_err(&e, Side::Destination))?;
1517        let mut verified = 0u64;
1518        loop {
1519            let next_source = source_rows
1520                .next()
1521                .await
1522                .map_err(|e| db_err(&e, Side::Source))?;
1523            let next_dest = dest_rows
1524                .next()
1525                .await
1526                .map_err(|e| db_err(&e, Side::Destination))?;
1527            match (next_source, next_dest) {
1528                (None, None) => break,
1529                (Some(row), None) => {
1530                    let id = record_id(&row, Side::Source)?;
1531                    return Err(RekeyError::VerificationMismatch(format!(
1532                        "destination is missing record {id}"
1533                    )));
1534                }
1535                (None, Some(row)) => {
1536                    let id = record_id(&row, Side::Destination)?;
1537                    return Err(RekeyError::VerificationMismatch(format!(
1538                        "destination has unexpected extra record {id}"
1539                    )));
1540                }
1541                (Some(src_row), Some(dst_row)) => {
1542                    compare_row(&src_row, &dst_row)?;
1543                    verified += 1;
1544                }
1545            }
1546        }
1547        Ok(verified)
1548    })
1549}
1550
1551fn record_id(row: &turso::Row, side: Side) -> Result<String, RekeyError> {
1552    let service = read_text(row, 0, "service", side)?;
1553    let user = read_text(row, 1, "user", side)?;
1554    let uuid = read_text(row, 2, "uuid", side)?;
1555    Ok(format!("{service}/{user}/{uuid}"))
1556}
1557
1558/// Compare one source row against one destination row: every column must be
1559/// equal in both storage class and content (the copy is class- and byte-exact,
1560/// so any difference is a real divergence). Mismatch messages name the field
1561/// and the record's identity, never value content.
1562fn compare_row(src: &turso::Row, dst: &turso::Row) -> Result<(), RekeyError> {
1563    for (idx, field) in [
1564        (0, "service"),
1565        (1, "user"),
1566        (2, "uuid"),
1567        (3, "comment"),
1568        (4, "secret"),
1569    ] {
1570        let s = src.get_value(idx).map_err(|e| db_err(&e, Side::Source))?;
1571        let d = dst
1572            .get_value(idx)
1573            .map_err(|e| db_err(&e, Side::Destination))?;
1574        // Exact comparison; the result reveals only equal/not-equal, never
1575        // the content. The values here are turso-owned copies either way
1576        // (see the module docs on the turso boundary).
1577        if s != d {
1578            return Err(RekeyError::VerificationMismatch(format!(
1579                "{field} mismatch for record {}",
1580                record_id(src, Side::Source)?
1581            )));
1582        }
1583    }
1584    Ok(())
1585}
1586
1587/// Checkpoint the destination WAL with TRUNCATE so no committed credential is
1588/// stranded in the WAL, and the WAL file ends up empty.
1589fn checkpoint_truncate(conn: &Connection) -> Result<(), RekeyError> {
1590    block_on(async {
1591        let mut rows = conn
1592            .query("PRAGMA wal_checkpoint(TRUNCATE)", ())
1593            .await
1594            .map_err(|e| db_err(&e, Side::Destination))?;
1595        if let Some(row) = rows
1596            .next()
1597            .await
1598            .map_err(|e| db_err(&e, Side::Destination))?
1599        {
1600            let busy = row
1601                .get_value(0)
1602                .map_err(|e| db_err(&e, Side::Destination))?;
1603            if let Value::Integer(busy) = busy
1604                && busy != 0
1605            {
1606                return Err(RekeyError::Database(
1607                    "destination WAL checkpoint reported busy".to_string(),
1608                ));
1609            }
1610        }
1611        Ok(())
1612    })
1613}
1614
1615#[cfg(test)]
1616mod tests {
1617    use super::*;
1618    use crate::DbKeyStoreConfig;
1619    use keyring_core::api::CredentialStoreApi;
1620
1621    const HEXKEY_128: &str = "000102030405060708090a0b0c0d0e0f";
1622    const HEXKEY_256: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
1623
1624    #[test]
1625    fn sensitive_key_from_hex_round_trips() {
1626        let key = SensitiveKey::from_hex(HEXKEY_256).expect("from_hex");
1627        assert_eq!(key.len(), 32);
1628        assert!(!key.is_empty());
1629        assert_eq!(key.as_bytes()[0], 0x00);
1630        assert_eq!(key.as_bytes()[31], 0x1f);
1631        assert_eq!(key.to_hex().as_str(), HEXKEY_256);
1632
1633        let key = SensitiveKey::from_hex(HEXKEY_128).expect("from_hex 128");
1634        assert_eq!(key.len(), 16);
1635        assert_eq!(key.to_hex().as_str(), HEXKEY_128);
1636
1637        // uppercase accepted, re-encoded lowercase
1638        let key = SensitiveKey::from_hex(&HEXKEY_256.to_ascii_uppercase()).expect("upper");
1639        assert_eq!(key.to_hex().as_str(), HEXKEY_256);
1640    }
1641
1642    #[test]
1643    fn sensitive_key_rejects_bad_input() {
1644        assert!(matches!(
1645            SensitiveKey::from_hex("abcd"),
1646            Err(RekeyError::InvalidKey(_))
1647        ));
1648        let bad = "zz0102030405060708090a0b0c0d0e0f";
1649        assert!(matches!(
1650            SensitiveKey::from_hex(bad),
1651            Err(RekeyError::InvalidKey(_))
1652        ));
1653        assert!(matches!(
1654            SensitiveKey::from_bytes(&[0u8; 8]),
1655            Err(RekeyError::InvalidKey(_))
1656        ));
1657        assert!(SensitiveKey::from_bytes(&[7u8; 32]).is_ok());
1658        assert!(SensitiveKey::from_bytes(&[7u8; 16]).is_ok());
1659    }
1660
1661    // Acceptance 8/9 (crate side): no key material appears in Debug output of
1662    // any type that holds a key, and hex encoding stays in zeroizing owners.
1663    #[test]
1664    fn debug_output_redacts_keys() {
1665        let key = SensitiveKey::from_hex(HEXKEY_256).expect("key");
1666        let debug = format!("{key:?}");
1667        assert!(
1668            !debug.contains("0001"),
1669            "debug leaked key material: {debug}"
1670        );
1671        assert!(debug.contains("redacted"));
1672
1673        let opts = EncryptionOpts::new("aes256gcm", HEXKEY_256).expect("opts");
1674        let debug = format!("{opts:?}");
1675        assert!(
1676            !debug.contains("0001"),
1677            "debug leaked key material: {debug}"
1678        );
1679        assert!(debug.contains("redacted"));
1680        assert!(debug.contains("aes256gcm"));
1681
1682        // the hex encoding used at the turso boundary is itself zeroizing
1683        let hex: Zeroizing<String> = key.to_hex();
1684        assert_eq!(hex.len(), 64);
1685    }
1686
1687    #[test]
1688    fn encryption_opts_validates_key_length() {
1689        // 128-bit key for a 256-bit cipher must be rejected
1690        let err = EncryptionOpts::new("aes256gcm", HEXKEY_128).expect_err("length mismatch");
1691        assert!(err.to_string().contains("32"), "unexpected: {err}");
1692        // and the reverse
1693        assert!(EncryptionOpts::new("aes128gcm", HEXKEY_256).is_err());
1694        // dash alias accepted
1695        assert!(EncryptionOpts::new("aes-256-gcm", HEXKEY_256).is_ok());
1696        // empty cipher rejected
1697        assert!(EncryptionOpts::new("", HEXKEY_256).is_err());
1698    }
1699
1700    // Acceptance (feedback B4): the Panicked payload is length-bounded and
1701    // control-stripped at capture time, whatever the panicking code put in it.
1702    #[test]
1703    fn panicked_payload_is_bounded_and_redacted() {
1704        let payload = format!("boom\x1b[31m\n\0{}", "A".repeat(4096));
1705        let err = catch_panics::<()>(|| std::panic::panic_any(payload)).expect_err("must catch");
1706        let RekeyError::Panicked(msg) = err else {
1707            panic!("expected Panicked, got other variant");
1708        };
1709        assert!(
1710            msg.chars().count() <= PANIC_PAYLOAD_MAX_CHARS + "… (truncated)".chars().count(),
1711            "payload not bounded: {} chars",
1712            msg.chars().count()
1713        );
1714        assert!(
1715            msg.ends_with("… (truncated)"),
1716            "oversized payload must be marked truncated"
1717        );
1718        assert!(
1719            !msg.contains('\x1b') && !msg.contains('\n') && !msg.contains('\0'),
1720            "control characters must be stripped"
1721        );
1722
1723        // short, clean payloads pass through unmodified
1724        let err = catch_panics::<()>(|| panic!("plain message")).expect_err("must catch");
1725        let RekeyError::Panicked(msg) = err else {
1726            panic!("expected Panicked, got other variant");
1727        };
1728        assert_eq!(msg, "plain message");
1729    }
1730
1731    fn store_at(path: &std::path::Path) -> std::sync::Arc<DbKeyStore> {
1732        DbKeyStore::new(DbKeyStoreConfig {
1733            path: path.to_path_buf(),
1734            ..Default::default()
1735        })
1736        .expect("store")
1737    }
1738
1739    fn raw_conn(path: &std::path::Path) -> Connection {
1740        let db = block_on(Builder::new_local(path.to_str().expect("utf8")).build()).expect("db");
1741        db.connect().expect("conn")
1742    }
1743
1744    fn connections(
1745        src: &std::path::Path,
1746        dst: &std::path::Path,
1747    ) -> (Database, Connection, Database, Connection) {
1748        let sdb = open_turso_db(src.to_str().unwrap(), None, Side::Source).expect("src db");
1749        let sconn = connect(&sdb, Side::Source).expect("src conn");
1750        let ddb = open_turso_db(dst.to_str().unwrap(), None, Side::Destination).expect("dst db");
1751        let dconn = connect(&ddb, Side::Destination).expect("dst conn");
1752        (sdb, sconn, ddb, dconn)
1753    }
1754
1755    // Acceptance 1: corrupting one credential secret in the destination
1756    // (leaving the row count unchanged) makes verification fail.
1757    #[test]
1758    fn verification_detects_corrupted_secret() {
1759        let dir = tempfile::tempdir().expect("tempdir");
1760        let src = dir.path().join("src.db");
1761        let dst = dir.path().join("dst.db");
1762        {
1763            let store = store_at(&src);
1764            for (user, pw) in [("alice", "pw-a"), ("bob", "pw-b")] {
1765                let entry = store.build("svc", user, None).expect("build");
1766                entry.set_password(pw).expect("set");
1767            }
1768        }
1769        DbKeyStore::rekey(&src, None, &dst, None).expect("rekey");
1770
1771        // verification passes on the honest copy
1772        {
1773            let (_sdb, sconn, _ddb, dconn) = connections(&src, &dst);
1774            assert_eq!(verify_records(&sconn, &dconn).expect("verify"), 2);
1775        }
1776
1777        // corrupt one destination secret without changing the row count
1778        {
1779            let conn = raw_conn(&dst);
1780            let changed = block_on(conn.execute(
1781                "UPDATE credentials SET secret = X'DEADBEEF' WHERE user = 'bob'",
1782                (),
1783            ))
1784            .expect("corrupt");
1785            assert_eq!(changed, 1);
1786        }
1787
1788        let (_sdb, sconn, _ddb, dconn) = connections(&src, &dst);
1789        let err = verify_records(&sconn, &dconn).expect_err("must detect corruption");
1790        assert!(
1791            matches!(err, RekeyError::VerificationMismatch(_)),
1792            "unexpected error: {err:?}"
1793        );
1794        let msg = err.to_string();
1795        assert!(
1796            !msg.contains("pw-b")
1797                && !msg.contains("DEADBEEF")
1798                && !msg.to_lowercase().contains("deadbeef"),
1799            "error message must not contain secret material: {msg}"
1800        );
1801    }
1802
1803    // Acceptance 2: two records with identical metadata but different secrets
1804    // are detected (a count- or metadata-only comparison would miss this).
1805    #[test]
1806    fn verification_detects_identical_metadata_different_secrets() {
1807        let dir = tempfile::tempdir().expect("tempdir");
1808        let src = dir.path().join("src.db");
1809        let dst = dir.path().join("dst.db");
1810
1811        // hand-build both databases with two rows of identical metadata;
1812        // source secrets {A, B}, destination secrets {A, A}
1813        for (path, second_secret) in [(&src, "B"), (&dst, "A")] {
1814            let conn = raw_conn(path);
1815            block_on(conn.execute(
1816                "CREATE TABLE credentials (service TEXT NOT NULL, user TEXT NOT NULL, \
1817                 uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
1818                (),
1819            ))
1820            .expect("create");
1821            for secret in ["A", second_secret] {
1822                block_on(conn.execute(
1823                    "INSERT INTO credentials (service, user, uuid, secret) \
1824                     VALUES ('svc', 'alice', '018f0000-0000-7000-8000-000000000001', ?1)",
1825                    (Value::Blob(secret.as_bytes().to_vec()),),
1826                ))
1827                .expect("insert");
1828            }
1829        }
1830
1831        let (_sdb, sconn, _ddb, dconn) = connections(&src, &dst);
1832        let err = verify_records(&sconn, &dconn).expect_err("must detect differing secrets");
1833        assert!(
1834            matches!(err, RekeyError::VerificationMismatch(_)),
1835            "unexpected error: {err:?}"
1836        );
1837    }
1838
1839    // Verification detects missing and extra destination records even when
1840    // metadata-identical rows make the count ambiguous.
1841    #[test]
1842    fn verification_detects_missing_and_extra_records() {
1843        let dir = tempfile::tempdir().expect("tempdir");
1844        let src = dir.path().join("src.db");
1845        let dst = dir.path().join("dst.db");
1846        {
1847            let store = store_at(&src);
1848            for (user, pw) in [("alice", "pw-a"), ("bob", "pw-b")] {
1849                let entry = store.build("svc", user, None).expect("build");
1850                entry.set_password(pw).expect("set");
1851            }
1852        }
1853        DbKeyStore::rekey(&src, None, &dst, None).expect("rekey");
1854        {
1855            let conn = raw_conn(&dst);
1856            block_on(conn.execute("DELETE FROM credentials WHERE user = 'bob'", ()))
1857                .expect("delete");
1858        }
1859        let (_sdb, sconn, _ddb, dconn) = connections(&src, &dst);
1860        let err = verify_records(&sconn, &dconn).expect_err("must detect missing record");
1861        assert!(matches!(err, RekeyError::VerificationMismatch(_)));
1862
1863        // extra record in the destination
1864        {
1865            let conn = raw_conn(&dst);
1866            for user in ["bob", "eve"] {
1867                block_on(conn.execute(
1868                    &format!(
1869                        "INSERT INTO credentials (service, user, uuid, secret) \
1870                         VALUES ('svc', '{user}', '018f0000-0000-7000-8000-0000000000aa', X'00')"
1871                    ),
1872                    (),
1873                ))
1874                .expect("insert");
1875            }
1876        }
1877        let (_sdb, sconn, _ddb, dconn) = connections(&src, &dst);
1878        let err = verify_records(&sconn, &dconn).expect_err("must detect extra record");
1879        assert!(matches!(err, RekeyError::VerificationMismatch(_)));
1880    }
1881
1882    // The destination inode check: if the directory entry is swapped after
1883    // creation, the mismatch is detected (UnsafeDestination).
1884    #[cfg(unix)]
1885    #[test]
1886    fn destination_inode_swap_is_detected() {
1887        let dir = tempfile::tempdir().expect("tempdir");
1888        let dir_fd = open_dir(dir.path()).expect("dir fd");
1889        let guard = create_destination(dir_fd.as_fd(), "dst.db").expect("create");
1890
1891        // swap the directory entry for a different file
1892        std::fs::remove_file(dir.path().join("dst.db")).expect("remove");
1893        std::fs::write(dir.path().join("dst.db"), b"substitute").expect("substitute");
1894
1895        let entry = rustix::fs::statat(dir_fd.as_fd(), "dst.db", AtFlags::SYMLINK_NOFOLLOW)
1896            .expect("statat");
1897        let created = rustix::fs::fstat(guard.fd()).expect("fstat");
1898        assert!(
1899            entry.st_ino != created.st_ino,
1900            "test setup: entry should now be a different inode"
1901        );
1902    }
1903
1904    // Destination files start with mode 0600 regardless of umask (fchmod pins it).
1905    #[cfg(unix)]
1906    #[test]
1907    fn destination_created_mode_0600() {
1908        use std::os::unix::fs::MetadataExt;
1909        let dir = tempfile::tempdir().expect("tempdir");
1910        let dir_fd = open_dir(dir.path()).expect("dir fd");
1911        let _guard = create_destination(dir_fd.as_fd(), "dst.db").expect("create");
1912        for name in ["dst.db", "dst.db-wal", "dst.db-tshm"] {
1913            let mode = dir.path().join(name).metadata().expect("meta").mode() & 0o7777;
1914            assert_eq!(mode, 0o600, "{name} must be created 0600, got {mode:o}");
1915        }
1916    }
1917}