fix(backup): verify scheduled export destinations before writing

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-17 12:41:26 +08:00
1 parent a8f966dcfd
commit 4ce27f8ae5
12 files changed
+652 -7

No files matched your search

+3
View File
@@ -85,5 +85,8 @@ tempfile = "3"
[target.'cfg(not(all(windows, target_vendor = "win7")))'.dependencies]
iana-time-zone = "0.1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Security"] }
[dev-dependencies]
mysql_common = { git = "https://github.com/t8y2/rust_mysql_common.git", rev = "47740d85a8277167a5717afac785d37b46a36296", default-features = false }
+376 -6
View File
@@ -1630,6 +1630,195 @@ fn emit_database_export_running(
});
}
fn export_destination_state_key(dir: &std::path::Path) -> String {
format!("database_export_destination:{}", dir.to_string_lossy())
}
/// Records the destination identity for a scheduled backup as soon as it is
/// configured, not just after its first successful export. Scheduled plans
/// live in the frontend and may not run for hours after being saved; without
/// an eager record here, a destination whose mount disappears before its
/// very first run is indistinguishable from a brand-new local folder to
/// `ensure_export_destination_dir` (both have no recorded state) and gets
/// silently recreated on the wrong filesystem. The schedule editor only
/// accepts directories selected from the filesystem, so a missing path here
/// means the destination vanished before its identity could be recorded and
/// the schedule must not be saved. See #6327.
pub async fn record_export_destination_identity(
state: &crate::connection::AppState,
dir: &std::path::Path,
) -> Result<(), String> {
if !dir.is_dir() {
return Err(format!(
"Backup directory {} does not exist or is not a directory. Select an existing destination before saving the schedule.",
dir.display()
));
}
save_export_destination_dir_device_id(state, dir).await
}
async fn save_export_destination_dir_device_id(
state: &crate::connection::AppState,
dir: &std::path::Path,
) -> Result<(), String> {
let value = match export_destination_device_id_for_path(dir) {
Some(dev) => dev.to_le_bytes().to_vec(),
None => Vec::new(),
};
state.storage.save_state(&export_destination_state_key(dir), &value, "application/octet-stream").await
}
/// Ensures `dir` exists for an export destination, without ever silently
/// recreating a directory that previously produced a successful export (or
/// was recorded via [`record_export_destination_identity`]) and has since
/// disappeared. Auto-creating on every run is what the original fix for
/// #6109 did, but that is unsafe for a destination on a removable or network
/// drive: if the mount is temporarily gone when a run executes, blindly
/// recreating the path resurrects it on the local root filesystem and the
/// export "succeeds" while silently writing to the wrong disk. A directory
/// dbx has never seen before is safe to create (normal first-time
/// configuration of a local folder); a directory dbx has seen before but
/// that is now missing, or that now resolves to a different filesystem than
/// last time, is refused instead. See #6327.
///
/// Returns the device identity that was just verified (or recorded for a
/// newly created directory), if the current platform can determine one, so
/// the caller can re-verify it against the file it actually opens -- the
/// directory check here and the later `File::create` are separate
/// operations, and the mount can change in between.
async fn ensure_export_destination_dir(
state: &crate::connection::AppState,
dir: &std::path::Path,
) -> Result<Option<u64>, String> {
let key = export_destination_state_key(dir);
let recorded_dev: Option<Option<u64>> = state
.storage
.load_state(&key)
.await?
.map(|(bytes, _content_type)| <[u8; 8]>::try_from(bytes.as_slice()).ok().map(u64::from_le_bytes));
if dir.is_dir() {
if let Some(Some(recorded_dev)) = recorded_dev {
if let Some(current_dev) = export_destination_device_id_for_path(dir) {
if current_dev != recorded_dev {
return Err(format!(
"Backup directory {} now resolves to a different filesystem than the last \
successful export to this location. Refusing to write here automatically -- \
if this directory is on a removable or network drive, make sure the correct \
drive is connected before running the backup.",
dir.display()
));
}
}
}
} else {
if recorded_dev.is_some() {
return Err(format!(
"Backup directory {} is missing. It was configured or previously used for exports to \
this location, so dbx will not recreate it automatically -- if this is on a removable \
or network drive, reconnect it and try again.",
dir.display()
));
}
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup directory: {e}"))?;
}
save_export_destination_dir_device_id(state, dir).await?;
Ok(export_destination_device_id_for_path(dir))
}
/// Whether a destination's identity, checked once via [`ensure_export_destination_dir`]
/// and then again against the file dbx actually opened, indicates the mount
/// changed in between. An unknown expected identity has nothing to compare,
/// but once an expected identity is known, failing to identify the opened
/// handle must fail closed rather than allowing an unverified write.
fn export_destination_identity_mismatch(expected: Option<u64>, actual: Option<u64>) -> bool {
expected.is_some_and(|expected| actual != Some(expected))
}
#[cfg(unix)]
fn export_destination_device_id_for_path(dir: &std::path::Path) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(dir).ok().map(|metadata| metadata.dev())
}
#[cfg(unix)]
fn export_destination_device_id_for_file(file: &std::fs::File) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
file.metadata().ok().map(|metadata| metadata.dev())
}
#[cfg(windows)]
fn export_destination_device_id_for_path(dir: &std::path::Path) -> Option<u64> {
windows_export_destination::device_id_for_path(dir)
}
#[cfg(windows)]
fn export_destination_device_id_for_file(file: &std::fs::File) -> Option<u64> {
windows_export_destination::device_id_for_handle(file)
}
#[cfg(not(any(unix, windows)))]
fn export_destination_device_id_for_path(_dir: &std::path::Path) -> Option<u64> {
None
}
#[cfg(not(any(unix, windows)))]
fn export_destination_device_id_for_file(_file: &std::fs::File) -> Option<u64> {
None
}
/// Windows has no stable `std` API for a directory or file's volume identity
/// (`MetadataExt::volume_serial_number` is still gated behind the unstable
/// `windows_by_handle` feature), so this queries `dwVolumeSerialNumber` from
/// `BY_HANDLE_FILE_INFORMATION` directly. Using the *handle* rather than
/// re-resolving the path is what makes `export_destination_device_id_for_file`
/// safe to call on an already-open `File`: it reports the volume the handle
/// was actually opened against, not whatever currently sits at that path.
#[cfg(windows)]
mod windows_export_destination {
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::AsRawHandle;
use std::path::Path;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS,
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
pub(super) fn device_id_for_path(path: &Path) -> Option<u64> {
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
wide.push(0);
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return None;
}
let result = volume_serial_number(handle);
unsafe { CloseHandle(handle) };
result
}
pub(super) fn device_id_for_handle(file: &std::fs::File) -> Option<u64> {
volume_serial_number(file.as_raw_handle() as HANDLE)
}
fn volume_serial_number(handle: HANDLE) -> Option<u64> {
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(handle, &mut info) };
(ok != 0).then_some(info.dwVolumeSerialNumber as u64)
}
}
pub async fn export_database_sql_core(
state: &crate::connection::AppState,
request: &DatabaseExportRequest,
@@ -1673,7 +1862,28 @@ pub async fn export_database_sql_core(
)
.await?;
// 4. Create file
let mut expected_destination_dev = None;
if let Some(parent) = std::path::Path::new(&request.file_path).parent() {
if !parent.as_os_str().is_empty() {
expected_destination_dev = ensure_export_destination_dir(state, parent).await?;
}
}
let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?;
// The directory check above and this `File::create` are separate
// operations: the mount can disappear and be replaced by something else
// at the same path in between. Re-check the identity of the handle we
// actually opened, not just the path, and refuse to keep a backup that
// landed on the wrong filesystem. See #6327.
if export_destination_identity_mismatch(expected_destination_dev, export_destination_device_id_for_file(&file)) {
drop(file);
let _ = std::fs::remove_file(&request.file_path);
return Err(format!(
"Backup destination for {} changed while opening the output file -- the directory now \
resolves to a different filesystem than the one just verified. If a removable or network \
drive was disconnected and reconnected, retry the backup.",
request.file_path
));
}
let mut file = BufWriter::new(file);
let create_database_preamble = if request.include_create_database && matches!(db_type, DatabaseType::Mysql) {
@@ -2519,19 +2729,22 @@ fn build_database_export_object_source_sql(
mod tests {
use super::{
build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements,
database_export_select_sql, database_export_total_objects, drop_table_if_exists_sql, filter_export_table_infos,
database_export_select_sql, database_export_total_objects, drop_table_if_exists_sql,
ensure_export_destination_dir, export_destination_identity_mismatch, filter_export_table_infos,
format_export_sql_literal, format_export_table_ddl, format_mysql_spatial_export_literal,
generate_postgres_extension_ddl, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
generate_postgres_sequence_setval_sql, is_postgres_extension_member_routine, mysql_database_export_preamble,
mysql_view_dependencies_from_rows, mysql_view_dependencies_sql, normalize_export_table_ddl,
record_export_error, replace_database_export_select_list, sort_export_views_by_dependencies,
write_database_export_rows, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions,
DatabaseExportObjectCounts, DatabaseExportRequest, DdlNormalizeOptions, ExportedTableSql,
PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, DATABASE_EXPORT_INSERT_BATCH_SIZE,
DATABASE_EXPORT_ROW_LIMIT,
record_export_destination_identity, record_export_error, replace_database_export_select_list,
sort_export_views_by_dependencies, write_database_export_rows, BuildDatabaseSqlExportOptions,
BuildExportInsertStatementsOptions, DatabaseExportObjectCounts, DatabaseExportRequest, DdlNormalizeOptions,
ExportedTableSql, PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers,
DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
};
use super::{concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency};
use crate::connection::AppState;
use crate::models::connection::DatabaseType;
use crate::storage::Storage;
use crate::types::{ObjectInfo, ObjectSourceKind, TableInfo};
use serde_json::{json, Value};
@@ -3926,4 +4139,161 @@ mod tests {
assert!(std::fs::read_to_string(&path).unwrap().is_empty());
let _ = std::fs::remove_file(path);
}
async fn test_app_state(scratch_dir: &std::path::Path) -> AppState {
let storage = Storage::open(&scratch_dir.join("storage.db")).await.unwrap();
AppState::new(storage)
}
// Regression tests for #6327: create_dir_all on every export run is unsafe
// when the destination is on a removable/network drive, because a mount
// that is temporarily gone at write time would be silently recreated on
// the local root filesystem, and the backup would "succeed" while writing
// to the wrong disk.
#[tokio::test]
async fn ensure_export_destination_dir_creates_a_never_before_seen_local_directory() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-new-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let state = test_app_state(&scratch).await;
let destination = scratch.join("backups").join("mydb");
assert!(!destination.exists());
ensure_export_destination_dir(&state, &destination)
.await
.expect("first-time local directory should be created");
assert!(destination.is_dir());
let _ = std::fs::remove_dir_all(&scratch);
}
#[tokio::test]
async fn ensure_export_destination_dir_refuses_to_recreate_a_destination_that_disappeared() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-vanished-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let state = test_app_state(&scratch).await;
// Simulates a previously configured, already-used backup destination
// (e.g. on an external or network drive) by successfully exporting to
// it once first.
let destination = scratch.join("mounted-drive").join("mydb");
ensure_export_destination_dir(&state, &destination).await.expect("initial export should create the directory");
assert!(destination.is_dir());
// Simulates the mount disappearing (unplugged drive, unmounted share,
// etc.) before the next run.
std::fs::remove_dir_all(scratch.join("mounted-drive")).unwrap();
assert!(!destination.exists());
let result = ensure_export_destination_dir(&state, &destination).await;
assert!(result.is_err(), "a destination that existed before should not be silently recreated");
assert!(!destination.exists(), "the backup directory must not be resurrected on the wrong filesystem");
let _ = std::fs::remove_dir_all(&scratch);
}
// Regression test for review feedback on #6327: `ensure_export_destination_dir`
// only remembered a destination *after* a successful export, so a mount
// that was present when a schedule was configured but vanished before its
// very first run looked identical to a brand-new local folder (no
// recorded state either way) and got silently recreated on the local
// disk. `record_export_destination_identity` closes that gap by letting
// the schedule-configuration flow record the destination eagerly.
#[tokio::test]
async fn record_export_destination_identity_protects_a_mount_that_vanishes_before_its_first_run() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let state = test_app_state(&scratch).await;
// Simulates the user picking an already-mounted external/network
// drive in the schedule editor and saving the schedule -- the
// directory exists at configuration time, but no export has run yet.
let destination = scratch.join("mounted-drive").join("mydb");
std::fs::create_dir_all(&destination).unwrap();
record_export_destination_identity(&state, &destination)
.await
.expect("configuring the schedule should succeed");
// The mount disappears before the scheduler ever runs this schedule
// for the first time.
std::fs::remove_dir_all(scratch.join("mounted-drive")).unwrap();
assert!(!destination.exists());
let result = ensure_export_destination_dir(&state, &destination).await;
assert!(result.is_err(), "a mount that was recorded at configuration time must not be silently recreated");
assert!(!destination.exists(), "the backup directory must not be resurrected on the wrong filesystem");
let _ = std::fs::remove_dir_all(&scratch);
}
#[tokio::test]
async fn record_export_destination_identity_rejects_a_directory_that_disappeared_before_save() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-new-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let state = test_app_state(&scratch).await;
// The schedule editor only accepts an existing directory. If that
// directory disappears before the save request reaches the backend,
// the schedule must not be persisted without a recorded identity.
let destination = scratch.join("backups").join("mydb");
assert!(!destination.exists());
let error = record_export_destination_identity(&state, &destination)
.await
.expect_err("a missing scheduled destination must be rejected");
assert!(error.contains("does not exist or is not a directory"));
assert!(!destination.exists());
let _ = std::fs::remove_dir_all(&scratch);
}
// Regression test for review feedback on #6327: the directory identity
// check and the later `File::create` are separate operations, so the
// mount can disappear and be replaced by something else at the same path
// in between. `export_destination_identity_mismatch` is the comparison
// `export_database_sql_core` runs against the handle it actually opened;
// exercised directly here since reproducing a real cross-filesystem swap
// mid-write is not something a portable unit test can simulate.
#[test]
fn export_destination_identity_mismatch_detects_a_changed_device() {
assert!(export_destination_identity_mismatch(Some(1), Some(2)));
assert!(!export_destination_identity_mismatch(Some(1), Some(1)));
assert!(
!export_destination_identity_mismatch(None, Some(2)),
"an unknown expected device has nothing to compare against"
);
assert!(
export_destination_identity_mismatch(Some(1), None),
"an opened file with unknown identity must not bypass a known expected device"
);
assert!(!export_destination_identity_mismatch(None, None));
}
// Regression test for review feedback on #6327: the non-Unix path used
// to report no device identity at all, so replacing a Windows drive or
// mount at the same path went undetected. This only runs on native
// Windows (this repo's CI has no Windows job that executes `cargo test`,
// only `cargo check`, so it is exercised locally by Windows contributors
// and by the compile-check itself).
#[cfg(windows)]
#[test]
fn export_destination_device_id_for_path_and_open_file_agree_on_windows() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-win-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let dir_dev = super::export_destination_device_id_for_path(&scratch);
assert!(dir_dev.is_some(), "a real local directory should report a volume serial number");
let file_path = scratch.join("probe.txt");
let file = std::fs::File::create(&file_path).unwrap();
let file_dev = super::export_destination_device_id_for_file(&file);
drop(file);
assert_eq!(dir_dev, file_dev, "a file and its parent directory must resolve to the same volume");
let _ = std::fs::remove_dir_all(&scratch);
}
}
@@ -1,5 +1,5 @@
use dbx_core::connection::AppState;
use dbx_core::database_export::{export_database_sql_core, DatabaseExportRequest};
use dbx_core::database_export::{export_database_sql_core, record_export_destination_identity, DatabaseExportRequest};
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::query::execute_sql_statement;
use dbx_core::sql::SqlFileRequest;
@@ -133,3 +133,229 @@ async fn live_mysql_database_export_restores_dependent_views() {
assert_eq!(result.rows, vec![vec![serde_json::json!("7")]]);
assert_eq!(spatial_result.rows, vec![vec![serde_json::json!(4326), serde_json::json!(1)]]);
}
/// Regression test for #6109 ("backup always errors"): the very first export
/// to a destination directory that has never been configured or used before
/// (a normal, not-yet-created local folder -- e.g. the user just typed a new
/// path into the schedule editor and saved it) must create it rather than
/// failing on every run with a raw std::fs::File::create OS error.
///
/// This must NOT be confused with a destination directory that previously
/// existed -- either because it produced a successful export before, or
/// because it was recorded at configuration time via
/// `record_export_destination_identity` -- and has since disappeared (e.g.
/// an unmounted external/network drive). See
/// `live_mysql_database_export_refuses_to_recreate_a_destination_that_disappeared`
/// and `live_mysql_database_export_refuses_a_destination_that_vanished_before_its_first_run`
/// below, and the #6327 discussion, for why those cases are refused instead.
#[tokio::test]
#[ignore = "requires a disposable MySQL endpoint"]
async fn live_mysql_database_export_creates_missing_destination_directory() {
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("live-mysql-export-missing-dir-{suffix}");
let database = format!("dbx_export_missing_dir_{suffix}");
let dir = std::env::temp_dir().join(format!("dbx-live-mysql-export-missing-dir-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
state.configs.write().await.insert(connection_id.clone(), live_mysql_config(&connection_id));
for sql in [
format!("DROP DATABASE IF EXISTS `{database}`"),
format!("CREATE DATABASE `{database}`"),
format!("CREATE TABLE `{database}`.`widgets` (id INT PRIMARY KEY, name VARCHAR(50))"),
format!("INSERT INTO `{database}`.`widgets` VALUES (1, 'alpha')"),
] {
execute_sql_statement(&state, &connection_id, "", &sql, None, None).await.unwrap();
}
// Destination directory deliberately never created and never recorded --
// simulates a brand-new local folder the user just typed into the
// schedule editor, not a mount that used to exist.
let missing_destination_dir = dir.join("brand-new-local-folder");
let file_path = missing_destination_dir.join("export.sql");
let export_request = DatabaseExportRequest {
export_id: format!("live-mysql-export-missing-dir-{suffix}"),
connection_id: connection_id.clone(),
database: database.clone(),
schema: database.clone(),
file_path: file_path.to_string_lossy().to_string(),
selected_tables: Vec::new(),
excluded_tables: Vec::new(),
include_structure: true,
include_data: true,
include_objects: true,
include_create_database: true,
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
snapshot_session_id: None,
batch_size: 1000,
};
let result = export_database_sql_core(&state, &export_request, |_| {}).await;
execute_sql_statement(&state, &connection_id, "", &format!("DROP DATABASE `{database}`"), None, None)
.await
.unwrap();
result.expect("export should succeed even when the destination directory is missing");
assert!(missing_destination_dir.exists(), "destination directory should have been auto-created");
let exported = std::fs::read_to_string(&file_path).unwrap();
assert!(exported.contains("'alpha'"), "exported SQL should contain the seeded row");
std::fs::remove_dir_all(dir).unwrap();
}
/// Regression test for the #6327 review of the #6109 fix: once a destination
/// directory has produced a successful export, its later disappearance (e.g.
/// an external/network drive that got unmounted between scheduled runs) must
/// make the next export fail with a clear error instead of silently
/// recreating the directory -- which, for a vanished mount, would resurrect
/// it on the local root filesystem and write the backup to the wrong disk.
#[tokio::test]
#[ignore = "requires a disposable MySQL endpoint"]
async fn live_mysql_database_export_refuses_to_recreate_a_destination_that_disappeared() {
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("live-mysql-export-vanished-dir-{suffix}");
let database = format!("dbx_export_vanished_dir_{suffix}");
let dir = std::env::temp_dir().join(format!("dbx-live-mysql-export-vanished-dir-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
state.configs.write().await.insert(connection_id.clone(), live_mysql_config(&connection_id));
for sql in [
format!("DROP DATABASE IF EXISTS `{database}`"),
format!("CREATE DATABASE `{database}`"),
format!("CREATE TABLE `{database}`.`widgets` (id INT PRIMARY KEY, name VARCHAR(50))"),
format!("INSERT INTO `{database}`.`widgets` VALUES (1, 'alpha')"),
] {
execute_sql_statement(&state, &connection_id, "", &sql, None, None).await.unwrap();
}
// Simulates a backup destination on an external/network drive that is
// currently connected and gets used successfully once.
let destination_dir = dir.join("mounted-drive-destination");
let file_path = destination_dir.join("export.sql");
let export_request = DatabaseExportRequest {
export_id: format!("live-mysql-export-vanished-dir-{suffix}"),
connection_id: connection_id.clone(),
database: database.clone(),
schema: database.clone(),
file_path: file_path.to_string_lossy().to_string(),
selected_tables: Vec::new(),
excluded_tables: Vec::new(),
include_structure: true,
include_data: true,
include_objects: true,
include_create_database: true,
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
snapshot_session_id: None,
batch_size: 1000,
};
export_database_sql_core(&state, &export_request, |_| {}).await.expect("first export should succeed");
assert!(destination_dir.exists());
// Simulates the drive being disconnected/unmounted before the next run:
// the whole destination directory is gone, not just emptied.
std::fs::remove_dir_all(&destination_dir).unwrap();
assert!(!destination_dir.exists());
let result = export_database_sql_core(&state, &export_request, |_| {}).await;
execute_sql_statement(&state, &connection_id, "", &format!("DROP DATABASE `{database}`"), None, None)
.await
.unwrap();
assert!(result.is_err(), "export must not silently recreate a destination that previously existed");
assert!(!destination_dir.exists(), "the backup directory must not be resurrected on the wrong filesystem");
std::fs::remove_dir_all(dir).unwrap();
}
/// Regression test for review feedback on #6327: scheduled plans live in the
/// frontend and may not run for hours after being saved. Recording a
/// destination's identity only after a *successful* export (as in
/// `live_mysql_database_export_refuses_to_recreate_a_destination_that_disappeared`
/// above) means a mount that was connected when the schedule was configured,
/// but disappears before its very first scheduled run, is indistinguishable
/// from a brand-new local folder and gets silently recreated on the wrong
/// filesystem. `record_export_destination_identity` -- called from the
/// schedule editor when the schedule is saved, before any export ever runs
/// -- closes that gap. This exercises the same "recorded, then vanished"
/// scenario through the public export API end-to-end, but with the identity
/// recorded eagerly (simulating schedule configuration) instead of via a
/// prior successful run.
#[tokio::test]
#[ignore = "requires a disposable MySQL endpoint"]
async fn live_mysql_database_export_refuses_a_destination_that_vanished_before_its_first_run() {
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("live-mysql-export-preconfigured-vanished-dir-{suffix}");
// MySQL identifiers are capped at 64 characters -- keep this well under
// that even with the 32-character uuid suffix.
let database = format!("dbx_export_precfg_vanished_{suffix}");
let dir = std::env::temp_dir().join(format!("dbx-live-mysql-export-preconfigured-vanished-dir-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
state.configs.write().await.insert(connection_id.clone(), live_mysql_config(&connection_id));
for sql in [
format!("DROP DATABASE IF EXISTS `{database}`"),
format!("CREATE DATABASE `{database}`"),
format!("CREATE TABLE `{database}`.`widgets` (id INT PRIMARY KEY, name VARCHAR(50))"),
format!("INSERT INTO `{database}`.`widgets` VALUES (1, 'alpha')"),
] {
execute_sql_statement(&state, &connection_id, "", &sql, None, None).await.unwrap();
}
// Simulates the user picking an already-mounted external/network drive
// in the schedule editor and saving the schedule. The directory exists
// at configuration time, but no export has run against it yet.
let destination_dir = dir.join("preconfigured-mounted-drive-destination");
let file_path = destination_dir.join("export.sql");
std::fs::create_dir_all(&destination_dir).unwrap();
record_export_destination_identity(&state, &destination_dir)
.await
.expect("configuring the schedule should succeed");
let export_request = DatabaseExportRequest {
export_id: format!("live-mysql-export-preconfigured-vanished-dir-{suffix}"),
connection_id: connection_id.clone(),
database: database.clone(),
schema: database.clone(),
file_path: file_path.to_string_lossy().to_string(),
selected_tables: Vec::new(),
excluded_tables: Vec::new(),
include_structure: true,
include_data: true,
include_objects: true,
include_create_database: true,
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
snapshot_session_id: None,
batch_size: 1000,
};
// The mount disappears before the scheduler ever runs this schedule for
// the first time.
std::fs::remove_dir_all(&destination_dir).unwrap();
assert!(!destination_dir.exists());
let result = export_database_sql_core(&state, &export_request, |_| {}).await;
execute_sql_statement(&state, &connection_id, "", &format!("DROP DATABASE `{database}`"), None, None)
.await
.unwrap();
assert!(result.is_err(), "a mount that was recorded at configuration time must not be silently recreated");
assert!(!destination_dir.exists(), "the backup directory must not be resurrected on the wrong filesystem");
assert!(!file_path.exists(), "no backup should have been written to the resurrected directory");
std::fs::remove_dir_all(dir).unwrap();
}