From d174d86d2d2106ce2663e21a25341edb8df7ed87 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 16:39:40 +0200 Subject: [PATCH 1/6] feat(mssql): back up as dacpac with a backup_format option --- src/domain/mssql/backup.rs | 20 +++++- src/domain/mssql/database.rs | 3 +- src/domain/mssql/format.rs | 33 +++++++++ src/domain/mssql/mod.rs | 1 + src/tests/domain/mssql.rs | 131 ++++++++++++++++++++++++++++++++++- 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 src/domain/mssql/format.rs diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index be564e1..0c6c90f 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; use anyhow::{Context, Result}; @@ -15,6 +16,11 @@ pub async fn run( tokio::task::spawn_blocking(move || -> Result { logger.log("info", format!("Starting MSSQL backup for database {}", cfg.name)); + let (format, unknown_format) = MssqlFormat::from_config(&cfg); + if let Some(v) = unknown_format { + logger.log("warn", format!("Unknown backup_format '{}' for {}, falling back to 'dacpac'", v, cfg.name)); + } + let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension)); let connection_string = format!( "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", @@ -24,8 +30,18 @@ pub async fn run( logger.log("info", format!("MSSQL backup: {}:{}/{} → {}", cfg.host, cfg.port, cfg.database, file_path.display())); let start = Instant::now(); - let output = Command::new("sqlpackage") - .arg("/a:Export") + let mut command = Command::new("sqlpackage"); + match format { + MssqlFormat::Dacpac => command.args([ + "/a:Extract", + "/p:ExtractAllTableData=True", + "/p:ExtractReferencedServerScopedElements=False", + "/p:IgnorePermissions=False", + "/p:IgnoreUserLoginMappings=False", + ]), + MssqlFormat::Bacpac => command.arg("/a:Export"), + }; + let output = command .arg(format!("/scs:{}", connection_string)) .arg(format!("/tf:{}", file_path.display())) .output() diff --git a/src/domain/mssql/database.rs b/src/domain/mssql/database.rs index fda0652..b15d69b 100644 --- a/src/domain/mssql/database.rs +++ b/src/domain/mssql/database.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use super::{backup, ping, restore}; use crate::domain::factory::Database; use crate::services::backup::logger::JobLogger; @@ -21,7 +22,7 @@ impl MssqlDatabase { #[async_trait] impl Database for MssqlDatabase { fn file_extension(&self) -> &'static str { - ".bacpac" + MssqlFormat::from_config(&self.cfg).0.extension() } async fn ping(&self) -> Result { diff --git a/src/domain/mssql/format.rs b/src/domain/mssql/format.rs new file mode 100644 index 0000000..dedb542 --- /dev/null +++ b/src/domain/mssql/format.rs @@ -0,0 +1,33 @@ +use crate::services::config::DatabaseConfig; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MssqlFormat { + Dacpac, + Bacpac, +} + +impl MssqlFormat { + pub fn from_config(cfg: &DatabaseConfig) -> (Self, Option) { + match cfg.options.get("backup_format").and_then(|v| v.as_str()) { + None | Some("dacpac") => (Self::Dacpac, None), + Some("bacpac") => (Self::Bacpac, None), + Some(other) => (Self::Dacpac, Some(other.to_string())), + } + } + + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "dacpac" => Some(Self::Dacpac), + "bacpac" => Some(Self::Bacpac), + _ => None, + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Dacpac => ".dacpac", + Self::Bacpac => ".bacpac", + } + } +} diff --git a/src/domain/mssql/mod.rs b/src/domain/mssql/mod.rs index 143be20..c518aaf 100644 --- a/src/domain/mssql/mod.rs +++ b/src/domain/mssql/mod.rs @@ -1,4 +1,5 @@ pub mod database; +pub(crate) mod format; mod connection; mod ping; mod backup; diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index 6022834..40ced10 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -1,8 +1,10 @@ use crate::domain::factory::DatabaseFactory; +use crate::services::backup::logger::JobLogger; use crate::services::config::{DatabaseConfig, DbType}; use crate::tests::init_tracing_for_test; use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz}; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use tiberius::{AuthMethod, Client, Config}; @@ -10,7 +12,7 @@ use testcontainers::runners::AsyncRunner; use testcontainers::{ContainerAsync, GenericImage, ImageExt}; use testcontainers::core::IntoContainerPort; use tokio::net::TcpStream; -use tokio_util::compat::TokioAsyncWriteCompatExt; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tracing::{error, info}; const SA_PASSWORD: &str = "Test!Str0ng1"; @@ -61,6 +63,43 @@ fn make_config(host: String, port: u16, database: &str, generated_id: &str) -> D } } +async fn start() -> (ContainerAsync, String, u16) { + let container = start_container().await; + tokio::time::sleep(Duration::from_secs(30)).await; + let host = container.get_host().await.unwrap().to_string(); + let port = container.get_host_port_ipv4(1433).await.unwrap(); + (container, host, port) +} + +async fn connect_as(host: &str, port: u16, database: &str, user: &str, password: &str) -> Client> { + let mut config = Config::new(); + config.host(host); + config.port(port); + config.database(database); + config.authentication(AuthMethod::sql_server(user, password)); + config.trust_cert(); + + let tcp = TcpStream::connect(config.get_addr()).await.unwrap(); + tcp.set_nodelay(true).unwrap(); + Client::connect(config, tcp.compat_write()).await.unwrap() +} + +async fn exec(host: &str, port: u16, database: &str, sql: &str) { + let mut client = connect_as(host, port, database, "sa", SA_PASSWORD).await; + client.simple_query(sql).await.unwrap().into_results().await.unwrap(); +} + +async fn scalar(host: &str, port: u16, database: &str, user: &str, password: &str, sql: &str) -> i32 { + let mut client = connect_as(host, port, database, user, password).await; + let row = client.simple_query(sql).await.unwrap().into_row().await.unwrap().unwrap(); + row.get::(0).unwrap() +} + +fn with_backup_format(mut config: DatabaseConfig, format: &str) -> DatabaseConfig { + config.options.insert("backup_format".to_string(), serde_json::json!(format)); + config +} + #[tokio::test] async fn mssql_ping_test() { init_tracing_for_test(); @@ -102,8 +141,8 @@ async fn mssql_backup_test() { "backup file should be non-empty" ); assert!( - file_path.extension().and_then(|e| e.to_str()) == Some("bacpac"), - "backup file should have .bacpac extension" + file_path.extension().and_then(|e| e.to_str()) == Some("dacpac"), + "backup file should have .dacpac extension" ); } @@ -153,3 +192,89 @@ async fn mssql_backup_restore_test() { } } } + +#[tokio::test] +async fn mssql_backup_bacpac_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "bacpacdb").await; + + let config = with_backup_format( + make_config(host, port, "bacpacdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d6"), + "bacpac", + ); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + + assert!(file_path.metadata().unwrap().len() > 0); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("bacpac")); +} + +#[tokio::test] +async fn mssql_backup_windows_principal_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "windowsdb").await; + exec( + &host, + port, + "windowsdb", + "CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \ + CREATE USER [NT AUTHORITY\\SYSTEM] FOR LOGIN [NT AUTHORITY\\SYSTEM];", + ) + .await; + + let config = make_config(host, port, "windowsdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d7"); + let temp_dir = TempDir::new().unwrap(); + + let bacpac = DatabaseFactory::create_for_backup(with_backup_format(config.clone(), "bacpac")) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await; + assert!(bacpac.unwrap_err().to_string().contains("SQL71627")); + + let file_path = DatabaseFactory::create_for_backup(config) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("dacpac")); +} + +mod format_tests { + use super::{make_config, with_backup_format}; + use crate::domain::mssql::format::MssqlFormat; + use std::path::Path; + + #[test] + fn from_config_defaults_to_dacpac() { + let cfg = make_config("localhost".into(), 1433, "db", "00000000-0000-0000-0000-000000000000"); + assert_eq!(MssqlFormat::from_config(&cfg), (MssqlFormat::Dacpac, None)); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg.clone(), "dacpac")), + (MssqlFormat::Dacpac, None) + ); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg.clone(), "bacpac")), + (MssqlFormat::Bacpac, None) + ); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg, "zip")), + (MssqlFormat::Dacpac, Some("zip".to_string())) + ); + } + + #[test] + fn from_path_reads_extension() { + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.dacpac")), Some(MssqlFormat::Dacpac)); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.BACPAC")), Some(MssqlFormat::Bacpac)); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/backup.dump")), None); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/noext")), None); + assert_eq!(MssqlFormat::Dacpac.extension(), ".dacpac"); + assert_eq!(MssqlFormat::Bacpac.extension(), ".bacpac"); + } +} From 1e4a07964afb28f0728717911b592658385dd08a Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 16:49:03 +0200 Subject: [PATCH 2/6] feat(mssql): restore dacpac and bacpac over an existing database --- src/domain/mssql/connection.rs | 5 +- src/domain/mssql/ping.rs | 2 +- src/domain/mssql/restore.rs | 51 ++++++++++++++++++-- src/tests/domain/mssql.rs | 87 ++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/src/domain/mssql/connection.rs b/src/domain/mssql/connection.rs index aad6fad..a95ec7b 100644 --- a/src/domain/mssql/connection.rs +++ b/src/domain/mssql/connection.rs @@ -4,10 +4,13 @@ use tiberius::{AuthMethod, Client, Config}; use tokio::net::TcpStream; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; -pub async fn build_client(cfg: &DatabaseConfig) -> Result>> { +pub async fn build_client(cfg: &DatabaseConfig, database: Option<&str>) -> Result>> { let mut config = Config::new(); config.host(&cfg.host); config.port(cfg.port); + if let Some(database) = database { + config.database(database); + } config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password)); config.trust_cert(); diff --git a/src/domain/mssql/ping.rs b/src/domain/mssql/ping.rs index 34c33a5..14873bf 100644 --- a/src/domain/mssql/ping.rs +++ b/src/domain/mssql/ping.rs @@ -5,7 +5,7 @@ use tracing::{error, info}; pub async fn run(cfg: DatabaseConfig) -> Result { info!("Running ping for MSSQL database {}", cfg.name); - match super::connection::build_client(&cfg).await { + match super::connection::build_client(&cfg, None).await { Ok(mut client) => match client.simple_query("SELECT 1").await { Ok(_) => { info!("MSSQL ping succeeded for {}", cfg.name); diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index af56d6c..9cc084a 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; use anyhow::{Context, Result}; @@ -6,10 +7,29 @@ use std::process::Command; use std::sync::Arc; use std::time::Instant; -pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc) -> Result<()> { - tokio::task::spawn_blocking(move || -> Result<()> { - logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name)); +const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1); +IF DB_ID(@P1) IS NOT NULL +BEGIN + IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5 + EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); + EXEC(N'DROP DATABASE ' + @q); +END"; +pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc) -> Result<()> { + logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name)); + + let Some(format) = MssqlFormat::from_path(&restore_file) else { + let message = format!( + "Unsupported MSSQL backup file {}: expected .bacpac or .dacpac", + restore_file.display() + ); + logger.log("error", message.clone()); + anyhow::bail!(message); + }; + + drop_database(&cfg, &logger).await?; + + tokio::task::spawn_blocking(move || -> Result<()> { let connection_string = format!( "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", cfg.host, cfg.port, cfg.database, cfg.username, cfg.password @@ -24,8 +44,12 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc command.args(["/a:Publish", "/p:AllowIncompatiblePlatform=True"]), + MssqlFormat::Bacpac => command.arg("/a:Import"), + }; + let output = command .arg(format!("/tcs:{}", connection_string)) .arg(format!("/sf:{}", restore_file.display())) .output() @@ -52,3 +76,20 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc Result<()> { + logger.log("info", format!("Dropping MSSQL database {} before restore", cfg.database)); + + let start = Instant::now(); + let mut client = super::connection::build_client(cfg, Some("master")) + .await + .with_context(|| format!("Failed to connect to master for {}", cfg.name))?; + client + .execute(DROP_DATABASE_SQL, &[&cfg.database.as_str()]) + .await + .with_context(|| format!("Failed to drop MSSQL database {}", cfg.database))?; + + let duration_ms = start.elapsed().as_millis() as f64; + logger.log_command(format!("DROP DATABASE [{}]", cfg.database), None, Some(0), Some(duration_ms)); + Ok(()) +} diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index 40ced10..61670f5 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -245,6 +245,93 @@ async fn mssql_backup_windows_principal_test() { assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("dacpac")); } +async fn restore_over_existing(format: &str, generated_id: &str) { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "appdb").await; + exec(&host, port, "master", "CREATE LOGIN app_login WITH PASSWORD = 'App!Str0ng1'").await; + exec( + &host, + port, + "appdb", + "CREATE TABLE dbo.users (id INT PRIMARY KEY, email NVARCHAR(100) NOT NULL); \ + INSERT dbo.users VALUES (1, 'a@x'), (2, 'b@x'), (3, 'c@x'); \ + CREATE USER app_user FOR LOGIN app_login; \ + GRANT SELECT ON dbo.users TO app_user;", + ) + .await; + + let config = with_backup_format(make_config(host.clone(), port, "appdb", generated_id), format); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config.clone()) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some(format)); + + exec( + &host, + port, + "appdb", + "DELETE FROM dbo.users WHERE id = 1; INSERT dbo.users VALUES (4, 'd@x'); CREATE TABLE dbo.extra (id INT);", + ) + .await; + + DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await + .unwrap(); + + assert_eq!(scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3); + assert_eq!( + scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users WHERE id IN (1, 2, 3)").await, + 3 + ); + assert_eq!( + scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT CASE WHEN OBJECT_ID('dbo.extra') IS NULL THEN 1 ELSE 0 END").await, + 1 + ); + assert_eq!( + scalar(&host, port, "appdb", "app_login", "App!Str0ng1", "SELECT COUNT(*) FROM dbo.users").await, + 3 + ); +} + +#[tokio::test] +async fn mssql_restore_over_existing_dacpac_test() { + restore_over_existing("dacpac", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d8").await; +} + +#[tokio::test] +async fn mssql_restore_over_existing_bacpac_test() { + restore_over_existing("bacpac", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d9").await; +} + +#[tokio::test] +async fn mssql_restore_rejects_unknown_extension_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "keepdb").await; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("backup.dump"); + std::fs::write(&file_path, b"not a package").unwrap(); + + let config = make_config(host.clone(), port, "keepdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6da"); + let result = DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(result.unwrap_err().to_string().contains("expected .bacpac or .dacpac")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('keepdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); +} + mod format_tests { use super::{make_config, with_backup_format}; use crate::domain::mssql::format::MssqlFormat; From c4f4cd86cd259371d9476f3cf47bf9fc591704cc Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 16:58:12 +0200 Subject: [PATCH 3/6] fix(mssql): refuse restore drop when the login cannot recreate the database --- src/domain/mssql/restore.rs | 2 ++ src/tests/domain/mssql.rs | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index 9cc084a..21ea161 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -10,6 +10,8 @@ use std::time::Instant; const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1); IF DB_ID(@P1) IS NOT NULL BEGIN + IF HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE') = 0 + THROW 50000, N'Restore needs CREATE DATABASE permission (dbcreator) to recreate the target database; nothing was dropped', 1; IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5 EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); EXEC(N'DROP DATABASE ' + @q); diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index 61670f5..68db97c 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -332,6 +332,47 @@ async fn mssql_restore_rejects_unknown_extension_test() { ); } +#[tokio::test] +async fn mssql_restore_refuses_without_create_database_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "ownerdb").await; + exec(&host, port, "master", "CREATE LOGIN owner_login WITH PASSWORD = 'Owner!Str0ng1'").await; + exec( + &host, + port, + "ownerdb", + "CREATE TABLE dbo.users (id INT PRIMARY KEY, email NVARCHAR(100) NOT NULL); \ + INSERT dbo.users VALUES (1, 'a@x'), (2, 'b@x'), (3, 'c@x'); \ + CREATE USER owner_user FOR LOGIN owner_login; \ + ALTER ROLE db_owner ADD MEMBER owner_user;", + ) + .await; + + let config = make_config(host.clone(), port, "ownerdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6db"); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config.clone()) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + + let mut owner_config = config; + owner_config.username = "owner_login".to_string(); + owner_config.password = "Owner!Str0ng1".to_string(); + let result = DatabaseFactory::create_for_restore(owner_config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(format!("{:#}", result.unwrap_err()).contains("CREATE DATABASE")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('ownerdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); + assert_eq!(scalar(&host, port, "ownerdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3); +} + mod format_tests { use super::{make_config, with_backup_format}; use crate::domain::mssql::format::MssqlFormat; From 7e9b276685245d2137c40b51b3e65a3dd14d75c2 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 17:38:32 +0200 Subject: [PATCH 4/6] fix(mssql): keep the target usable when the restore drop fails --- src/domain/mssql/backup.rs | 4 +- src/domain/mssql/restore.rs | 21 +++++++-- src/tests/domain/mssql.rs | 86 +++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index 0c6c90f..ccef82e 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -14,13 +14,13 @@ pub async fn run( logger: Arc, ) -> Result { tokio::task::spawn_blocking(move || -> Result { - logger.log("info", format!("Starting MSSQL backup for database {}", cfg.name)); - let (format, unknown_format) = MssqlFormat::from_config(&cfg); if let Some(v) = unknown_format { logger.log("warn", format!("Unknown backup_format '{}' for {}, falling back to 'dacpac'", v, cfg.name)); } + logger.log("info", format!("Starting MSSQL {} backup for database {}", &format.extension()[1..], cfg.name)); + let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension)); let connection_string = format!( "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index 21ea161..1568c39 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -10,11 +10,24 @@ use std::time::Instant; const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1); IF DB_ID(@P1) IS NOT NULL BEGIN - IF HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE') = 0 + IF DB_ID(@P1) <= 4 + THROW 50000, N'Refusing to restore over a system database; nothing was dropped', 1; + IF ISNULL(HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE'), 0) = 0 THROW 50000, N'Restore needs CREATE DATABASE permission (dbcreator) to recreate the target database; nothing was dropped', 1; + DECLARE @single bit = 0; IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5 + BEGIN EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); - EXEC(N'DROP DATABASE ' + @q); + SET @single = 1; + END + BEGIN TRY + EXEC(N'DROP DATABASE ' + @q); + END TRY + BEGIN CATCH + IF @single = 1 AND DB_ID(@P1) IS NOT NULL + EXEC(N'ALTER DATABASE ' + @q + N' SET MULTI_USER'); + THROW; + END CATCH END"; pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc) -> Result<()> { @@ -80,7 +93,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc Result<()> { - logger.log("info", format!("Dropping MSSQL database {} before restore", cfg.database)); + logger.log("info", format!("Replacing MSSQL database {} (drop if it exists, then recreate from the backup)", cfg.database)); let start = Instant::now(); let mut client = super::connection::build_client(cfg, Some("master")) @@ -92,6 +105,6 @@ async fn drop_database(cfg: &DatabaseConfig, logger: &JobLogger) -> Result<()> { .with_context(|| format!("Failed to drop MSSQL database {}", cfg.database))?; let duration_ms = start.elapsed().as_millis() as f64; - logger.log_command(format!("DROP DATABASE [{}]", cfg.database), None, Some(0), Some(duration_ms)); + logger.log_command(format!("DROP DATABASE IF EXISTS [{}]", cfg.database), None, Some(0), Some(duration_ms)); Ok(()) } diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index 68db97c..4aa6f6a 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -373,6 +373,92 @@ async fn mssql_restore_refuses_without_create_database_test() { assert_eq!(scalar(&host, port, "ownerdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3); } +#[tokio::test] +async fn mssql_restore_failed_drop_keeps_multi_user_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "alterdb").await; + exec( + &host, + port, + "master", + "CREATE LOGIN alter_login WITH PASSWORD = 'Alter!Str0ng1'; GRANT CREATE ANY DATABASE TO alter_login;", + ) + .await; + exec( + &host, + port, + "alterdb", + "CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \ + CREATE USER alter_user FOR LOGIN alter_login; GRANT ALTER TO alter_user;", + ) + .await; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("backup.dacpac"); + std::fs::write(&file_path, b"not a package").unwrap(); + + let mut config = make_config(host.clone(), port, "alterdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6dc"); + config.username = "alter_login".to_string(); + config.password = "Alter!Str0ng1".to_string(); + let result = DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(!format!("{:#}", result.unwrap_err()).contains("nothing was dropped")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('alterdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); + assert_eq!( + scalar( + &host, + port, + "master", + "sa", + SA_PASSWORD, + "SELECT CASE WHEN DATABASEPROPERTYEX('alterdb', 'UserAccess') = 'MULTI_USER' THEN 1 ELSE 0 END" + ) + .await, + 1 + ); +} + +#[tokio::test] +async fn mssql_restore_refuses_system_database_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("backup.dacpac"); + std::fs::write(&file_path, b"not a package").unwrap(); + + let config = make_config(host.clone(), port, "msdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6dd"); + let result = DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(format!("{:#}", result.unwrap_err()).contains("system database")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('msdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); + assert_eq!( + scalar( + &host, + port, + "master", + "sa", + SA_PASSWORD, + "SELECT CASE WHEN DATABASEPROPERTYEX('msdb', 'UserAccess') = 'MULTI_USER' THEN 1 ELSE 0 END" + ) + .await, + 1 + ); +} + mod format_tests { use super::{make_config, with_backup_format}; use crate::domain::mssql::format::MssqlFormat; From a1a84bb76d1b700e275f1fe507c1c6b3f5a0d220 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 17:54:08 +0200 Subject: [PATCH 5/6] refactor(mssql): move restore drop into the connection module --- src/domain/mssql/connection.rs | 36 ++++++++++++++++++++++++++- src/domain/mssql/restore.rs | 45 +++------------------------------- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/src/domain/mssql/connection.rs b/src/domain/mssql/connection.rs index a95ec7b..92ee08d 100644 --- a/src/domain/mssql/connection.rs +++ b/src/domain/mssql/connection.rs @@ -1,9 +1,32 @@ use crate::services::config::DatabaseConfig; -use anyhow::Result; +use anyhow::{Context, Result}; use tiberius::{AuthMethod, Client, Config}; use tokio::net::TcpStream; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; +const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1); +IF DB_ID(@P1) IS NOT NULL +BEGIN + IF DB_ID(@P1) <= 4 + THROW 50000, N'Refusing to restore over a system database; nothing was dropped', 1; + IF ISNULL(HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE'), 0) = 0 + THROW 50000, N'Restore needs CREATE DATABASE permission (dbcreator) to recreate the target database; nothing was dropped', 1; + DECLARE @single bit = 0; + IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5 + BEGIN + EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); + SET @single = 1; + END + BEGIN TRY + EXEC(N'DROP DATABASE ' + @q); + END TRY + BEGIN CATCH + IF @single = 1 AND DB_ID(@P1) IS NOT NULL + EXEC(N'ALTER DATABASE ' + @q + N' SET MULTI_USER'); + THROW; + END CATCH +END"; + pub async fn build_client(cfg: &DatabaseConfig, database: Option<&str>) -> Result>> { let mut config = Config::new(); config.host(&cfg.host); @@ -19,3 +42,14 @@ pub async fn build_client(cfg: &DatabaseConfig, database: Option<&str>) -> Resul let client = Client::connect(config, tcp.compat_write()).await?; Ok(client) } + +pub async fn drop_database(cfg: &DatabaseConfig) -> Result<()> { + let mut client = build_client(cfg, Some("master")) + .await + .with_context(|| format!("Failed to connect to master for {}", cfg.name))?; + client + .execute(DROP_DATABASE_SQL, &[&cfg.database.as_str()]) + .await + .with_context(|| format!("Failed to drop MSSQL database {}", cfg.database))?; + Ok(()) +} diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index 1568c39..cce459e 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -7,29 +7,6 @@ use std::process::Command; use std::sync::Arc; use std::time::Instant; -const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1); -IF DB_ID(@P1) IS NOT NULL -BEGIN - IF DB_ID(@P1) <= 4 - THROW 50000, N'Refusing to restore over a system database; nothing was dropped', 1; - IF ISNULL(HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE'), 0) = 0 - THROW 50000, N'Restore needs CREATE DATABASE permission (dbcreator) to recreate the target database; nothing was dropped', 1; - DECLARE @single bit = 0; - IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5 - BEGIN - EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); - SET @single = 1; - END - BEGIN TRY - EXEC(N'DROP DATABASE ' + @q); - END TRY - BEGIN CATCH - IF @single = 1 AND DB_ID(@P1) IS NOT NULL - EXEC(N'ALTER DATABASE ' + @q + N' SET MULTI_USER'); - THROW; - END CATCH -END"; - pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc) -> Result<()> { logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name)); @@ -42,7 +19,10 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc Result<()> { let connection_string = format!( @@ -91,20 +71,3 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc Result<()> { - logger.log("info", format!("Replacing MSSQL database {} (drop if it exists, then recreate from the backup)", cfg.database)); - - let start = Instant::now(); - let mut client = super::connection::build_client(cfg, Some("master")) - .await - .with_context(|| format!("Failed to connect to master for {}", cfg.name))?; - client - .execute(DROP_DATABASE_SQL, &[&cfg.database.as_str()]) - .await - .with_context(|| format!("Failed to drop MSSQL database {}", cfg.database))?; - - let duration_ms = start.elapsed().as_millis() as f64; - logger.log_command(format!("DROP DATABASE IF EXISTS [{}]", cfg.database), None, Some(0), Some(duration_ms)); - Ok(()) -} From df34765e0fb8608602dc783bf7330ffdb4729e96 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 18:12:15 +0200 Subject: [PATCH 6/6] fix: refactoring --- docker-compose.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 699ed22..2adce57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,16 +12,16 @@ services: - ./databases.json:/config/config.json #- ./databases.toml:/config/config.toml - /var/run/docker.sock:/var/run/docker.sock -# - cargo-target:/app/target + # - cargo-target:/app/target - databases_sqlite-data:/sqlite-data/workspace/data - ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data -# - /bigdisk:/scratch + # - /bigdisk:/scratch environment: APP_ENV: development LOG: debug TZ: "Europe/Paris" -# TMPDIR: /scratch - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiYTQyNjQzNTQtZGE3Ni00OWFkLWJkYjctZDVjMjMwYzhjYmViIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" + # TMPDIR: /scratch + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #RETRY_ATTEMPTS: 3 @@ -40,7 +40,7 @@ services: volumes: cargo-registry: cargo-git: -# cargo-target: + # cargo-target: databases_sqlite-data: external: true