1use 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
93const SIDECAR_SUFFIXES: [&str; 2] = ["-wal", "-tshm"];
102
103const WAL_SUFFIX: &str = "-wal";
106
107#[derive(Debug, Clone, Copy, Eq, PartialEq)]
115pub struct RekeyOutcome {
116 pub copied: u64,
118}
119
120#[derive(Debug)]
127#[non_exhaustive]
128pub enum RekeyError {
129 WrongSourceKey,
131 WrongDestinationKey,
136 SourceReplaced(String),
139 SourceNotFound(String),
141 CorruptSource(String),
146 VerificationMismatch(String),
148 DestinationExists(String),
150 DestinationNotFound(String),
154 CorruptDestination(String),
158 DestinationReplaced(String),
163 UnsafeDestination(String),
166 InvalidKey(String),
168 Io(std::io::Error),
170 Database(String),
172 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#[derive(Clone)]
252pub struct SensitiveKey {
253 bytes: Zeroizing<[u8; 32]>,
254 len: usize,
255}
256
257impl SensitiveKey {
258 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 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 pub fn as_bytes(&self) -> &[u8] {
294 &self.bytes[..self.len]
295 }
296
297 pub fn len(&self) -> usize {
299 self.len
300 }
301
302 pub fn is_empty(&self) -> bool {
304 false
305 }
306
307 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339enum Side {
340 Source,
341 Destination,
342}
343
344fn 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 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 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#[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#[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
566fn 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
583const PANIC_PAYLOAD_MAX_CHARS: usize = 256;
585
586fn 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#[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 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 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#[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 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 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 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 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
731struct SidecarSnapshot {
737 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 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
773fn 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 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#[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#[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#[cfg(unix)]
888struct DestGuard<'a> {
889 dir: BorrowedFd<'a>,
890 name: &'a str,
891 file: Option<OwnedFd>,
893 sidecars: Vec<(String, OwnedFd)>,
895 committed: bool,
896}
897
898#[cfg(unix)]
899impl DestGuard<'_> {
900 fn fd(&self) -> &OwnedFd {
902 self.file
903 .as_ref()
904 .expect("destination fd present until commit")
905 }
906
907 fn commit(mut self) -> OwnedFd {
910 self.committed = true;
911 self.file
912 .take()
913 .expect("destination fd present until commit")
914 }
915
916 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#[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#[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; 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 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 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#[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#[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 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 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 if !entry_matches(source_dir, source_name, &source_fd) {
1068 return Err(RekeyError::SourceReplaced(source_name.to_string()));
1069 }
1070
1071 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 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 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 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#[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 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 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#[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#[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
1193fn 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 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 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
1248fn 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 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
1282fn 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 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
1339fn 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
1358fn 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 {
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
1465fn 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
1479fn 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
1495const VERIFY_SQL: &str = "SELECT service, user, uuid, comment, secret FROM credentials \
1500 ORDER BY service, user, uuid, comment, secret";
1501
1502fn 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
1558fn 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 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
1587fn 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 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 #[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 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 let err = EncryptionOpts::new("aes256gcm", HEXKEY_128).expect_err("length mismatch");
1691 assert!(err.to_string().contains("32"), "unexpected: {err}");
1692 assert!(EncryptionOpts::new("aes128gcm", HEXKEY_256).is_err());
1694 assert!(EncryptionOpts::new("aes-256-gcm", HEXKEY_256).is_ok());
1696 assert!(EncryptionOpts::new("", HEXKEY_256).is_err());
1698 }
1699
1700 #[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 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 #[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 {
1773 let (_sdb, sconn, _ddb, dconn) = connections(&src, &dst);
1774 assert_eq!(verify_records(&sconn, &dconn).expect("verify"), 2);
1775 }
1776
1777 {
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 #[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 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 #[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 {
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 #[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 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 #[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}