feat(mongodb): bson dumps and database backup/restore

This commit is contained in:
Hand Sonic authored and GitHub committed 2026-09-16 22:46:48 +08:00
1 parent ecc1135de3
commit f5673ca0a1
54 files changed
+6355 -106

No files matched your search

+2
View File
@@ -83,6 +83,8 @@ bytes = "1"
font-kit = { version = "0.14.3", optional = true }
zip = { version = "4", default-features = false, features = ["deflate"] }
flate2 = "1"
crc = "3"
sha1 = "0.10"
tar = "0.4"
zstd = "0.13"
sysinfo = { version = "0.32", features = ["system"] }
+3 -3
View File
@@ -2579,17 +2579,17 @@ pub struct MongoInsertOutcome {
pub errors: Vec<MongoBulkWriteError>,
}
pub async fn insert_bson_documents(
pub async fn insert_bson_documents<T: serde::Serialize + Send + Sync>(
client: &Client,
database: &str,
collection: &str,
documents: Vec<Document>,
documents: Vec<T>,
) -> Result<MongoInsertOutcome, MongoBulkWriteError> {
if documents.is_empty() {
return Ok(MongoInsertOutcome::default());
}
let total = documents.len() as u64;
let col = client.database(database).collection::<Document>(collection);
let col = client.database(database).collection::<T>(collection);
// Unordered: one rejected document must not abandon the rest of the batch, and the server
// then reports every rejection instead of stopping at the first.
match col.insert_many(documents).ordered(false).await {
+1
View File
@@ -58,6 +58,7 @@ pub mod models;
pub mod mongo_oidc;
pub mod mongo_ops;
pub mod mongo_shell;
pub mod mongodb_dump;
pub mod mongodb_import_export;
#[cfg(feature = "mq-admin")]
pub mod mq;
+639
View File
@@ -0,0 +1,639 @@
//! Database-scoped MongoDB dump/restore; transport adapters own dialogs and uploads.
mod archive;
mod directory;
mod metadata;
mod source;
mod stream;
pub use source::{
attach_mongodb_restore_upload, mongodb_restore_upload_files, prepare_mongodb_directory_catalog,
prepare_owned_mongodb_restore_source, MongoDumpFile,
};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::time::{Duration, Instant};
use futures::TryStreamExt;
use mongodb::bson::{doc, Document};
use serde::{Deserialize, Serialize};
use crate::connection::{AppState, PoolKind};
use crate::mongodb_import_export::{export_mongodb_query_core, MongoExportFormat, MongoExportRequest};
use metadata::CollectionMetadata;
type CancelFuture = Pin<Box<dyn Future<Output = bool> + Send>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MongoDumpFormat {
Directory,
Archive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDumpCollection {
pub database: String,
pub name: String,
pub kind: String,
pub documents: Option<u64>,
pub size_bytes: u64,
pub indexes: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDumpCatalog {
pub databases: Vec<String>,
pub collections: Vec<MongoDumpCollection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoRestoreSourceRequest {
pub path: String,
pub format: MongoDumpFormat,
#[serde(default)]
pub gzip: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoRestoreSourcePreview {
pub source_ref: String,
#[serde(flatten)]
pub catalog: MongoDumpCatalog,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDatabaseDumpRequest {
pub task_id: String,
pub connection_id: String,
pub database: String,
pub file_path: String,
pub format: MongoDumpFormat,
#[serde(default)]
pub gzip: bool,
#[serde(default)]
pub collections: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDatabaseRestoreRequest {
pub task_id: String,
pub connection_id: String,
pub database: String,
pub source_database: String,
pub source_ref: String,
#[serde(default)]
pub collections: Option<Vec<String>>,
#[serde(default)]
pub drop_existing: bool,
#[serde(default = "yes")]
pub restore_options: bool,
#[serde(default = "yes")]
pub restore_indexes: bool,
#[serde(default = "yes")]
pub stop_on_error: bool,
#[serde(default)]
pub objcheck: bool,
#[serde(default = "batch_size")]
pub batch_size: usize,
#[serde(default)]
pub execution_id: Option<String>,
}
fn yes() -> bool {
true
}
fn batch_size() -> usize {
500
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDatabaseDumpProgress {
pub task_id: String,
pub status: String,
pub phase: String,
pub collection: Option<String>,
pub collections_done: usize,
pub collections_total: usize,
pub documents_read: u64,
pub documents_written: u64,
pub documents_failed: u64,
pub indexes_created: usize,
pub elapsed_ms: u128,
pub error_message: Option<String>,
pub file_path: Option<String>,
#[serde(default)]
pub bytes_processed: u64,
#[serde(default)]
pub documents_validated: u64,
}
impl MongoDatabaseDumpProgress {
pub fn initial(task_id: &str) -> Self {
Self {
task_id: task_id.into(),
status: "running".into(),
phase: "preparing".into(),
collection: None,
collections_done: 0,
collections_total: 0,
documents_read: 0,
documents_written: 0,
documents_failed: 0,
indexes_created: 0,
elapsed_ms: 0,
error_message: None,
file_path: None,
bytes_processed: 0,
documents_validated: 0,
}
}
fn emit(&mut self, started: Instant, callback: &mut impl FnMut(Self)) {
self.elapsed_ms = started.elapsed().as_millis();
callback(self.clone());
}
}
#[derive(Clone)]
struct PreparedCollection {
database: String,
metadata: CollectionMetadata,
path: Option<PathBuf>,
documents: u64,
size_bytes: u64,
metadata_path: Option<PathBuf>,
}
pub fn release_mongodb_restore_source(source_ref: &str) -> bool {
source::release(source_ref)
}
pub async fn prepare_mongodb_restore_source(
request: MongoRestoreSourceRequest,
) -> Result<MongoRestoreSourcePreview, String> {
source::prepare(request, None, None).await
}
fn catalog(entries: &[PreparedCollection], exact: bool) -> MongoDumpCatalog {
MongoDumpCatalog {
databases: entries.iter().map(|entry| entry.database.clone()).collect::<BTreeSet<_>>().into_iter().collect(),
collections: entries
.iter()
.map(|entry| MongoDumpCollection {
database: entry.database.clone(),
name: entry.metadata.collection_name.clone(),
kind: entry.metadata.kind.clone(),
documents: if exact { Some(entry.documents) } else { None },
size_bytes: entry.size_bytes,
indexes: entry.metadata.restore_indexes().len(),
source_files: Vec::new(),
})
.collect(),
}
}
async fn native_client(state: &AppState, id: &str, database: &str) -> Result<mongodb::Client, String> {
metadata::validate_database(database)?;
state.get_or_create_pool(id, Some(database)).await?;
match state.pool_handle(id).await {
Some(PoolKind::MongoDb(client)) => Ok(client.clone()),
_ => Err("Database dump/restore requires the native MongoDB driver".into()),
}
}
async fn database_entries(client: &mongodb::Client, database: &str) -> Result<Vec<PreparedCollection>, String> {
let db = client.database(database);
let mut cursor = db.list_collections().await.map_err(|e| e.to_string())?;
let mut entries = Vec::new();
while let Some(spec) = cursor.try_next().await.map_err(|e| e.to_string())? {
if spec.name.starts_with("system.") && spec.name != "system.js" {
continue;
}
let mut metadata = CollectionMetadata::empty(&spec.name);
metadata.kind =
crate::db::mongo_driver::MongoCollectionKind::from_driver_type(&spec.collection_type).as_str().into();
metadata.options = mongodb::bson::to_document(&spec.options).map_err(|e| e.to_string())?;
metadata.validate()?;
if !metadata.is_view() {
let mut indexes = db.collection::<Document>(&spec.name).list_indexes().await.map_err(|e| e.to_string())?;
while let Some(index) = indexes.try_next().await.map_err(|e| e.to_string())? {
metadata.indexes.push(mongodb::bson::to_document(&index).map_err(|e| e.to_string())?);
}
}
metadata.validate()?;
entries.push(PreparedCollection {
database: database.into(),
metadata,
path: None,
documents: 0,
size_bytes: 0,
metadata_path: None,
});
}
entries.sort_by(|a, b| a.metadata.collection_name.cmp(&b.metadata.collection_name));
Ok(entries)
}
pub async fn inspect_mongodb_database_dump(
state: &AppState,
id: &str,
database: &str,
) -> Result<MongoDumpCatalog, String> {
let client = native_client(state, id, database).await?;
let entries = database_entries(&client, database).await?;
let mut catalog = catalog(&entries, false);
catalog.databases = vec![database.into()];
Ok(catalog)
}
fn select_entries(
entries: &[PreparedCollection],
selected: &Option<Vec<String>>,
) -> Result<Vec<PreparedCollection>, String> {
match selected {
None => Ok(entries.to_vec()),
Some(names) => {
let mut requested: HashSet<_> = names.iter().collect();
let chosen =
entries.iter().filter(|entry| requested.remove(&entry.metadata.collection_name)).cloned().collect();
if !requested.is_empty() {
return Err("A selected collection is not present in the dump plan".into());
}
Ok(chosen)
}
}
}
fn ordered_entries(entries: &[PreparedCollection]) -> Result<Vec<PreparedCollection>, String> {
let names: HashMap<_, _> = entries
.iter()
.enumerate()
.map(|(i, entry)| ((entry.database.clone(), entry.metadata.collection_name.clone()), i))
.collect();
let mut ordered = Vec::with_capacity(entries.len());
// 0 = unseen, 1 = active dependency, 2 = emitted. Uploaded metadata may
// contain arbitrarily deep chains, so keep the traversal off the call stack.
let mut states = vec![0u8; entries.len()];
for (i, entry) in entries.iter().enumerate().filter(|(_, e)| !e.metadata.is_view()) {
states[i] = 2;
ordered.push(entry.clone());
}
for i in 0..entries.len() {
let mut pending = vec![(i, false)];
while let Some((index, finish)) = pending.pop() {
if finish {
states[index] = 2;
ordered.push(entries[index].clone());
continue;
}
match states[index] {
2 => continue,
1 => return Err("Cyclic view dependencies in dump metadata".into()),
_ => {}
}
states[index] = 1;
pending.push((index, true));
let entry = &entries[index];
if let Ok(base) = entry.metadata.options.get_str("viewOn") {
if let Some(&parent) = names.get(&(entry.database.clone(), base.into())) {
pending.push((parent, false));
}
}
}
}
Ok(ordered)
}
fn cancelled(flag: &AtomicBool) -> Result<(), String> {
if flag.load(Ordering::Relaxed) {
Err("MongoDB dump/restore cancelled".into())
} else {
Ok(())
}
}
async fn blocking_work<T: Send + 'static>(
task_id: &str,
is_cancelled: &mut impl FnMut(&str) -> CancelFuture,
work: impl FnOnce(Arc<AtomicBool>) -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
if is_cancelled(task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
struct CancelOnDrop(Arc<AtomicBool>);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
let flag = Arc::new(AtomicBool::new(false));
let _guard = CancelOnDrop(flag.clone());
let signal = flag.clone();
let mut task = tokio::task::spawn_blocking(move || work(signal));
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
tokio::select! {
result = &mut task => return result.map_err(|e| e.to_string())?,
_ = interval.tick() => if is_cancelled(task_id).await { flag.store(true, Ordering::Relaxed); },
}
}
}
pub async fn dump_mongodb_database<C, F>(
state: &AppState,
request: &MongoDatabaseDumpRequest,
mut is_cancelled: C,
mut on_progress: F,
) -> Result<MongoDatabaseDumpProgress, String>
where
C: FnMut(&str) -> CancelFuture,
F: FnMut(MongoDatabaseDumpProgress),
{
let started = Instant::now();
let mut progress = MongoDatabaseDumpProgress::initial(&request.task_id);
progress.emit(started, &mut on_progress);
let result: Result<(), String> = async {
if is_cancelled(&request.task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
let client = native_client(state, &request.connection_id, &request.database).await?;
let mut entries = select_entries(&database_entries(&client, &request.database).await?, &request.collections)?;
progress.collections_total = entries.len();
let target = PathBuf::from(&request.file_path);
if target.exists() {
return Err(match request.format {
MongoDumpFormat::Directory => "Dump destination already exists; choose a new directory",
MongoDumpFormat::Archive => "Dump destination already exists; choose a new archive file",
}
.into());
}
let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let working =
tempfile::Builder::new().prefix(".dbx-mongo-dump-").tempdir_in(parent).map_err(|e| e.to_string())?;
for (index, entry) in entries.iter_mut().enumerate() {
if is_cancelled(&request.task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
progress.collection = Some(entry.metadata.collection_name.clone());
progress.phase = "data".into();
progress.emit(started, &mut on_progress);
if !entry.metadata.is_view() {
let path = working.path().join(format!("{index}.bson"));
let child = MongoExportRequest {
export_id: request.task_id.clone(),
connection_id: request.connection_id.clone(),
database: request.database.clone(),
collection: entry.metadata.collection_name.clone(),
filter: None,
sort: None,
projection: None,
collation: None,
format: MongoExportFormat::Bson,
include_header: false,
gzip: false,
file_path: path.to_string_lossy().into_owned(),
execution_id: None,
};
let base = progress.documents_read;
let summary = export_mongodb_query_core(
state,
&child,
|id| is_cancelled(id),
|child| {
progress.documents_read = base + child.documents_read;
progress.emit(started, &mut on_progress);
},
)
.await?;
entry.documents = summary.documents_exported;
entry.size_bytes = std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
entry.path = Some(path);
}
progress.collections_done += 1;
}
progress.phase = "archive".into();
progress.emit(started, &mut on_progress);
let server_version = client
.database(&request.database)
.run_command(doc! { "buildInfo": 1 })
.await
.map_err(|e| e.to_string())?
.get_str("version")
.unwrap_or("")
.to_string();
let database = request.database.clone();
let gzip = request.gzip;
let format = request.format;
blocking_work(&request.task_id, &mut is_cancelled, move |flag| {
let _working = working;
let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
match format {
MongoDumpFormat::Archive => {
let temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
directory::write_file(temp.path(), gzip, |writer| {
archive::pack(writer, &entries, &server_version, || cancelled(&flag))
})?;
cancelled(&flag)?;
if target.exists() {
return Err("Dump destination already exists".into());
}
temp.persist(&target).map_err(|e| e.to_string())?;
}
MongoDumpFormat::Directory => {
let output = tempfile::Builder::new()
.prefix(".dbx-dump-output-")
.tempdir_in(parent)
.map_err(|e| e.to_string())?;
directory::publish_files(output.path(), &database, &entries, gzip, || cancelled(&flag))?;
cancelled(&flag)?;
if target.exists() {
return Err("Dump destination already exists".into());
}
std::fs::rename(output.path(), &target).map_err(|e| e.to_string())?;
}
}
Ok(())
})
.await?;
progress.file_path = Some(request.file_path.clone());
Ok(())
}
.await;
finish_progress(progress, result, started, &mut on_progress)
}
pub async fn restore_mongodb_database<C, F>(
state: &AppState,
request: &MongoDatabaseRestoreRequest,
mut is_cancelled: C,
mut on_progress: F,
) -> Result<MongoDatabaseDumpProgress, String>
where
C: FnMut(&str) -> CancelFuture,
F: FnMut(MongoDatabaseDumpProgress),
{
let started = Instant::now();
let mut progress = MongoDatabaseDumpProgress::initial(&request.task_id);
progress.emit(started, &mut on_progress);
let result: Result<(), String> = async {
if let Some(name) = crate::query::connection_readonly_name(state, &request.connection_id).await {
return Err(format!("Read-only connection '{name}': restore blocked"));
}
if is_cancelled(&request.task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
crate::mongodb_import_export::clamp_batch_size(request.batch_size).map_err(|e| e.display_message())?;
let source = source::get(&request.source_ref)?;
let source_entries: Vec<_> =
source.entries.iter().filter(|entry| entry.database == request.source_database).cloned().collect();
if source_entries.is_empty() && !source.entries.is_empty() {
return Err("Source database is not present in the dump".into());
}
let entries = ordered_entries(&select_entries(&source_entries, &request.collections)?)?;
progress.collections_total = entries.len();
source.check_ready(&entries)?;
let client = native_client(state, &request.connection_id, &request.database).await?;
let db = client.database(&request.database);
let existing = db
.list_collections()
.await
.map_err(|e| e.to_string())?
.try_collect::<Vec<_>>()
.await
.map_err(|e| e.to_string())?;
for entry in &entries {
entry.metadata.validate()?;
if let Some(target) = existing.iter().find(|target| target.name == entry.metadata.collection_name) {
if !request.drop_existing
&& (entry.metadata.is_view()
|| !matches!(target.collection_type, mongodb::results::CollectionType::Collection))
{
return Err(format!(
"{}: replacing an existing view or non-regular collection requires dropExisting",
target.name
));
}
}
if let Ok(base) = entry.metadata.options.get_str("viewOn") {
if !entries.iter().any(|e| e.metadata.collection_name == base)
&& !existing.iter().any(|e| e.name == base)
{
return Err(format!(
"Missing backing collection for view {}: {base}",
entry.metadata.collection_name
));
}
}
}
let existing_names = existing.iter().map(|e| e.name.clone()).collect::<HashSet<_>>();
stream::restore_data(
state,
&client,
request,
source,
&entries,
&existing_names,
&mut progress,
started,
&mut is_cancelled,
&mut on_progress,
)
.await?;
for entry in &entries {
if is_cancelled(&request.task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
stream::writable(state, &request.connection_id).await?;
let name = &entry.metadata.collection_name;
progress.collection = Some(name.clone());
progress.phase = if entry.metadata.is_view() { "views" } else { "indexes" }.into();
progress.emit(started, &mut on_progress);
if entry.metadata.is_view() {
stream::initialize_collection(&client, request, entry, existing_names.contains(name)).await?;
progress.collections_done += 1;
} else if request.restore_indexes {
for index in entry.metadata.restore_indexes() {
if is_cancelled(&request.task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
db.run_command(doc! { "createIndexes": name, "indexes": [index] })
.await
.map_err(|e| e.to_string())?;
progress.indexes_created += 1;
}
}
}
Ok(())
}
.await;
finish_progress(progress, result, started, &mut on_progress)
}
fn finish_progress(
mut progress: MongoDatabaseDumpProgress,
result: Result<(), String>,
started: Instant,
callback: &mut impl FnMut(MongoDatabaseDumpProgress),
) -> Result<MongoDatabaseDumpProgress, String> {
progress.phase = "done".into();
progress.status = match &result {
Ok(()) => "done",
Err(error)
if matches!(error.as_str(), "MongoDB dump/restore cancelled" | "Import cancelled" | "Export cancelled") =>
{
"cancelled"
}
Err(_) => "error",
}
.into();
progress.error_message = result.as_ref().err().cloned();
progress.emit(started, callback);
result.map(|_| progress)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn view_ordering_handles_deep_dependency_chains() {
let count = 20_000;
let entries = (0..count)
.map(|index| {
let mut metadata = CollectionMetadata::empty(&format!("view_{index}"));
if index + 1 < count {
metadata.kind = "view".into();
metadata.options = doc! { "viewOn": format!("view_{}", index + 1), "pipeline": [] };
}
PreparedCollection {
database: "source".into(),
metadata,
path: None,
documents: 0,
size_bytes: 0,
metadata_path: None,
}
})
.collect::<Vec<_>>();
let ordered = ordered_entries(&entries).unwrap();
assert_eq!(ordered.len(), count);
for (entry, index) in ordered.iter().zip((0..count).rev()) {
assert_eq!(entry.metadata.collection_name, format!("view_{index}"));
}
}
}
+290
View File
@@ -0,0 +1,290 @@
//! MongoDB Database Tools archive format 0.1, see common/archive/spec.md upstream.
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufReader, Read, Write};
use crc::{Crc, Digest, CRC_64_XZ};
use mongodb::bson::{doc, Document};
use super::{metadata::CollectionMetadata, PreparedCollection};
const MAGIC: u32 = 0x8199_e26d;
const TERMINATOR: [u8; 4] = [255; 4];
// Go hash/crc64 with the ECMA table reflects bits and complements the initial/final state.
const CRC: Crc<u64> = Crc::<u64>::new(&CRC_64_XZ);
pub(super) const MAX_DOCUMENT: usize = 16 * 1024 * 1024;
pub(super) enum Frame {
End,
Terminator,
Document(Vec<u8>),
}
pub(super) fn read_frame(reader: &mut dyn Read) -> Result<Frame, String> {
let frame = read_raw_frame(reader)?;
if let Frame::Document(bytes) = &frame {
mongodb::bson::from_slice::<Document>(bytes).map_err(|e| format!("Invalid BSON document: {e}"))?;
}
Ok(frame)
}
pub(super) fn read_raw_frame(reader: &mut dyn Read) -> Result<Frame, String> {
let mut header = [0; 4];
loop {
match reader.read(&mut header[..1]) {
Ok(0) => return Ok(Frame::End),
Ok(_) => break,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.to_string()),
}
}
reader.read_exact(&mut header[1..]).map_err(|e| format!("Truncated BSON header: {e}"))?;
if header == TERMINATOR {
return Ok(Frame::Terminator);
}
let length = i32::from_le_bytes(header);
if !(5..=MAX_DOCUMENT as i32).contains(&length) {
return Err(format!("Invalid BSON frame length: {length}"));
}
let mut bytes = vec![0; length as usize];
bytes[..4].copy_from_slice(&header);
reader.read_exact(&mut bytes[4..]).map_err(|e| format!("Truncated BSON document: {e}"))?;
mongodb::bson::RawDocument::from_bytes(&bytes).map_err(|e| format!("Invalid BSON document: {e}"))?;
Ok(Frame::Document(bytes))
}
fn document(frame: Frame) -> Result<Document, String> {
match frame {
Frame::Document(bytes) => mongodb::bson::from_slice(&bytes).map_err(|e| e.to_string()),
_ => Err("Expected an archive BSON header".into()),
}
}
fn string(doc: &Document, key: &str) -> Result<String, String> {
doc.get_str(key).map(String::from).map_err(|_| format!("Archive header is missing {key}"))
}
fn write_document(writer: &mut dyn Write, doc: &Document) -> Result<(), String> {
let bytes = mongodb::bson::to_vec(doc).map_err(|e| e.to_string())?;
if bytes.len() > MAX_DOCUMENT {
return Err("Archive metadata exceeds the BSON document limit".into());
}
writer.write_all(&bytes).map_err(|e| e.to_string())
}
pub(super) fn inspect(
reader: &mut dyn Read,
mut check: impl FnMut() -> Result<(), String>,
) -> Result<Vec<PreparedCollection>, String> {
let mut magic = [0; 4];
reader.read_exact(&mut magic).map_err(|e| format!("Invalid MongoDB archive: {e}"))?;
if u32::from_le_bytes(magic) != MAGIC {
return Err("Not a MongoDB --archive file".into());
}
let header = document(read_frame(reader)?)?;
if header.get_str("version") != Ok("0.1") {
return Err("Unsupported MongoDB archive version".into());
}
let mut entries = Vec::<PreparedCollection>::new();
let mut namespaces = HashMap::new();
let mut metadata_bytes = 0usize;
loop {
check()?;
let frame = read_frame(reader)?;
if matches!(frame, Frame::Terminator) {
break;
}
let header = document(frame)?;
let database = string(&header, "db")?;
super::metadata::validate_database(&database)?;
let name = string(&header, "collection")?;
let json = string(&header, "metadata")?;
metadata_bytes += json.len();
if entries.len() >= 100_000 || metadata_bytes > 128 * 1024 * 1024 {
return Err("Archive metadata exceeds the supported limit".into());
}
let metadata = if json.is_empty() {
CollectionMetadata::empty(&name)
} else {
CollectionMetadata::from_json(&json, &name)?
};
metadata.validate()?;
if metadata.collection_name != name {
return Err("Archive namespace does not match collection metadata".into());
}
if header.get_str("type").is_ok_and(|kind| kind == "timeseries") {
return Err("Time-series archives require server-specific restore support".into());
}
let index = entries.len();
if namespaces.insert((database.clone(), name), index).is_some() {
return Err("Duplicate archive namespace".into());
}
entries.push(PreparedCollection {
database,
metadata,
path: None,
documents: 0,
size_bytes: 0,
metadata_path: None,
});
}
Ok(entries)
}
pub(super) enum StreamEvent {
Document(usize, Vec<u8>),
End(usize),
}
pub(super) fn stream(
reader: &mut dyn Read,
expected: &[PreparedCollection],
mut check: impl FnMut() -> Result<(), String>,
mut emit: impl FnMut(StreamEvent) -> Result<(), String>,
) -> Result<(), String> {
let entries = inspect(reader, &mut check)?;
let namespaces: HashMap<_, _> = entries
.iter()
.enumerate()
.map(|(i, e)| ((e.database.clone(), e.metadata.collection_name.clone()), i))
.collect();
if entries.len() != expected.len() {
return Err("Archive metadata changed after preview".into());
}
let mut mapped = Vec::with_capacity(entries.len());
for entry in &entries {
let index = expected
.iter()
.position(|e| e.database == entry.database && e.metadata.collection_name == entry.metadata.collection_name)
.ok_or("Archive metadata changed after preview")?;
if expected[index].metadata.to_json()? != entry.metadata.to_json()? {
return Err("Archive metadata changed after preview".into());
}
mapped.push(index);
}
let mut digests: Vec<Digest<'_, u64>> = entries.iter().map(|_| CRC.digest()).collect();
let mut finished = vec![false; entries.len()];
loop {
check()?;
let frame = read_frame(reader)?;
if matches!(frame, Frame::End) {
break;
}
let header = document(frame)?;
let namespace = (string(&header, "db")?, string(&header, "collection")?);
let index = *namespaces.get(&namespace).ok_or_else(|| "Unknown namespace in archive data".to_string())?;
if finished[index] {
return Err("Archive contains data after namespace EOF".into());
}
let eof = header.get_bool("EOF").map_err(|_| "Archive header is missing EOF".to_string())?;
let checksum = header.get_i64("CRC").map_err(|_| "Archive header is missing CRC".to_string())? as u64;
if eof {
if !matches!(read_frame(reader)?, Frame::Terminator) {
return Err("Invalid archive EOF terminator".into());
}
if digests[index].clone().finalize() != checksum {
return Err(format!("Archive checksum mismatch: {}.{}", namespace.0, namespace.1));
}
finished[index] = true;
emit(StreamEvent::End(mapped[index]))?;
continue;
}
if checksum != 0 {
return Err("Non-EOF archive header contains a checksum".into());
}
let entry = &entries[index];
loop {
check()?;
match read_raw_frame(reader)? {
Frame::Document(bytes) => {
if entry.metadata.is_view() {
return Err("Archive contains BSON documents for a view".into());
}
digests[index].update(&bytes);
emit(StreamEvent::Document(mapped[index], bytes))?;
}
Frame::Terminator => break,
Frame::End => return Err("Truncated archive namespace segment".into()),
}
}
}
for (index, entry) in entries.iter().enumerate() {
if !finished[index] {
return Err(format!("Missing archive EOF: {}.{}", entry.database, entry.metadata.collection_name));
}
}
Ok(())
}
pub(super) fn pack(
writer: &mut dyn Write,
entries: &[PreparedCollection],
server_version: &str,
mut check: impl FnMut() -> Result<(), String>,
) -> Result<(), String> {
writer.write_all(&MAGIC.to_le_bytes()).map_err(|e| e.to_string())?;
write_document(
writer,
&doc! { "concurrent_collections": 1i32, "version": "0.1", "server_version": server_version, "tool_version": concat!("DBX ", env!("CARGO_PKG_VERSION")) },
)?;
for entry in entries {
check()?;
write_document(
writer,
&doc! { "db": &entry.database, "collection": &entry.metadata.collection_name, "metadata": entry.metadata.to_json()?, "size": entry.documents as i64, "type": if entry.metadata.is_view() { "view" } else { "" } },
)?;
}
writer.write_all(&TERMINATOR).map_err(|e| e.to_string())?;
for entry in entries {
check()?;
if entry.metadata.is_view() {
write_document(
writer,
&doc! { "db": &entry.database, "collection": &entry.metadata.collection_name, "EOF": true, "CRC": 0i64 },
)?;
writer.write_all(&TERMINATOR).map_err(|e| e.to_string())?;
continue;
}
let mut digest = CRC.digest();
let path = entry.path.as_ref().ok_or_else(|| "Missing collection BSON file".to_string())?;
let mut reader = BufReader::new(File::open(path).map_err(|e| e.to_string())?);
let mut opened = false;
loop {
check()?;
match read_frame(&mut reader)? {
Frame::End => break,
Frame::Terminator => return Err("Unexpected terminator in BSON file".into()),
Frame::Document(bytes) => {
if !opened {
write_document(
writer,
&doc! { "db": &entry.database, "collection": &entry.metadata.collection_name, "EOF": false, "CRC": 0i64 },
)?;
opened = true;
}
writer.write_all(&bytes).map_err(|e| e.to_string())?;
digest.update(&bytes);
}
}
}
if opened {
writer.write_all(&TERMINATOR).map_err(|e| e.to_string())?;
}
write_document(
writer,
&doc! { "db": &entry.database, "collection": &entry.metadata.collection_name, "EOF": true, "CRC": digest.finalize() as i64 },
)?;
writer.write_all(&TERMINATOR).map_err(|e| e.to_string())?;
}
writer.flush().map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checksum_matches_go_crc64_ecma() {
assert_eq!(CRC.checksum(b"123456789"), 0x995d_c9bb_df19_39fa);
}
}
@@ -0,0 +1,302 @@
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use flate2::{read::MultiGzDecoder, write::GzEncoder, Compression};
use super::{
archive::MAX_DOCUMENT,
metadata::{self, CollectionMetadata},
PreparedCollection,
};
pub(super) fn reader(path: &Path, gzip: bool) -> Result<Box<dyn Read>, String> {
regular_file(path)?;
let reader = BufReader::new(File::open(path).map_err(|e| e.to_string())?);
if gzip {
Ok(Box::new(BufReader::with_capacity(64 * 1024, MultiGzDecoder::new(reader))))
} else {
Ok(Box::new(reader))
}
}
fn reject_link(path: &Path) -> Result<std::fs::Metadata, String> {
let metadata = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?;
let is_link = metadata.file_type().is_symlink();
#[cfg(windows)]
let is_link = {
use std::os::windows::fs::MetadataExt;
is_link || metadata.file_attributes() & 0x400 != 0
};
if is_link {
return Err(format!("Dump sources cannot contain links: {}", path.display()));
}
Ok(metadata)
}
pub(super) fn regular_file(path: &Path) -> Result<(), String> {
if !reject_link(path)?.is_file() {
return Err(format!("Not a regular dump file: {}", path.display()));
}
Ok(())
}
pub(super) fn copy_stream(
reader: &mut dyn Read,
writer: &mut dyn Write,
check: &mut impl FnMut() -> Result<(), String>,
) -> Result<(), String> {
let mut buffer = [0; 64 * 1024];
loop {
check()?;
let length = reader.read(&mut buffer).map_err(|e| e.to_string())?;
if length == 0 {
break;
}
writer.write_all(&buffer[..length]).map_err(|e| e.to_string())?;
}
Ok(())
}
pub(super) fn write_file(
path: &Path,
gzip: bool,
write: impl FnOnce(&mut dyn Write) -> Result<(), String>,
) -> Result<(), String> {
let file = std::io::BufWriter::new(File::create(path).map_err(|e| e.to_string())?);
if gzip {
let mut writer = GzEncoder::new(file, Compression::default());
write(&mut writer)?;
writer.finish().map_err(|e| e.to_string())?.flush().map_err(|e| e.to_string())
} else {
let mut writer = file;
write(&mut writer)?;
writer.flush().map_err(|e| e.to_string())
}
}
#[derive(Default)]
struct Files {
data: Option<PathBuf>,
metadata: Option<PathBuf>,
}
fn collect(
path: &Path,
depth: usize,
gzip: bool,
groups: &mut BTreeMap<(String, String), Files>,
) -> Result<(), String> {
if depth > 3 || !reject_link(path)?.is_dir() {
return Err("Invalid dump directory layout".into());
}
let database =
path.file_name().and_then(|name| name.to_str()).ok_or_else(|| "Invalid database directory name".to_string())?;
let mut subdirectories = Vec::new();
let mut has_data = false;
for entry in std::fs::read_dir(path).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let info = reject_link(&path)?;
if info.is_dir() {
subdirectories.push(path);
continue;
}
if !info.is_file() {
return Err("Unsupported dump directory entry".into());
}
let filename = entry.file_name().to_str().ok_or_else(|| "Invalid dump filename".to_string())?.to_string();
let (base, metadata_file, compressed) = if let Some(base) = filename.strip_suffix(".metadata.json.gz") {
(base, true, true)
} else if let Some(base) = filename.strip_suffix(".bson.gz") {
(base, false, true)
} else if let Some(base) = filename.strip_suffix(".metadata.json") {
(base, true, false)
} else if let Some(base) = filename.strip_suffix(".bson") {
(base, false, false)
} else {
continue;
};
if compressed != gzip {
return Err(format!("Gzip option does not match dump file {filename}"));
}
metadata::validate_database(database)?;
has_data = true;
let group = groups.entry((database.into(), base.into())).or_default();
let slot = if metadata_file { &mut group.metadata } else { &mut group.data };
if slot.replace(path).is_some() {
return Err("Duplicate dump filename".into());
}
if groups.len() > 100_000 {
return Err("Too many dump collections".into());
}
}
if has_data && !subdirectories.is_empty() {
return Err(
"Mixed database files and directories; select a single database directory or a dump root without oplog"
.into(),
);
}
for directory in subdirectories {
collect(&directory, depth + 1, gzip, groups)?;
}
Ok(())
}
pub(super) fn inspect(
path: &Path,
gzip: bool,
manifest: Option<&[super::MongoDumpFile]>,
) -> Result<Vec<PreparedCollection>, String> {
let mut groups = BTreeMap::new();
if let Some(manifest) = manifest {
let mut seen = std::collections::HashSet::new();
for file in manifest {
let relative = super::source::relative_path(&file.path)?;
if !seen.insert(file.path.clone()) {
return Err("Duplicate dump manifest path".into());
}
let database = relative
.parent()
.and_then(Path::file_name)
.and_then(|s| s.to_str())
.ok_or("Directory uploads require database-relative paths")?;
metadata::validate_database(database)?;
let filename = relative.file_name().unwrap().to_str().ok_or("Invalid dump filename")?;
let plain = filename.strip_suffix(".gz").unwrap_or(filename);
let (base, is_metadata) = if let Some(base) = plain.strip_suffix(".metadata.json") {
(base, true)
} else if let Some(base) = plain.strip_suffix(".bson") {
(base, false)
} else {
continue;
};
if filename.ends_with(".gz") != gzip {
return Err(format!("Gzip option does not match dump file {filename}"));
}
let group = groups.entry((database.to_string(), base.to_string())).or_insert_with(Files::default);
let slot = if is_metadata { &mut group.metadata } else { &mut group.data };
if slot.replace(path.join(relative)).is_some() {
return Err("Duplicate dump namespace".into());
}
}
} else {
collect(path, 0, gzip, &mut groups)?;
}
let mut entries = Vec::new();
let root = path;
let mut namespaces = std::collections::HashSet::new();
let mut metadata_bytes = 0usize;
for ((database, basename), files) in groups {
// Truncated official filenames may end mid-escape; collectionName is authoritative.
let fallback = metadata::unescape_collection(&basename).unwrap_or_default();
let metadata = if let Some(path) = &files.metadata {
let mut json = String::new();
reader(path, gzip)?.take(MAX_DOCUMENT as u64 + 1).read_to_string(&mut json).map_err(|e| e.to_string())?;
if json.len() > MAX_DOCUMENT {
return Err("Collection metadata exceeds 16 MiB".into());
}
metadata_bytes = metadata_bytes.saturating_add(json.len());
if metadata_bytes > 128 * 1024 * 1024 {
return Err("Dump metadata exceeds 128 MiB".into());
}
if json.trim().is_empty() {
CollectionMetadata::empty(&fallback)
} else {
CollectionMetadata::from_json(&json, &fallback)?
}
} else {
CollectionMetadata::empty(&fallback)
};
metadata.validate()?;
if !namespaces.insert((database.clone(), metadata.collection_name.clone())) {
return Err("Duplicate dump namespace".into());
}
let mut entry = PreparedCollection {
database,
metadata,
path: None,
documents: 0,
size_bytes: 0,
metadata_path: files.metadata,
};
match files.data {
Some(path) => {
if entry.metadata.is_view() {
return Err("View dump unexpectedly contains BSON data".into());
}
entry.size_bytes = if let Some(manifest) = manifest {
manifest.iter().find(|f| path == root.join(&f.path)).map(|f| f.size_bytes).unwrap_or(0)
} else {
regular_file(&path)?;
std::fs::metadata(&path).map_err(|e| e.to_string())?.len()
};
entry.path = Some(path);
}
None if !entry.metadata.is_view() => {
return Err(format!("Missing BSON file for {}", entry.metadata.collection_name))
}
None => {}
}
entries.push(entry);
}
Ok(entries)
}
pub(super) fn publish_files(
root: &Path,
database: &str,
entries: &[PreparedCollection],
gzip: bool,
mut check: impl FnMut() -> Result<(), String>,
) -> Result<(), String> {
let database_dir = root.join(database);
std::fs::create_dir_all(&database_dir).map_err(|e| e.to_string())?;
let mut names = std::collections::HashSet::new();
for entry in entries {
check()?;
let basename = metadata::escaped_collection(&entry.metadata.collection_name);
let filesystem_key = if cfg!(windows) { basename.to_lowercase() } else { basename.clone() };
if !names.insert(filesystem_key) {
return Err("Collection names collide on this filesystem; use archive format".into());
}
let suffix = if gzip { ".gz" } else { "" };
write_file(&database_dir.join(format!("{basename}.metadata.json{suffix}")), gzip, |writer| {
writer.write_all(entry.metadata.to_json()?.as_bytes()).map_err(|e| e.to_string())
})?;
if let Some(path) = &entry.path {
write_file(&database_dir.join(format!("{basename}.bson{suffix}")), gzip, |writer| {
copy_stream(&mut *reader(path, false)?, writer, &mut check)
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regular_file_accepts_files_and_rejects_directories() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join("records.bson");
std::fs::write(&file, []).unwrap();
regular_file(&file).unwrap();
assert!(regular_file(root.path()).unwrap_err().contains("Not a regular dump file"));
}
#[cfg(unix)]
#[test]
fn reject_link_rejects_file_and_directory_symlinks() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join("records.bson");
std::fs::write(&file, []).unwrap();
for (name, target) in [("file-link", file.as_path()), ("directory-link", root.path())] {
let link = root.path().join(name);
std::os::unix::fs::symlink(target, &link).unwrap();
assert!(reject_link(&link).unwrap_err().contains("Dump sources cannot contain links"));
}
}
}
@@ -0,0 +1,190 @@
use base64::Engine;
use mongodb::bson::{doc, Bson, Document};
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CollectionMetadata {
#[serde(default)]
pub options: Document,
#[serde(default)]
pub indexes: Vec<Document>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
#[serde(default)]
pub collection_name: String,
#[serde(default, rename = "type")]
pub kind: String,
}
impl CollectionMetadata {
pub fn empty(name: &str) -> Self {
Self {
options: Document::new(),
indexes: Vec::new(),
uuid: None,
collection_name: name.into(),
kind: "collection".into(),
}
}
pub fn from_json(json: &str, name: &str) -> Result<Self, String> {
let value = serde_json::from_str(json).map_err(|e| format!("Invalid metadata for {name}: {e}"))?;
let document = crate::db::mongo_driver::json_object_to_document_extended_json(&value)?;
let mut metadata: Self =
mongodb::bson::from_document(document).map_err(|e| format!("Invalid metadata for {name}: {e}"))?;
if metadata.collection_name.is_empty() {
metadata.collection_name = name.into();
}
if metadata.kind.is_empty() {
metadata.kind = if metadata.options.contains_key("viewOn") { "view" } else { "collection" }.into();
}
metadata.validate()?;
Ok(metadata)
}
pub fn to_json(&self) -> Result<String, String> {
let document = mongodb::bson::to_document(self).map_err(|e| e.to_string())?;
let json = crate::db::mongo_driver::document_to_canonical_extended_json(&document).to_string();
if json.len() > super::archive::MAX_DOCUMENT {
return Err("Collection metadata exceeds 16 MiB".into());
}
Ok(json)
}
pub fn is_view(&self) -> bool {
self.kind == "view"
}
pub fn validate(&self) -> Result<(), String> {
validate_collection(&self.collection_name)?;
let parsed_options: mongodb::options::CreateCollectionOptions =
mongodb::bson::from_document(self.options.clone())
.map_err(|e| format!("Invalid collection options for {}: {e}", self.collection_name))?;
let known_options = mongodb::bson::to_document(&parsed_options).map_err(|e| e.to_string())?;
for key in self.options.keys() {
if !known_options.contains_key(key) && key != "autoIndexId" && key != "recordIdsReplicated" {
return Err(format!("Unsupported collection option for {}: {key}", self.collection_name));
}
}
if self.kind == "timeseries"
|| self.options.contains_key("timeseries")
|| self.options.contains_key("encryptedFields")
{
return Err(format!(
"{}: time-series and encrypted collection dumps require server-specific restore support",
self.collection_name
));
}
if self.kind != "collection" && self.kind != "view" {
return Err(format!("Unsupported collection type: {}", self.kind));
}
if self.is_view() {
self.options
.get_str("viewOn")
.map_err(|_| format!("{}: view metadata is missing viewOn", self.collection_name))?;
if !self.indexes.is_empty() {
return Err("View metadata cannot contain indexes".into());
}
}
for index in &self.indexes {
index.get_document("key").map_err(|_| "Index metadata is missing key".to_string())?;
index.get_str("name").map_err(|_| "Index metadata is missing name".to_string())?;
}
Ok(())
}
pub fn create_command(&self, restore_options: bool) -> Document {
let mut command = doc! { "create": &self.collection_name };
if restore_options || self.is_view() {
let mut options = self.options.clone();
options.remove("uuid");
options.remove("recordIdsReplicated");
options.remove("autoIndexId");
// Keep create as the first command key and never accept it from metadata.
options.remove("create");
command.extend(options);
}
command
}
pub fn restore_indexes(&self) -> Vec<Document> {
self.indexes
.iter()
.filter(|index| {
let key = index.get_document("key").expect("validated index");
!(key.len() == 1
&& matches!(key.get("_id"), Some(Bson::Int32(1) | Bson::Int64(1)) | Some(Bson::Double(1.0))))
})
.map(|index| {
let mut index = index.clone();
index.remove("ns");
index.remove("v");
index.remove("background");
index
})
.collect()
}
}
pub fn validate_database(name: &str) -> Result<(), String> {
if name.is_empty() || name.len() > 63 || name.chars().any(|c| c.is_control() || " /\\.\"$*<>:|?".contains(c)) {
return Err("Invalid MongoDB database name".into());
}
if matches!(name, "admin" | "config" | "local") {
return Err(
"Internal MongoDB databases and user/role migration are not supported by database dump/restore".into()
);
}
Ok(())
}
pub fn validate_collection(name: &str) -> Result<(), String> {
if name.is_empty()
|| name.contains('\0')
|| name.contains('$')
|| (name.starts_with("system.") && name != "system.js")
{
return Err(format!("Unsupported MongoDB collection name: {name}"));
}
Ok(())
}
pub fn escaped_collection(name: &str) -> String {
const ESCAPE: &percent_encoding::AsciiSet =
&percent_encoding::NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
let mut escaped = percent_encoding::utf8_percent_encode(name, ESCAPE).to_string().replace("%20", "+");
let stem = escaped.split('.').next().unwrap_or_default().to_ascii_uppercase();
if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem.len() == 4
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
{
escaped = format!("%{:02X}{}", escaped.as_bytes()[0], &escaped[1..]);
}
if escaped.len() > 238 {
let hash = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha1::digest(name.as_bytes()));
escaped = format!("{}%24{hash}", &escaped[..208]);
}
escaped
}
pub fn unescape_collection(name: &str) -> Result<String, String> {
let bytes = name.as_bytes();
let mut position = 0;
while position < bytes.len() {
if bytes[position] == b'%' {
if position + 2 >= bytes.len() || !bytes[position + 1..position + 3].iter().all(u8::is_ascii_hexdigit) {
return Err("Invalid escaped collection filename".into());
}
position += 3;
} else {
position += 1;
}
}
percent_encoding::percent_decode_str(&name.replace('+', " "))
.decode_utf8()
.map(|v| v.into_owned())
.map_err(|e| e.to_string())
}
+247
View File
@@ -0,0 +1,247 @@
use std::collections::HashMap;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
use serde::{Deserialize, Serialize};
use super::{
archive, catalog, directory, ordered_entries, select_entries, MongoDatabaseRestoreRequest, MongoDumpFormat,
MongoRestoreSourcePreview, MongoRestoreSourceRequest, PreparedCollection,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoDumpFile {
pub path: String,
pub size_bytes: u64,
}
pub(super) struct CatalogSource {
_owner: Option<tempfile::TempDir>,
request: MongoRestoreSourceRequest,
pub entries: Vec<PreparedCollection>,
identities: HashMap<PathBuf, (u64, Option<SystemTime>)>,
manifest: Option<Vec<MongoDumpFile>>,
created: Instant,
}
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<CatalogSource>>>> = OnceLock::new();
fn sources() -> &'static Mutex<HashMap<String, Arc<CatalogSource>>> {
SOURCES.get_or_init(Default::default)
}
pub(super) fn get(reference: &str) -> Result<Arc<CatalogSource>, String> {
let source = sources()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(reference)
.cloned()
.ok_or("Restore source is no longer available")?;
if source.created.elapsed() >= Duration::from_secs(86400) {
return Err("Restore source has expired".into());
}
Ok(source)
}
pub(super) fn release(reference: &str) -> bool {
sources().lock().unwrap_or_else(|e| e.into_inner()).remove(reference).is_some()
}
pub(super) fn relative_path(value: &str) -> Result<&Path, String> {
let path = Path::new(value);
if value.is_empty()
|| value.contains(['\\', ':', '\0'])
|| path.components().count() > 4
|| path.components().any(|c| !matches!(c, Component::Normal(_)))
{
return Err("Invalid dump upload path".into());
}
Ok(path)
}
fn identity(path: &Path) -> Result<(u64, Option<SystemTime>), String> {
directory::regular_file(path)?;
let info = std::fs::metadata(path).map_err(|e| e.to_string())?;
Ok((info.len(), info.modified().ok()))
}
pub(super) async fn prepare(
request: MongoRestoreSourceRequest,
owner: Option<tempfile::TempDir>,
manifest: Option<Vec<MongoDumpFile>>,
) -> Result<MongoRestoreSourcePreview, String> {
tokio::task::spawn_blocking(move || {
if manifest.as_ref().is_some_and(|m| m.len() > 100_000) {
return Err("Too many dump files".into());
}
let path = Path::new(&request.path);
let entries = match request.format {
MongoDumpFormat::Directory => directory::inspect(path, request.gzip, manifest.as_deref())?,
MongoDumpFormat::Archive => archive::inspect(&mut *directory::reader(path, request.gzip)?, || Ok(()))?,
};
ordered_entries(&entries)?;
let mut catalog = catalog(&entries, false);
let mut identities = HashMap::new();
for (entry, preview) in entries.iter().zip(catalog.collections.iter_mut()) {
for file in entry.path.iter().chain(entry.metadata_path.iter()) {
if manifest.is_none() || entry.metadata_path.as_ref() == Some(file) {
identities.insert(file.clone(), identity(file)?);
}
if let Ok(relative) = file.strip_prefix(path) {
preview.source_files.push(relative.to_string_lossy().replace('\\', "/"));
}
}
}
if request.format == MongoDumpFormat::Archive {
identities.insert(path.to_owned(), identity(path)?);
}
let source =
Arc::new(CatalogSource { _owner: owner, request, entries, identities, manifest, created: Instant::now() });
let mut sources = sources().lock().unwrap_or_else(|e| e.into_inner());
sources.retain(|_, s| s.created.elapsed() < Duration::from_secs(86400));
if sources.len() >= 32 {
return Err("Too many prepared restores; close or release an existing source".into());
}
let source_ref = uuid::Uuid::new_v4().to_string();
sources.insert(source_ref.clone(), source);
Ok(MongoRestoreSourcePreview { source_ref, catalog })
})
.await
.map_err(|e| e.to_string())?
}
pub async fn prepare_owned_mongodb_restore_source(
request: MongoRestoreSourceRequest,
owner: tempfile::TempDir,
) -> Result<MongoRestoreSourcePreview, String> {
prepare(request, Some(owner), None).await
}
pub async fn prepare_mongodb_directory_catalog(
owner: tempfile::TempDir,
manifest: Vec<MongoDumpFile>,
gzip: bool,
) -> Result<MongoRestoreSourcePreview, String> {
let request = MongoRestoreSourceRequest {
path: owner.path().to_string_lossy().into_owned(),
format: MongoDumpFormat::Directory,
gzip,
};
prepare(request, Some(owner), Some(manifest)).await
}
fn same_metadata(a: &PreparedCollection, b: &PreparedCollection) -> Result<bool, String> {
Ok(a.database == b.database && a.metadata.to_json()? == b.metadata.to_json()? && a.size_bytes == b.size_bytes)
}
/// The server, not the upload payload, determines which files the confirmed selection requires.
pub fn mongodb_restore_upload_files(request: &MongoDatabaseRestoreRequest) -> Result<Vec<MongoDumpFile>, String> {
let source = get(&request.source_ref)?;
let manifest = source.manifest.as_ref().ok_or("Source does not require a directory upload")?;
let paths = selected(&source, request)?
.into_iter()
.flat_map(|e| e.path.into_iter().chain(e.metadata_path))
.collect::<Vec<_>>();
Ok(manifest.iter().filter(|f| paths.contains(&Path::new(&source.request.path).join(&f.path))).cloned().collect())
}
/// Bind the complete selected upload to the reviewed catalog.
pub async fn attach_mongodb_restore_upload(
request: &MongoDatabaseRestoreRequest,
owner: tempfile::TempDir,
) -> Result<MongoRestoreSourcePreview, String> {
let original = get(&request.source_ref)?;
if original.manifest.is_none() {
return Err("Source does not require a directory upload".into());
}
let selected = selected(&original, request)?;
let next = prepare_owned_mongodb_restore_source(
MongoRestoreSourceRequest {
path: owner.path().to_string_lossy().into_owned(),
format: MongoDumpFormat::Directory,
gzip: original.request.gzip,
},
owner,
)
.await?;
let uploaded = get(&next.source_ref)?;
let matches =
(|| -> Result<bool, String> {
if selected.len() != uploaded.entries.len() {
return Ok(false);
}
for entry in &selected {
let Some(found) = uploaded.entries.iter().find(|e| {
e.database == entry.database && e.metadata.collection_name == entry.metadata.collection_name
}) else {
return Ok(false);
};
if !same_metadata(entry, found)? {
return Ok(false);
}
let expected = entry
.path
.iter()
.chain(entry.metadata_path.iter())
.map(|p| p.strip_prefix(&original.request.path).unwrap().to_owned())
.collect::<Vec<_>>();
let actual = found
.path
.iter()
.chain(found.metadata_path.iter())
.map(|p| p.strip_prefix(&uploaded.request.path).unwrap().to_owned())
.collect::<Vec<_>>();
if expected != actual {
return Ok(false);
}
}
Ok(true)
})();
if matches != Ok(true) {
release(&next.source_ref);
return Err("Uploaded files differ from the confirmed catalog; read the backup again".into());
}
Ok(next)
}
fn selected(source: &CatalogSource, request: &MongoDatabaseRestoreRequest) -> Result<Vec<PreparedCollection>, String> {
let entries: Vec<_> = source.entries.iter().filter(|e| e.database == request.source_database).cloned().collect();
select_entries(&entries, &request.collections)
}
fn unchanged(source: &CatalogSource, path: &Path) -> Result<(), String> {
if source.identities.get(path) != Some(&identity(path)?) {
return Err("Backup source changed; read the backup again".into());
}
Ok(())
}
impl CatalogSource {
pub(super) fn format(&self) -> MongoDumpFormat {
self.request.format
}
pub(super) fn check_ready(&self, entries: &[PreparedCollection]) -> Result<(), String> {
if self.manifest.is_some() {
return Err("Selected directory data has not been uploaded".into());
}
if self.request.format == MongoDumpFormat::Archive {
unchanged(self, Path::new(&self.request.path))?;
} else {
for entry in entries {
for path in entry.path.iter().chain(entry.metadata_path.iter()) {
unchanged(self, path)?;
}
}
}
Ok(())
}
pub(super) fn reader(&self, path: Option<&Path>) -> Result<Box<dyn Read>, String> {
let path = path.unwrap_or_else(|| Path::new(&self.request.path));
unchanged(self, path)?;
directory::reader(path, self.request.gzip)
}
}
+377
View File
@@ -0,0 +1,377 @@
use std::collections::HashSet;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
use std::time::{Duration, Instant};
use mongodb::bson::{Document, RawDocumentBuf};
use tokio::sync::mpsc;
use super::{
archive, cancelled, source::CatalogSource, CancelFuture, MongoDatabaseDumpProgress, MongoDatabaseRestoreRequest,
MongoDumpFormat, PreparedCollection,
};
use crate::{connection::AppState, db::mongo_driver::insert_bson_documents};
const BATCH_BYTES: usize = 8 * 1024 * 1024;
enum Event {
Batch { index: usize, documents: Vec<RawDocumentBuf> },
End(usize),
}
struct Batcher {
tx: mpsc::Sender<Event>,
index: usize,
documents: Vec<RawDocumentBuf>,
bytes: usize,
limit: usize,
objcheck: bool,
}
impl Batcher {
fn flush(&mut self) -> Result<(), String> {
if self.documents.is_empty() {
return Ok(());
}
let documents = std::mem::take(&mut self.documents);
self.bytes = 0;
self.tx
.blocking_send(Event::Batch { index: self.index, documents })
.map_err(|_| "MongoDB dump/restore cancelled".into())
}
fn push(&mut self, index: usize, bytes: Vec<u8>) -> Result<(), String> {
if index != self.index || self.bytes.saturating_add(bytes.len()) > BATCH_BYTES {
self.flush()?;
}
self.index = index;
let document = checked_document(bytes, self.objcheck)?;
self.bytes += document.as_bytes().len();
self.documents.push(document);
if self.documents.len() >= self.limit {
self.flush()?;
}
Ok(())
}
fn end(&mut self, index: usize) -> Result<(), String> {
self.flush()?;
self.tx.blocking_send(Event::End(index)).map_err(|_| "MongoDB dump/restore cancelled".into())
}
}
// Like mongorestore's objcheck, deep parsing is optional and occurs in the stream,
// immediately before a document is queued for insertion. No whole-backup pass.
fn checked_document(bytes: Vec<u8>, objcheck: bool) -> Result<RawDocumentBuf, String> {
let raw = RawDocumentBuf::from_bytes(bytes).map_err(|e| format!("Invalid BSON document: {e}"))?;
if objcheck {
mongodb::bson::from_slice::<Document>(raw.as_bytes()).map_err(|e| format!("Invalid BSON document: {e}"))?;
}
Ok(raw)
}
fn produce(
source: Arc<CatalogSource>,
entries: Vec<PreparedCollection>,
flag: Arc<AtomicBool>,
bytes_read: Arc<AtomicU64>,
mut batcher: Batcher,
) -> Result<(), String> {
let result = (|| {
source.check_ready(&entries)?;
if source.format() == MongoDumpFormat::Archive {
let indices = source
.entries
.iter()
.map(|original| {
entries.iter().position(|e| {
e.database == original.database
&& e.metadata.collection_name == original.metadata.collection_name
&& !e.metadata.is_view()
})
})
.collect::<Vec<_>>();
archive::stream(
&mut *source.reader(None)?,
&source.entries,
|| cancelled(&flag),
|event| {
match event {
archive::StreamEvent::Document(i, bytes) => {
bytes_read.fetch_add(bytes.len() as u64, Ordering::Relaxed);
if let Some(index) = indices[i] {
batcher.push(index, bytes)?;
}
}
archive::StreamEvent::End(i) => {
if let Some(index) = indices[i] {
batcher.end(index)?;
}
}
}
Ok(())
},
)?;
} else {
for (index, entry) in entries.iter().enumerate().filter(|(_, e)| !e.metadata.is_view()) {
cancelled(&flag)?;
let path = entry.path.as_deref().ok_or("Missing collection BSON file")?;
let mut reader = source.reader(Some(path))?;
loop {
cancelled(&flag)?;
match archive::read_raw_frame(&mut *reader)? {
archive::Frame::End => break,
archive::Frame::Terminator => return Err("Unexpected terminator in collection BSON".into()),
archive::Frame::Document(bytes) => {
bytes_read.fetch_add(bytes.len() as u64, Ordering::Relaxed);
batcher.push(index, bytes)?;
}
}
}
batcher.end(index)?;
}
}
Ok(())
})();
// Deliver valid earlier documents even if a later frame is corrupt, matching
// the streaming import contract and reporting partial writes accurately.
let flushed = batcher.flush();
result.and(flushed)
}
pub(super) async fn initialize_collection(
client: &mongodb::Client,
request: &MongoDatabaseRestoreRequest,
entry: &PreparedCollection,
exists: bool,
) -> Result<(), String> {
let db = client.database(&request.database);
if request.drop_existing && exists {
db.collection::<Document>(&entry.metadata.collection_name).drop().await.map_err(|e| e.to_string())?;
}
if !exists || request.drop_existing {
db.run_command(entry.metadata.create_command(request.restore_options)).await.map_err(|e| e.to_string())?;
}
Ok(())
}
pub(super) async fn writable(state: &AppState, id: &str) -> Result<(), String> {
if let Some(name) = crate::query::connection_readonly_name(state, id).await {
return Err(format!("Read-only connection '{name}': restore blocked"));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn restore_data(
state: &AppState,
client: &mongodb::Client,
request: &MongoDatabaseRestoreRequest,
source: Arc<CatalogSource>,
entries: &[PreparedCollection],
existing: &HashSet<String>,
progress: &mut MongoDatabaseDumpProgress,
started: Instant,
is_cancelled: &mut impl FnMut(&str) -> CancelFuture,
on_progress: &mut impl FnMut(MongoDatabaseDumpProgress),
) -> Result<(), String> {
struct StopOnDrop(Arc<AtomicBool>);
impl Drop for StopOnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
let flag = Arc::new(AtomicBool::new(false));
let _guard = StopOnDrop(flag.clone());
let (tx, mut rx) = mpsc::channel(2);
let batcher = Batcher {
tx,
index: 0,
documents: Vec::new(),
bytes: 0,
limit: request.batch_size.max(1),
objcheck: request.objcheck,
};
let producer_entries = entries.to_vec();
let signal = flag.clone();
let bytes_read = Arc::new(AtomicU64::new(0));
let reader_progress = bytes_read.clone();
let producer =
tokio::task::spawn_blocking(move || produce(source, producer_entries, signal, reader_progress, batcher));
progress.phase = "data".into();
progress.emit(started, on_progress);
let result = async {
let mut initialized = HashSet::new();
let mut tick = tokio::time::interval(Duration::from_millis(100));
loop {
let event = tokio::select! {
event = rx.recv() => match event { Some(event) => event, None => break },
_ = tick.tick() => {
if is_cancelled(&request.task_id).await { return Err("MongoDB dump/restore cancelled".into()); }
progress.bytes_processed = bytes_read.load(Ordering::Relaxed);
progress.emit(started, on_progress);
continue;
},
};
if is_cancelled(&request.task_id).await {
return Err("MongoDB dump/restore cancelled".into());
}
writable(state, &request.connection_id).await?;
let index = match &event {
Event::Batch { index, .. } | Event::End(index) => *index,
};
let entry = &entries[index];
progress.phase = "data".into();
progress.collection = Some(entry.metadata.collection_name.clone());
progress.emit(started, on_progress);
if initialized.insert(index) {
initialize_collection(client, request, entry, existing.contains(&entry.metadata.collection_name))
.await?;
}
match event {
Event::Batch { documents, .. } => {
progress.documents_read += documents.len() as u64;
progress.bytes_processed = bytes_read.load(Ordering::Relaxed);
if request.objcheck {
progress.documents_validated += documents.len() as u64;
}
let outcome =
insert_bson_documents(client, &request.database, &entry.metadata.collection_name, documents)
.await
.map_err(|e| e.message)?;
progress.documents_written += outcome.inserted;
progress.documents_failed += outcome.errors.len() as u64;
progress.emit(started, on_progress);
if let Some(error) = outcome.errors.first().filter(|_| request.stop_on_error) {
return Err(error.message.clone());
}
}
Event::End(_) => {
progress.collections_done += 1;
progress.emit(started, on_progress);
}
}
}
Ok(())
}
.await;
flag.store(true, Ordering::Relaxed);
drop(rx);
let produced = producer.await.map_err(|e| e.to_string()).and_then(|r| r);
result.and(produced)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn objcheck_uses_official_bson_parser_only_when_enabled() {
// Valid outer framing but invalid boolean payload.
let bytes = vec![9, 0, 0, 0, 8, b'x', 0, 2, 0];
assert!(checked_document(bytes.clone(), false).is_ok());
assert!(checked_document(bytes, true).is_err());
assert!(checked_document(vec![5, 0, 0, 0, 1], false).is_err());
}
#[test]
fn raw_bson_preserves_duplicate_keys_and_exact_bytes() {
let bytes = vec![19, 0, 0, 0, 16, b'x', 0, 1, 0, 0, 0, 16, b'x', 0, 2, 0, 0, 0, 0];
for objcheck in [false, true] {
let raw = checked_document(bytes.clone(), objcheck).unwrap();
assert_eq!(raw.as_bytes(), bytes);
assert_eq!(mongodb::bson::to_vec(&raw).unwrap(), bytes);
}
}
#[tokio::test]
async fn reader_delivers_earlier_batches_before_reporting_corruption() {
use crate::mongodb_dump::{
prepare_mongodb_restore_source, release_mongodb_restore_source, MongoRestoreSourceRequest,
};
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("source");
std::fs::create_dir(&db).unwrap();
let mut bytes = mongodb::bson::to_vec(&mongodb::bson::doc! { "_id": 1 }).unwrap();
bytes.extend_from_slice(b"invalid BSON");
std::fs::write(db.join("records.bson"), bytes).unwrap();
let preview = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: db.to_string_lossy().into_owned(),
format: MongoDumpFormat::Directory,
gzip: false,
})
.await
.unwrap();
let source = super::super::source::get(&preview.source_ref).unwrap();
let entries = source.entries.clone();
let (tx, mut rx) = mpsc::channel(1);
let batcher = Batcher { tx, index: 0, documents: Vec::new(), bytes: 0, limit: 1, objcheck: false };
let worker = tokio::task::spawn_blocking(move || {
produce(source, entries, Arc::new(AtomicBool::new(false)), Arc::new(AtomicU64::new(0)), batcher)
});
assert!(matches!(rx.recv().await, Some(Event::Batch { documents, .. }) if documents.len() == 1));
assert!(worker.await.unwrap().unwrap_err().contains("BSON"));
assert!(rx.recv().await.is_none());
release_mongodb_restore_source(&preview.source_ref);
}
#[test]
fn batches_are_limited_by_bytes_as_well_as_document_count() {
let bytes = mongodb::bson::to_vec(&mongodb::bson::doc! { "payload": "x".repeat(5 * 1024 * 1024) }).unwrap();
let (tx, mut rx) = mpsc::channel(2);
let mut batcher = Batcher { tx, index: 0, documents: Vec::new(), bytes: 0, limit: 500, objcheck: false };
batcher.push(0, bytes.clone()).unwrap();
batcher.push(0, bytes).unwrap();
batcher.flush().unwrap();
for _ in 0..2 {
assert!(matches!(rx.try_recv().unwrap(), Event::Batch { documents, .. } if documents.len() == 1));
}
}
#[test]
#[ignore = "opt-in compressed BSON parser throughput smoke test"]
fn compressed_bson_parser_throughput() {
use flate2::{read::MultiGzDecoder, write::GzEncoder, Compression};
use std::io::{BufReader, Cursor, Write};
let mut document = Document::new();
document.insert("_id", 1i64);
for i in 0..64 {
document.insert(format!("field_{i}"), "value".repeat(8));
}
let encoded = mongodb::bson::to_vec(&document).unwrap();
let documents = 8192;
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
for _ in 0..documents {
encoder.write_all(&encoded).unwrap();
}
let compressed = encoder.finish().unwrap();
for (mode, objcheck, passes) in
[("raw-stream", false, 1), ("objcheck-stream", true, 1), ("old-double-parse", true, 2)]
{
let started = Instant::now();
let mut count = 0;
for _ in 0..passes {
let mut input = BufReader::with_capacity(64 * 1024, MultiGzDecoder::new(Cursor::new(&compressed)));
loop {
match archive::read_raw_frame(&mut input).unwrap() {
archive::Frame::Document(bytes) => {
checked_document(bytes, objcheck).unwrap();
count += 1;
}
archive::Frame::End => break,
_ => panic!("unexpected terminator"),
}
}
}
assert_eq!(count, documents * passes);
eprintln!(
"{mode}: logical_mib={:.2}, elapsed_ms={}, logical_mib_s={:.2}",
(documents * encoded.len()) as f64 / 1048576.0,
started.elapsed().as_millis(),
(documents * encoded.len()) as f64 / 1048576.0 / started.elapsed().as_secs_f64()
);
}
}
}
+344 -3
View File
@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};
use std::time::Instant;
use chrono::{DateTime as ChronoDateTime, NaiveDate, Utc};
use flate2::{read::MultiGzDecoder, write::GzEncoder, Compression};
use mongodb::bson::{oid::ObjectId, Bson, DateTime, Decimal128, Document};
use serde::{Deserialize, Serialize};
@@ -26,6 +27,7 @@ pub const MAX_BATCH_SIZE: usize = 5000;
/// Rows sampled for CSV type inference, independent of the preview window so that the
/// preview and the import always agree on column types.
pub const TYPE_SAMPLE_ROWS: usize = 1000;
const BSON_MAX_DOCUMENT_BYTES: usize = 16 * 1024 * 1024;
const TYPE_STRING: u8 = 1 << 0;
const TYPE_BOOLEAN: u8 = 1 << 1;
@@ -45,6 +47,7 @@ pub enum MongoImportFormat {
Csv,
Json,
Ndjson,
Bson,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -341,14 +344,16 @@ struct CsvParseConfig {
pub fn format_from_path(path: &str) -> Result<MongoImportFormat, String> {
let lower = path.to_lowercase();
if lower.ends_with(".csv") || lower.ends_with(".tsv") || lower.ends_with(".txt") {
if lower.ends_with(".bson") || lower.ends_with(".bson.gz") {
Ok(MongoImportFormat::Bson)
} else if lower.ends_with(".csv") || lower.ends_with(".tsv") || lower.ends_with(".txt") {
Ok(MongoImportFormat::Csv)
} else if lower.ends_with(".ndjson") || lower.ends_with(".jsonl") {
Ok(MongoImportFormat::Ndjson)
} else if lower.ends_with(".json") {
Ok(MongoImportFormat::Json)
} else {
Err("Unsupported MongoDB import file type; use .csv, .json, or .ndjson".to_string())
Err("Unsupported MongoDB import file type; use .bson, .bson.gz, .csv, .json, or .ndjson".to_string())
}
}
@@ -1348,6 +1353,72 @@ fn file_size(path: &str) -> u64 {
std::fs::metadata(path).map(|metadata| metadata.len()).unwrap_or(0)
}
fn open_bson_dump_reader(path: &str) -> Result<Box<dyn Read>, MongoImportIssue> {
let file = File::open(path).map_err(|error| MongoImportIssue::new("FILE_UNREADABLE", error.to_string()))?;
let reader = BufReader::new(file);
if path.to_lowercase().ends_with(".gz") {
Ok(Box::new(MultiGzDecoder::new(reader)))
} else {
Ok(Box::new(reader))
}
}
fn read_bson_dump_document<R: Read + ?Sized>(reader: &mut R, row: u64) -> Result<Option<Document>, MongoImportIssue> {
let mut length_bytes = [0u8; 4];
loop {
match reader.read(&mut length_bytes[..1]) {
Ok(0) => return Ok(None),
Ok(_) => break,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(MongoImportIssue::new("FILE_UNREADABLE", error.to_string()).with_row(row)),
}
}
reader.read_exact(&mut length_bytes[1..]).map_err(|error| {
MongoImportIssue::new("BSON_STRUCTURE", format!("Truncated BSON document length: {error}")).with_row(row)
})?;
let document_length = i32::from_le_bytes(length_bytes);
if document_length < 5 || document_length as usize > BSON_MAX_DOCUMENT_BYTES {
return Err(MongoImportIssue::new(
"BSON_STRUCTURE",
format!("Invalid BSON document length {document_length}; expected 5..={BSON_MAX_DOCUMENT_BYTES} bytes"),
)
.with_row(row));
}
let mut bytes = vec![0u8; document_length as usize];
bytes[..4].copy_from_slice(&length_bytes);
reader.read_exact(&mut bytes[4..]).map_err(|error| {
MongoImportIssue::new(
"BSON_STRUCTURE",
format!("Truncated BSON document; expected {document_length} bytes: {error}"),
)
.with_row(row)
})?;
let document = mongodb::bson::from_slice::<Document>(&bytes).map_err(|error| {
MongoImportIssue::new("BSON_STRUCTURE", format!("Invalid BSON document: {error}")).with_row(row)
})?;
Ok(Some(document))
}
fn parse_bson_preview(
path: &str,
preview_limit: usize,
) -> Result<(Vec<ParsedMongoDocument>, u64, bool), MongoImportIssue> {
let mut reader = open_bson_dump_reader(path)?;
let mut documents = Vec::with_capacity(preview_limit.min(DEFAULT_PREVIEW_LIMIT));
let mut row = 1u64;
while documents.len() <= preview_limit {
let Some(document) = read_bson_dump_document(&mut *reader, row)? else {
return Ok((documents, row - 1, true));
};
documents.push(parsed_document(row, document, true));
row += 1;
}
documents.truncate(preview_limit);
Ok((documents, preview_limit as u64, false))
}
pub fn preview_mongodb_import_file(
request: &MongoImportPreviewRequest,
) -> Result<MongoImportPreview, MongoImportIssue> {
@@ -1406,6 +1477,25 @@ pub fn preview_mongodb_import_file(
estimated_rows_exact,
})
}
MongoImportFormat::Bson => {
let (documents, estimated_rows, estimated_rows_exact) =
parse_bson_preview(&request.file_path, preview_limit)?;
Ok(MongoImportPreview {
source_ref: request.source_ref.clone(),
format: request.format,
detected_encoding: None,
file_name: file_name(&request.file_path),
file_path: request.file_path.clone(),
size_bytes: file_size(&request.file_path),
columns: columns_from_documents(&documents),
row_numbers: documents.iter().map(|document| document.row).collect(),
rows: documents.into_iter().map(|document| document.extended_json).collect(),
warnings: Vec::new(),
errors: Vec::new(),
estimated_rows: Some(estimated_rows),
estimated_rows_exact,
})
}
}
}
@@ -1419,6 +1509,7 @@ pub fn preview_mongodb_import_bytes(
MongoImportFormat::Csv => "csv",
MongoImportFormat::Json => "json",
MongoImportFormat::Ndjson => "ndjson",
MongoImportFormat::Bson => "bson",
};
let dir = std::env::temp_dir().join(format!("dbx-mongo-import-preview-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).map_err(|error| MongoImportIssue::new("FILE_UNREADABLE", error.to_string()))?;
@@ -1517,6 +1608,20 @@ where
Ok(())
}
fn stream_bson_file<F>(path: &str, mut on_document: F) -> Result<(), MongoImportIssue>
where
F: FnMut(Result<ParsedMongoDocument, MongoImportIssue>) -> Result<(), MongoImportIssue>,
{
let mut reader = open_bson_dump_reader(path)?;
let mut row = 1u64;
loop {
// A corrupt frame has no reliable next-document boundary, even when skipping row errors.
let Some(document) = read_bson_dump_document(&mut *reader, row)? else { return Ok(()) };
on_document(Ok(parsed_document(row, document, false)))?;
row += 1;
}
}
pub fn for_each_mongodb_import_document<F>(
path: &str,
format: MongoImportFormat,
@@ -1531,6 +1636,7 @@ where
MongoImportFormat::Csv => stream_csv_file(path, options, on_document),
MongoImportFormat::Json => stream_json_file(path, options, false, on_document),
MongoImportFormat::Ndjson => stream_json_file(path, options, true, on_document),
MongoImportFormat::Bson => stream_bson_file(path, on_document),
}
}
@@ -1929,6 +2035,7 @@ pub fn mongodb_export_client_session_id(export_id: &str) -> String {
pub enum MongoExportFormat {
Csv,
Ndjson,
Bson,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1958,6 +2065,8 @@ pub struct MongoExportRequest {
pub format: MongoExportFormat,
#[serde(default = "default_true")]
pub include_header: bool,
#[serde(default)]
pub gzip: bool,
pub file_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_id: Option<String>,
@@ -1992,7 +2101,7 @@ pub struct MongoExportSummary {
fn temp_export_path(target: &Path) -> PathBuf {
let name = target.file_name().and_then(|name| name.to_str()).unwrap_or("export");
target.with_file_name(format!(".{name}.dbx-export.tmp"))
target.with_file_name(format!(".{name}.{}.dbx-export.tmp", uuid::Uuid::new_v4()))
}
fn cleanup_temp(path: &Path) {
@@ -2214,6 +2323,9 @@ where
{
let started_at = Instant::now();
on_progress(export_progress(&request.export_id, MongoExportStatus::Running, 0, 0, None, None, started_at));
if request.gzip && request.format != MongoExportFormat::Bson {
return Err("Gzip compression is only supported for BSON dump exports".to_string());
}
if is_cancelled(&request.export_id).await {
on_progress(export_progress(
&request.export_id,
@@ -2250,6 +2362,9 @@ where
MongoExportFormat::Csv => {
export_csv(state, request, &temp, total_documents, started_at, &mut is_cancelled, &mut on_progress).await
}
MongoExportFormat::Bson => {
export_bson(state, request, &temp, total_documents, started_at, &mut is_cancelled, &mut on_progress).await
}
};
match result {
@@ -2685,6 +2800,120 @@ where
Ok((documents_read, bytes_written))
}
enum BsonExportWriter {
Plain(BufWriter<File>),
Gzip(Box<GzEncoder<BufWriter<File>>>),
}
impl BsonExportWriter {
fn finish(self) -> Result<(), String> {
match self {
Self::Plain(mut writer) => writer.flush().map_err(|error| error.to_string()),
Self::Gzip(writer) => {
let mut writer = writer.finish().map_err(|error| error.to_string())?;
writer.flush().map_err(|error| error.to_string())
}
}
}
}
impl Write for BsonExportWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Self::Plain(writer) => writer.write(buf),
Self::Gzip(writer) => writer.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Self::Plain(writer) => writer.flush(),
Self::Gzip(writer) => writer.flush(),
}
}
}
async fn export_bson<C, F>(
state: &AppState,
request: &MongoExportRequest,
temp: &Path,
total_documents: Option<u64>,
started_at: Instant,
is_cancelled: &mut C,
on_progress: &mut F,
) -> Result<(u64, u64), String>
where
C: FnMut(&str) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>,
F: FnMut(MongoExportProgress),
{
if is_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
}
// BSON dumps must carry native BSON types, so they are written straight from the native
// driver's documents; the legacy agent's canonical Extended JSON round trip does not apply.
let client = match state.pool_handle(&request.connection_id).await {
Some(PoolKind::MongoDb(client)) => client,
_ => return Err("BSON dump export requires the native MongoDB driver".into()),
};
let file = File::create(temp).map_err(|error| error.to_string())?;
let buffered = BufWriter::new(file);
let mut writer = if request.gzip {
BsonExportWriter::Gzip(Box::new(GzEncoder::new(buffered, Compression::default())))
} else {
BsonExportWriter::Plain(buffered)
};
let mut documents_read = 0u64;
let mut bytes_written = 0u64;
let export = for_each_find_document(
&client,
&request.database,
&request.collection,
request.filter.as_deref(),
request.projection.as_deref(),
request.sort.as_deref(),
request.collation.as_deref(),
DEFAULT_EXPORT_BATCH_SIZE,
|document| {
let bytes = mongodb::bson::to_vec(&document).map_err(|error| error.to_string())?;
writer.write_all(&bytes).map_err(|error| error.to_string())?;
bytes_written += bytes.len() as u64;
documents_read += 1;
if documents_read == 1 || documents_read.is_multiple_of(500) {
on_progress(export_progress(
&request.export_id,
MongoExportStatus::Running,
documents_read,
bytes_written,
total_documents,
None,
started_at,
));
}
Ok(())
},
);
{
tokio::pin!(export);
let mut poll_cancel = tokio::time::interval(std::time::Duration::from_millis(100));
loop {
tokio::select! {
result = &mut export => { result?; break; }
_ = poll_cancel.tick() => {
if is_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
}
}
}
}
}
writer.finish()?;
if is_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
}
Ok((documents_read, bytes_written))
}
const CSV_FIELD_DISCOVERY_DOCS: usize = 10_000;
async fn export_csv<C, F>(
@@ -2847,6 +3076,112 @@ mod tests {
Ok(output)
}
fn bson_dump_bytes(documents: &[Document]) -> Vec<u8> {
let mut output = Vec::new();
for document in documents {
output.extend_from_slice(&mongodb::bson::to_vec(document).unwrap());
}
output
}
#[test]
fn bson_export_writer_finishes_plain_and_gzip_dumps() {
let root = tempfile::tempdir().unwrap();
let documents = vec![doc! { "_id": 1, "name": "first" }, doc! { "_id": 2, "value": 42i64 }];
for expected in [Vec::new(), bson_dump_bytes(&documents)] {
for gzip in [false, true] {
let path = root.path().join(if gzip { "records.bson.gz" } else { "records.bson" });
let buffered = BufWriter::new(File::create(&path).unwrap());
let mut writer = if gzip {
BsonExportWriter::Gzip(Box::new(GzEncoder::new(buffered, Compression::default())))
} else {
BsonExportWriter::Plain(buffered)
};
writer.write_all(&expected).unwrap();
writer.finish().unwrap();
let bytes = std::fs::read(&path).unwrap();
let actual = if gzip {
let mut decoded = Vec::new();
MultiGzDecoder::new(bytes.as_slice()).read_to_end(&mut decoded).unwrap();
decoded
} else {
bytes
};
assert_eq!(actual, expected);
}
}
}
#[test]
fn bson_dump_preview_and_import_preserve_native_types() {
use mongodb::bson::spec::BinarySubtype;
use mongodb::bson::{Binary, DateTime};
let documents = vec![
doc! {
"_id": ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap(),
"createdAt": DateTime::from_millis(1_609_459_200_000),
"payload": Bson::Binary(Binary { subtype: BinarySubtype::Generic, bytes: vec![1, 2, 3] }),
"nested": { "count": 42i64 },
},
doc! { "_id": 2i32, "name": "second" },
];
let bytes = bson_dump_bytes(&documents);
let preview =
preview_mongodb_import_bytes(&bytes, MongoImportFormat::Bson, &MongoImportParseOptions::default(), 10)
.unwrap();
assert_eq!(preview.rows.len(), 2);
assert_eq!(preview.estimated_rows, Some(2));
assert!(preview.estimated_rows_exact);
assert_eq!(preview.rows[0]["_id"]["$oid"], "507f1f77bcf86cd799439011");
assert!(preview.rows[0]["payload"].get("$binary").is_some());
let imported = execute_source(&bytes, "bson", MongoImportFormat::Bson, &MongoImportParseOptions::default());
assert_eq!(imported, preview.rows);
}
#[test]
fn gzip_bson_dump_import_matches_plain_dump() {
let documents = vec![doc! { "_id": 1i32, "name": "Ada" }, doc! { "_id": 2i32, "name": "Bob" }];
let plain = bson_dump_bytes(&documents);
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&plain).unwrap();
let compressed = encoder.finish().unwrap();
let imported =
execute_source(&compressed, "bson.gz", MongoImportFormat::Bson, &MongoImportParseOptions::default());
assert_eq!(imported.len(), 2);
assert_eq!(imported[0]["name"], "Ada");
assert_eq!(imported[1]["name"], "Bob");
}
#[test]
fn bson_dump_rejects_truncated_and_invalid_lengths() {
let mut truncated = mongodb::bson::to_vec(&doc! { "name": "Ada" }).unwrap();
truncated.pop();
let error =
preview_mongodb_import_bytes(&truncated, MongoImportFormat::Bson, &MongoImportParseOptions::default(), 10)
.unwrap_err();
assert_eq!(error.code, "BSON_STRUCTURE");
assert_eq!(error.row, Some(1));
let error = preview_mongodb_import_bytes(
&(BSON_MAX_DOCUMENT_BYTES as i32 + 1).to_le_bytes(),
MongoImportFormat::Bson,
&MongoImportParseOptions::default(),
10,
)
.unwrap_err();
assert_eq!(error.code, "BSON_STRUCTURE");
}
#[test]
fn bson_dump_format_is_detected_from_plain_and_gzip_paths() {
assert_eq!(format_from_path("users.bson").unwrap(), MongoImportFormat::Bson);
assert_eq!(format_from_path("users.BSON.GZ").unwrap(), MongoImportFormat::Bson);
}
#[test]
fn csv_quoted_comma_newline_and_escaped_quotes() {
let csv = "name,note\n\"Ada, Lovelace\",\"line1\nline2\"\n\"quotes\",\"she said \"\"hi\"\"\"\n";
@@ -3806,6 +4141,7 @@ def handle(request):
collation: None,
format: MongoExportFormat::Ndjson,
include_header: true,
gzip: false,
file_path: target.to_string_lossy().to_string(),
execution_id: None,
},
@@ -3857,6 +4193,7 @@ def handle(request):
collation: None,
format: MongoExportFormat::Ndjson,
include_header: true,
gzip: false,
file_path: target.to_string_lossy().to_string(),
execution_id: None,
},
@@ -3928,6 +4265,7 @@ def handle(request):
collation: None,
format: MongoExportFormat::Ndjson,
include_header: true,
gzip: false,
file_path: target.to_string_lossy().to_string(),
execution_id: None,
},
@@ -3979,6 +4317,7 @@ def handle(request):
collation: None,
format: MongoExportFormat::Ndjson,
include_header: true,
gzip: false,
file_path: target.to_string_lossy().to_string(),
execution_id: None,
},
@@ -4035,6 +4374,7 @@ def handle(request):
collation: None,
format: MongoExportFormat::Ndjson,
include_header: true,
gzip: false,
file_path: target.to_string_lossy().to_string(),
execution_id: None,
},
@@ -4085,6 +4425,7 @@ def handle(request):
collation: None,
format: MongoExportFormat::Ndjson,
include_header: true,
gzip: false,
file_path: target.to_string_lossy().to_string(),
execution_id: None,
},
@@ -0,0 +1,822 @@
use std::io::Write;
use std::path::Path;
use dbx_core::mongodb_import_export::{
for_each_mongodb_import_document, preview_mongodb_import_file, MongoImportFormat, MongoImportIssue,
MongoImportParseOptions, MongoImportPreviewRequest, ParsedMongoDocument,
};
use flate2::{write::GzEncoder, Compression};
use mongodb::bson::{doc, Bson, Document};
fn encode(documents: &[Document], gzip: bool) -> Vec<u8> {
let bytes = documents.iter().flat_map(|document| mongodb::bson::to_vec(document).unwrap()).collect::<Vec<_>>();
if !gzip {
return bytes;
}
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&bytes).unwrap();
encoder.finish().unwrap()
}
fn read(path: &Path) -> Result<Vec<Document>, Box<MongoImportIssue>> {
let mut documents = Vec::new();
#[expect(clippy::result_large_err, reason = "The public import callback requires MongoImportIssue by value")]
let collect = |parsed: Result<ParsedMongoDocument, MongoImportIssue>| {
documents.push(parsed?.document);
Ok(())
};
for_each_mongodb_import_document(
path.to_str().unwrap(),
MongoImportFormat::Bson,
&MongoImportParseOptions::default(),
collect,
)
.map_err(Box::new)?;
Ok(documents)
}
#[test]
fn bson_preview_is_bounded_and_preserves_explicit_format() {
let directory = tempfile::tempdir().unwrap();
// The selected format, not the filename, chooses the parser.
let path = directory.path().join("renamed.json");
let documents = (0..80).map(|index| doc! { "_id": index, "smallLong": 1i64 }).collect::<Vec<_>>();
std::fs::write(&path, encode(&documents, false)).unwrap();
let mut request = MongoImportPreviewRequest {
file_path: path.to_str().unwrap().into(),
source_ref: None,
format: MongoImportFormat::Bson,
parse_options: MongoImportParseOptions::default(),
preview_limit: Some(3),
};
let preview = preview_mongodb_import_file(&request).unwrap();
assert_eq!(preview.rows.len(), 3);
assert!(!preview.estimated_rows_exact);
assert_eq!(preview.detected_encoding, None);
assert_eq!(preview.rows[0]["smallLong"]["$numberLong"], "1");
assert_eq!(read(&path).unwrap(), documents);
std::fs::write(&path, []).unwrap();
request.preview_limit = Some(1);
let preview = preview_mongodb_import_file(&request).unwrap();
assert_eq!(preview.estimated_rows, Some(0));
assert!(preview.estimated_rows_exact);
assert!(read(&path).unwrap().is_empty());
}
#[test]
fn gzip_reads_all_members_and_rejects_corrupt_or_truncated_streams() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("records.bson.gz");
let first = doc! { "_id": 1 };
let second = doc! { "_id": 2 };
let mut bytes = encode(std::slice::from_ref(&first), true);
bytes.extend(encode(std::slice::from_ref(&second), true));
std::fs::write(&path, &bytes).unwrap();
assert_eq!(read(&path).unwrap(), vec![first.clone(), second]);
let valid = encode(&[first], true);
for length in [0, 1, valid.len() - 1, valid.len() - 8] {
std::fs::write(&path, &valid[..length]).unwrap();
assert!(read(&path).is_err(), "accepted truncated gzip at {length}");
}
let mut corrupt = valid.clone();
let crc_offset = corrupt.len() - 8;
corrupt[crc_offset] ^= 1;
std::fs::write(&path, corrupt).unwrap();
assert!(read(&path).is_err(), "accepted invalid gzip checksum");
let mut trailing = valid;
trailing.extend_from_slice(b"junk");
std::fs::write(&path, trailing).unwrap();
assert!(read(&path).is_err(), "ignored trailing compressed data");
std::fs::write(&path, encode(&[], true)).unwrap();
assert!(read(&path).unwrap().is_empty());
}
#[test]
fn invalid_bson_is_fatal_even_when_callback_skips_errors() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("records.bson");
let prefix = encode(&[doc! { "_id": 1 }], false);
for tail in [
vec![1],
vec![5, 0, 0],
4i32.to_le_bytes().to_vec(),
(-1i32).to_le_bytes().to_vec(),
(16 * 1024 * 1024 + 1i32).to_le_bytes().to_vec(),
vec![5, 0, 0, 0, 1],
] {
let mut bytes = prefix.clone();
bytes.extend(tail);
std::fs::write(&path, bytes).unwrap();
#[expect(clippy::result_large_err, reason = "The public import callback requires MongoImportIssue by value")]
let ignore = |_: Result<ParsedMongoDocument, MongoImportIssue>| Ok(());
let error = for_each_mongodb_import_document(
path.to_str().unwrap(),
MongoImportFormat::Bson,
&MongoImportParseOptions { skip_error_rows: Some(true), ..Default::default() },
ignore,
)
.unwrap_err();
assert_eq!(error.code, "BSON_STRUCTURE");
assert_eq!(error.row, Some(2));
}
}
fn run_tool(tools: &Path, name: &str, args: &[&str]) {
let logs = tempfile::tempdir().unwrap();
let log = logs.path().join("stderr.log");
let mut child = std::process::Command::new(tools.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)))
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::fs::File::create(&log).unwrap())
.spawn()
.unwrap_or_else(|error| panic!("cannot run {name}: {error}"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success(), "{name}: {}", std::fs::read_to_string(&log).unwrap());
return;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("{name} timed out: {}", std::fs::read_to_string(&log).unwrap());
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
async fn collection_bytes(client: &mongodb::Client, database: &str, collection: &str) -> Vec<Vec<u8>> {
use futures::TryStreamExt;
client
.database(database)
.collection::<Document>(collection)
.find(doc! {})
.sort(doc! { "_id": 1 })
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap()
.iter()
.map(|document| mongodb::bson::to_vec(document).unwrap())
.collect()
}
#[tokio::test]
#[ignore = "opt-in: DBX_MONGO_DUMP_TEST_URI and DBX_MONGO_TOOLS_DIR; creates a temporary database"]
async fn official_tools_round_trip_through_dbx_core() {
use dbx_core::connection::{AppState, PoolKind};
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::mongodb_import_export::{
export_mongodb_query_core, import_mongodb_file_core, MongoExportFormat, MongoExportRequest, MongoExportStatus,
MongoImportRequest, MongoImportStatus,
};
use dbx_core::storage::Storage;
use mongodb::bson::{oid::ObjectId, spec::BinarySubtype, Binary, DateTime, Decimal128, Regex, Timestamp};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
let uri = std::env::var("DBX_MONGO_DUMP_TEST_URI").expect("DBX_MONGO_DUMP_TEST_URI");
let tools = std::path::PathBuf::from(std::env::var("DBX_MONGO_TOOLS_DIR").expect("DBX_MONGO_TOOLS_DIR"));
let directory = tempfile::tempdir().unwrap();
let client = mongodb::Client::with_uri_str(&uri).await.unwrap();
let database = format!("dbx_dump_test_{}", uuid::Uuid::new_v4().simple());
let storage = Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let connection_id = "mongo-dump-test";
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": connection_id, "name": "Dump test", "db_type": "mongodb", "host": "127.0.0.1",
"port": 27090, "username": "", "password": "", "database": database,
"connection_string": uri, "driver_profile": "mongodb-native"
}))
.unwrap();
state.configs.write().await.insert(connection_id.into(), config);
state
.update_connection_pools(|pools| {
pools.insert(connection_id.into(), PoolKind::MongoDb(client.clone()));
})
.await;
let documents = (0..1207)
.map(|index| {
doc! {
"_id": index,
"objectId": ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap(),
"smallLong": 1i64, "maxLong": i64::MAX, "double": 1.5f64,
"decimal": "123.45".parse::<Decimal128>().unwrap(),
"date": DateTime::from_millis(-1234567890),
"binary": Binary { subtype: BinarySubtype::UserDefined(0x80), bytes: vec![0, 1, 255] },
"regex": Regex { pattern: "^test".into(), options: "im".into() },
"timestamp": Timestamp { time: 12345, increment: 42 },
"min": Bson::MinKey, "max": Bson::MaxKey, "undefined": Bson::Undefined,
"code": Bson::JavaScriptCode("return 1".into()),
"nested": { "array": [Bson::Null, Bson::Boolean(true), Bson::String("1".into())] },
}
})
.collect::<Vec<_>>();
client.database(&database).collection("source").insert_many(documents).await.unwrap();
let expected = collection_bytes(&client, &database, "source").await;
assert_eq!(expected.len(), 1207);
for gzip in [false, true] {
let extension = if gzip { "bson.gz" } else { "bson" };
let dump_dir = directory.path().join(if gzip { "official-gzip" } else { "official-plain" });
let mut args = vec![
"--uri",
uri.as_str(),
"--db",
&database,
"--collection",
"source",
"--out",
dump_dir.to_str().unwrap(),
];
if gzip {
args.push("--gzip");
}
run_tool(&tools, "mongodump", &args);
let dumped = dump_dir.join(&database).join(format!("source.{extension}"));
assert_eq!(read(&dumped).unwrap().len(), expected.len());
let preview = preview_mongodb_import_file(&MongoImportPreviewRequest {
file_path: dumped.to_str().unwrap().into(),
source_ref: None,
format: MongoImportFormat::Bson,
parse_options: Default::default(),
preview_limit: Some(5),
})
.unwrap();
assert_eq!(preview.rows.len(), 5);
let imported = if gzip { "dbx_gzip" } else { "dbx_plain" };
let request = MongoImportRequest {
import_id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
database: database.clone(),
collection: imported.into(),
file_path: dumped.to_str().unwrap().into(),
source_ref: None,
format: MongoImportFormat::Bson,
parse_options: Default::default(),
batch_size: 500,
execution_id: None,
};
let summary = import_mongodb_file_core(&state, &request, |_| Box::pin(async { false }), |_| {}).await.unwrap();
assert_eq!(summary.rows_inserted, 1207);
assert_eq!(summary.rows_failed, 0);
assert_eq!(summary.batches_committed, 3);
assert_eq!(collection_bytes(&client, &database, imported).await, expected);
// This file is produced by DBX, with no official metadata sidecar beside it.
let exported = directory.path().join(format!("dbx-output.{extension}"));
let export = MongoExportRequest {
export_id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
database: database.clone(),
collection: imported.into(),
filter: None,
sort: Some("{\"_id\":1}".into()),
projection: None,
collation: None,
format: MongoExportFormat::Bson,
include_header: false,
gzip,
file_path: exported.to_str().unwrap().into(),
execution_id: None,
};
let summary = export_mongodb_query_core(&state, &export, |_| Box::pin(async { false }), |_| {}).await.unwrap();
assert_eq!(summary.documents_exported, 1207);
assert_eq!(
read(&exported).unwrap().iter().map(|d| mongodb::bson::to_vec(d).unwrap()).collect::<Vec<_>>(),
expected
);
let restored = if gzip { "official_gzip" } else { "official_plain" };
let mut args = vec!["--uri", uri.as_str(), "--db", &database, "--collection", restored];
if gzip {
args.push("--gzip");
}
args.push(exported.to_str().unwrap());
run_tool(&tools, "mongorestore", &args);
assert_eq!(collection_bytes(&client, &database, restored).await, expected);
println!("{extension}: official -> DBX -> official, 1207 documents, BSON bytes match");
let mut empty_export = export.clone();
empty_export.collection = "empty".into();
empty_export.file_path = directory.path().join(format!("empty.{extension}")).to_str().unwrap().into();
let empty =
export_mongodb_query_core(&state, &empty_export, |_| Box::pin(async { false }), |_| {}).await.unwrap();
assert_eq!(empty.documents_exported, 0);
assert!(read(Path::new(&empty.file_path)).unwrap().is_empty());
let cancelled = Arc::new(AtomicBool::new(false));
let check = cancelled.clone();
let original = std::fs::read(&exported).unwrap();
let result = export_mongodb_query_core(
&state,
&export,
move |_| {
let check = check.clone();
Box::pin(async move { check.load(Ordering::SeqCst) })
},
|progress| {
if progress.status == MongoExportStatus::Running && progress.documents_read > 0 {
cancelled.store(true, Ordering::SeqCst);
}
},
)
.await;
assert_eq!(result.unwrap_err(), "Export cancelled");
assert_eq!(std::fs::read(&exported).unwrap(), original, "cancel replaced the previous export");
let damaged = directory.path().join("damaged.bson");
std::fs::write(&damaged, [1]).unwrap();
let mut request = request;
request.file_path = damaged.to_str().unwrap().into();
request.parse_options.skip_error_rows = Some(true);
let mut status = MongoImportStatus::Running;
let result =
import_mongodb_file_core(&state, &request, |_| Box::pin(async { false }), |p| status = p.status).await;
assert!(result.is_err());
assert_eq!(status, MongoImportStatus::Error);
}
client.database(&database).drop().await.unwrap();
}
async fn specs(client: &mongodb::Client, db: &str, collection: &str) -> (Document, Vec<Document>) {
use futures::TryStreamExt;
let specification = client
.database(db)
.list_collections()
.filter(doc! { "name": collection })
.await
.unwrap()
.try_next()
.await
.unwrap()
.unwrap();
let options = mongodb::bson::to_document(&specification.options).unwrap();
let mut indexes = Vec::new();
if !matches!(specification.collection_type, mongodb::results::CollectionType::View) {
let mut cursor = client.database(db).collection::<Document>(collection).list_indexes().await.unwrap();
while let Some(index) = cursor.try_next().await.unwrap() {
let mut index = mongodb::bson::to_document(&index).unwrap();
index.remove("ns");
index.remove("v");
indexes.push(index);
}
indexes.sort_by_key(|index| index.get_str("name").unwrap().to_string());
}
(options, indexes)
}
#[tokio::test]
#[ignore = "opt-in: DBX_MONGO_DUMP_TEST_URI and DBX_MONGO_TOOLS_DIR; creates temporary databases"]
async fn official_database_tools_round_trip_through_dbx() {
use dbx_core::{
connection::{AppState, PoolKind},
models::connection::ConnectionConfig,
mongodb_dump::*,
storage::Storage,
};
let uri = std::env::var("DBX_MONGO_DUMP_TEST_URI").expect("DBX_MONGO_DUMP_TEST_URI");
let tools = std::path::PathBuf::from(std::env::var("DBX_MONGO_TOOLS_DIR").expect("DBX_MONGO_TOOLS_DIR"));
let files = tempfile::tempdir().unwrap();
let client = mongodb::Client::with_uri_str(&uri).await.unwrap();
let database = format!("dbx_db_{}", uuid::Uuid::new_v4().simple());
let db = client.database(&database);
db.run_command(doc! { "create": "records", "validator": { "score": { "$gte": 0 } }, "validationLevel": "strict", "collation": { "locale": "en", "strength": 2 } }).await.unwrap();
db.collection("records")
.insert_many(vec![
doc! { "_id": 1, "score": 1i64, "name": "first" },
doc! { "_id": 2, "score": 2i64, "name": "second" },
])
.await
.unwrap();
db.run_command(doc! { "createIndexes": "records", "indexes": [ { "key": { "name": 1, "score": -1 }, "name": "compound_unique", "unique": true }, { "key": { "expires": 1 }, "name": "ttl", "expireAfterSeconds": 3600 }] }).await.unwrap();
db.create_collection("empty").await.unwrap();
db.run_command(doc! { "create": "capped", "capped": true, "size": 65536i64, "max": 100i64 }).await.unwrap();
db.collection("capped").insert_one(doc! { "_id": 1, "n": 1 }).await.unwrap();
db.collection("odd/name").insert_one(doc! { "_id": 1, "binary": mongodb::bson::Binary { subtype: mongodb::bson::spec::BinarySubtype::Generic, bytes: vec![1, 2, 3] } }).await.unwrap();
db.run_command(doc! { "create": "z_view", "viewOn": "records", "pipeline": [ { "$match": { "score": { "$gte": 1 } } } ], "collation": { "locale": "en", "strength": 2 } }).await.unwrap();
db.run_command(doc! { "create": "a_nested", "viewOn": "z_view", "pipeline": [], "collation": { "locale": "en", "strength": 2 } }).await.unwrap();
let collections = ["records", "empty", "capped", "odd/name", "z_view", "a_nested"];
let state = AppState::new(Storage::open(&files.path().join("storage.db")).await.unwrap());
let connection_id = "database-dump-test";
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ "id": connection_id, "name": "Database dump test", "db_type": "mongodb", "host": "127.0.0.1", "port": 27090, "username": "", "password": "", "database": database, "connection_string": uri, "driver_profile": "mongodb-native" })).unwrap();
state.configs.write().await.insert(connection_id.into(), config);
state
.update_connection_pools(|pools| {
pools.insert(connection_id.into(), PoolKind::MongoDb(client.clone()));
})
.await;
let mut databases = vec![database.clone()];
for format in [MongoDumpFormat::Directory, MongoDumpFormat::Archive] {
for gzip in [false, true] {
let tag = format!("{format:?}_{gzip}");
let official_path = files.path().join(format!("official-{tag}"));
let archive_arg = format!("--archive={}", official_path.display());
let mut args = vec!["--uri", uri.as_str(), "--db", &database];
if format == MongoDumpFormat::Archive {
args.push(&archive_arg);
} else {
args.extend(["--out", official_path.to_str().unwrap()]);
}
if gzip {
args.push("--gzip");
}
run_tool(&tools, "mongodump", &args);
let preview = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: official_path.to_str().unwrap().into(),
format,
gzip,
})
.await
.unwrap();
assert_eq!(preview.catalog.collections.len(), 6);
assert!(preview.catalog.collections.iter().all(|c| c.documents.is_none()));
let restored = format!("{database}_{tag}");
databases.push(restored.clone());
let request = MongoDatabaseRestoreRequest {
task_id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
database: restored.clone(),
source_database: database.clone(),
source_ref: preview.source_ref.clone(),
collections: None,
drop_existing: false,
restore_options: true,
restore_indexes: true,
stop_on_error: true,
objcheck: gzip,
batch_size: 500,
execution_id: None,
};
let mut saw_data = false;
let result = restore_mongodb_database(
&state,
&request,
|_| Box::pin(async { false }),
|p| {
assert_ne!(p.phase, "validating");
saw_data |= p.phase == "data";
},
)
.await
.unwrap();
assert!(saw_data);
assert_eq!(result.documents_validated, if gzip { 4 } else { 0 });
assert_eq!(result.collections_done, 6);
assert_eq!(result.documents_written, 4);
for collection in collections {
assert_eq!(
collection_bytes(&client, &database, collection).await,
collection_bytes(&client, &restored, collection).await,
"data: {tag} {collection}"
);
assert_eq!(
specs(&client, &database, collection).await,
specs(&client, &restored, collection).await,
"metadata: {tag} {collection}"
);
}
client.database(&restored).collection("unselected").insert_one(doc! { "_id": "keep" }).await.unwrap();
client
.database(&restored)
.collection("records")
.insert_one(doc! { "_id": 999, "score": 999, "name": "remove" })
.await
.unwrap();
let mut selected = request.clone();
selected.drop_existing = true;
selected.collections = Some(vec!["records".into()]);
restore_mongodb_database(&state, &selected, |_| Box::pin(async { false }), |_| {}).await.unwrap();
assert_eq!(
collection_bytes(&client, &database, "records").await,
collection_bytes(&client, &restored, "records").await
);
assert_eq!(collection_bytes(&client, &restored, "unselected").await.len(), 1);
if format == MongoDumpFormat::Directory && !gzip {
let cancel_dir = files.path().join("cancel_source");
std::fs::create_dir(&cancel_dir).unwrap();
let documents = (0..201).map(|id| doc! { "_id": id }).collect::<Vec<_>>();
std::fs::write(cancel_dir.join("records.bson"), encode(&documents, false)).unwrap();
let cancel_source = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: cancel_dir.to_str().unwrap().into(),
format: MongoDumpFormat::Directory,
gzip: false,
})
.await
.unwrap();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let signal = cancel.clone();
let mut cancel_request = selected.clone();
cancel_request.source_ref = cancel_source.source_ref.clone();
cancel_request.source_database = "cancel_source".into();
cancel_request.batch_size = 100;
let mut final_progress = None;
let cancelled = tokio::time::timeout(
std::time::Duration::from_secs(10),
restore_mongodb_database(
&state,
&cancel_request,
|_| {
let signal = signal.clone();
Box::pin(async move { signal.load(std::sync::atomic::Ordering::Relaxed) })
},
|p| {
if p.documents_written >= 100 {
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
final_progress = Some(p);
},
),
)
.await
.expect("cancellation must stop and join the reader")
.unwrap_err();
assert!(cancelled.contains("cancelled"), "{cancelled}");
let final_progress = final_progress.unwrap();
assert_eq!(final_progress.status, "cancelled");
assert_eq!(final_progress.documents_written, 100);
assert_eq!(collection_bytes(&client, &restored, "records").await.len(), 100);
let mut duplicate_request = cancel_request.clone();
duplicate_request.batch_size = 500;
duplicate_request.drop_existing = false;
let mut partial_progress = None;
let error = restore_mongodb_database(
&state,
&duplicate_request,
|_| Box::pin(async { false }),
|p| partial_progress = Some(p),
)
.await
.unwrap_err();
assert!(error.contains("duplicate key"), "{error}");
let partial_progress = partial_progress.unwrap();
assert_eq!(partial_progress.documents_written, 101);
assert_eq!(partial_progress.documents_failed, 100);
duplicate_request.stop_on_error = false;
let continued =
restore_mongodb_database(&state, &duplicate_request, |_| Box::pin(async { false }), |_| {})
.await
.unwrap();
assert_eq!(continued.documents_written, 0);
assert_eq!(continued.documents_failed, 201);
release_mongodb_restore_source(&cancel_source.source_ref);
let broken_dir = files.path().join("broken_source");
std::fs::create_dir(&broken_dir).unwrap();
std::fs::write(broken_dir.join("records.bson"), encode(&[doc! { "_id": "replacement" }], false))
.unwrap();
std::fs::write(broken_dir.join("zz_broken.bson"), b"invalid BSON").unwrap();
let broken = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: broken_dir.to_str().unwrap().into(),
format,
gzip,
})
.await
.unwrap();
let mut invalid = selected.clone();
invalid.source_ref = broken.source_ref.clone();
invalid.source_database = "broken_source".into();
invalid.collections = None;
let mut partial = 0;
let error = restore_mongodb_database(&state, &invalid, |_| Box::pin(async { false }), |_| {})
.await
.unwrap_err();
assert!(error.contains("BSON"), "{error}");
assert_eq!(
collection_bytes(&client, &restored, "records").await,
vec![mongodb::bson::to_vec(&doc! { "_id": "replacement" }).unwrap()],
"valid earlier collections are restored without a full pre-scan"
);
invalid.objcheck = true;
assert!(restore_mongodb_database(
&state,
&invalid,
|_| Box::pin(async { false }),
|p| partial = p.documents_written
)
.await
.is_err());
assert_eq!(partial, 1);
release_mongodb_restore_source(&broken.source_ref);
let mut no_metadata = request.clone();
no_metadata.database = format!("{database}_no_options");
databases.push(no_metadata.database.clone());
no_metadata.collections = Some(vec!["records".into()]);
no_metadata.restore_options = false;
no_metadata.restore_indexes = false;
restore_mongodb_database(&state, &no_metadata, |_| Box::pin(async { false }), |_| {}).await.unwrap();
let (options, indexes) = specs(&client, &no_metadata.database, "records").await;
assert!(options.is_empty());
assert_eq!(indexes.len(), 1);
state.configs.write().await.get_mut(connection_id).unwrap().read_only = true;
let blocked = restore_mongodb_database(&state, &no_metadata, |_| Box::pin(async { false }), |_| {})
.await
.unwrap_err();
assert!(blocked.contains("Read-only"));
state.configs.write().await.get_mut(connection_id).unwrap().read_only = false;
let cancelled = restore_mongodb_database(&state, &no_metadata, |_| Box::pin(async { true }), |_| {})
.await
.unwrap_err();
assert!(cancelled.contains("cancelled"));
}
assert!(release_mongodb_restore_source(&preview.source_ref));
let output = files.path().join(format!("dbx-{tag}"));
let dump = MongoDatabaseDumpRequest {
task_id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
database: database.clone(),
file_path: output.to_str().unwrap().into(),
format,
gzip,
collections: None,
};
dump_mongodb_database(&state, &dump, |_| Box::pin(async { false }), |_| {}).await.unwrap();
let target = format!("{database}_out_{tag}");
databases.push(target.clone());
let from = format!("{database}.*");
let to = format!("{target}.*");
let archive_arg = format!("--archive={}", output.display());
let mut args = vec!["--uri", uri.as_str(), "--nsFrom", &from, "--nsTo", &to];
if gzip {
args.push("--gzip");
}
if format == MongoDumpFormat::Archive {
args.push(&archive_arg);
} else {
args.push(output.to_str().unwrap());
}
run_tool(&tools, "mongorestore", &args);
for collection in collections {
assert_eq!(
collection_bytes(&client, &database, collection).await,
collection_bytes(&client, &target, collection).await,
"exported data: {tag} {collection}"
);
assert_eq!(
specs(&client, &database, collection).await,
specs(&client, &target, collection).await,
"exported metadata: {tag} {collection}"
);
}
println!("{tag}: six collections/views, BSON, options and indexes match in both directions");
if format == MongoDumpFormat::Archive && !gzip {
let mut bytes = std::fs::read(&output).unwrap();
let offset = bytes.windows(5).position(|bytes| bytes == b"first").unwrap();
bytes[offset] = b'F';
let corrupt = files.path().join("corrupt.archive");
std::fs::write(&corrupt, &bytes).unwrap();
let corrupt_preview = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: corrupt.to_str().unwrap().into(),
format,
gzip: false,
})
.await
.unwrap();
let mut corrupt_request = request.clone();
corrupt_request.source_ref = corrupt_preview.source_ref.clone();
corrupt_request.database = target.clone();
corrupt_request.drop_existing = true;
let mut partial = 0;
let error = restore_mongodb_database(
&state,
&corrupt_request,
|_| Box::pin(async { false }),
|p| partial = p.documents_written,
)
.await
.unwrap_err();
assert!(error.contains("checksum"), "{error}");
assert!(partial > 0, "archive CRC is checked as data streams, not in a separate pre-pass");
release_mongodb_restore_source(&corrupt_preview.source_ref);
bytes = std::fs::read(&output).unwrap();
bytes.truncate(bytes.len() - 1);
std::fs::write(&corrupt, bytes).unwrap();
let truncated = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: corrupt.to_str().unwrap().into(),
format,
gzip: false,
})
.await
.unwrap();
corrupt_request.source_ref = truncated.source_ref.clone();
assert!(restore_mongodb_database(&state, &corrupt_request, |_| Box::pin(async { false }), |_| {})
.await
.is_err());
assert_eq!(
collection_bytes(&client, &database, "records").await,
collection_bytes(&client, &target, "records").await
);
release_mongodb_restore_source(&truncated.source_ref);
}
}
}
for database in databases {
client.database(&database).drop().await.unwrap();
}
}
#[tokio::test]
async fn database_source_rejects_invalid_metadata_and_view_cycles() {
use dbx_core::mongodb_dump::*;
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("source");
std::fs::create_dir(&source).unwrap();
std::fs::write(source.join("records.bson"), []).unwrap();
let request = MongoRestoreSourceRequest {
path: source.to_str().unwrap().into(),
format: MongoDumpFormat::Directory,
gzip: false,
};
for metadata in [
r#"{"collectionName":"records","options":{"$db":"outside"},"indexes":[]}"#,
r#"{"collectionName":"records","options":{},"indexes":[{"name":"bad"}]}"#,
r#"{"collectionName":"records","type":"timeseries","options":{},"indexes":[]}"#,
] {
std::fs::write(source.join("records.metadata.json"), metadata).unwrap();
assert!(prepare_mongodb_restore_source(request.clone()).await.is_err());
}
std::fs::write(source.join("records.metadata.json"), r#"{"collectionName":"records","options":{},"indexes":[]}"#)
.unwrap();
std::fs::write(
source.join("one.metadata.json"),
r#"{"collectionName":"one","type":"view","options":{"viewOn":"two","pipeline":[]},"indexes":[]}"#,
)
.unwrap();
std::fs::write(
source.join("two.metadata.json"),
r#"{"collectionName":"two","type":"view","options":{"viewOn":"one","pipeline":[]},"indexes":[]}"#,
)
.unwrap();
let error = prepare_mongodb_restore_source(request).await.unwrap_err();
assert!(error.contains("Cyclic"));
}
#[tokio::test]
async fn directory_preview_does_not_read_bson_and_source_identity_is_checked_before_restore() {
use dbx_core::{connection::AppState, mongodb_dump::*, storage::Storage};
let files = tempfile::tempdir().unwrap();
let source = files.path().join("source");
std::fs::create_dir(&source).unwrap();
// Preview must succeed without opening/decoding this invalid gzip data.
std::fs::write(source.join("records.bson.gz"), b"not gzip or BSON").unwrap();
let preview = prepare_mongodb_restore_source(MongoRestoreSourceRequest {
path: source.to_str().unwrap().into(),
format: MongoDumpFormat::Directory,
gzip: true,
})
.await
.unwrap();
assert_eq!(preview.catalog.collections[0].documents, None);
assert_eq!(preview.catalog.collections[0].size_bytes, 16);
let state = AppState::new(Storage::open(&files.path().join("state.db")).await.unwrap());
let request: MongoDatabaseRestoreRequest = serde_json::from_value(serde_json::json!({
"taskId":"validation-test", "connectionId":"missing", "database":"target", "sourceDatabase":"source",
"sourceRef":preview.source_ref, "dropExisting":true
}))
.unwrap();
let error = restore_mongodb_database(&state, &request, |_| Box::pin(async { false }), |_| {}).await.unwrap_err();
assert!(
!error.to_lowercase().contains("gzip"),
"no pre-scan should run before the missing connection fails: {error}"
);
assert!(!request.objcheck, "objcheck is opt-in");
std::fs::write(source.join("records.bson.gz"), []).unwrap();
let error = restore_mongodb_database(&state, &request, |_| Box::pin(async { false }), |_| {}).await.unwrap_err();
assert!(error.contains("changed"), "{error}");
release_mongodb_restore_source(&preview.source_ref);
}
#[tokio::test]
async fn directory_catalog_accepts_large_manifest_without_data_and_binds_only_selected_upload() {
use dbx_core::mongodb_dump::*;
let owner = tempfile::tempdir().unwrap();
let manifest = vec![
MongoDumpFile { path: "source/selected.bson".into(), size_bytes: 0 },
MongoDumpFile { path: "source/huge.bson".into(), size_bytes: 5 * 1024 * 1024 * 1024 },
];
let preview = prepare_mongodb_directory_catalog(owner, manifest, false).await.unwrap();
assert_eq!(preview.catalog.collections.len(), 2);
assert!(preview.catalog.collections.iter().all(|c| c.documents.is_none()));
let request: MongoDatabaseRestoreRequest = serde_json::from_value(serde_json::json!({
"taskId":"upload-test", "connectionId":"missing", "database":"target", "sourceDatabase":"source",
"sourceRef":preview.source_ref, "collections":["selected"]
}))
.unwrap();
let upload = tempfile::tempdir().unwrap();
std::fs::create_dir(upload.path().join("source")).unwrap();
std::fs::write(upload.path().join("source/selected.bson"), []).unwrap();
let acquired = attach_mongodb_restore_upload(&request, upload).await.unwrap();
assert_eq!(acquired.catalog.collections.len(), 1);
release_mongodb_restore_source(&acquired.source_ref);
let changed = tempfile::tempdir().unwrap();
std::fs::create_dir(changed.path().join("source")).unwrap();
std::fs::write(changed.path().join("source/selected.bson"), b"changed").unwrap();
assert!(attach_mongodb_restore_upload(&request, changed).await.unwrap_err().contains("differ"));
release_mongodb_restore_source(&preview.source_ref);
}
+5 -1
View File
@@ -34,10 +34,14 @@ tracing = "0.1"
async-stream = "0.3"
futures = "0.3"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
tokio-util = "0.7"
tokio-util = { version = "0.7", features = ["io"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
chrono = "0.4"
aes-gcm = "0.10"
base64 = "0.22"
pbkdf2 = "0.12"
sha2 = "0.10"
tempfile = "3"
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["multipart"] }
+19
View File
@@ -988,6 +988,25 @@ async fn main() {
.route("/mongo/export/progress/{exportId}", get(routes::mongodb_import_export::export_progress))
.route("/mongo/export/download/{exportId}", get(routes::mongodb_import_export::export_download))
.route("/mongo/export/cancel", post(routes::mongodb_import_export::cancel_export))
.route("/mongo/dump/catalog", post(routes::mongodb_dump::catalog))
.route(
"/mongo/dump/source",
post(routes::mongodb_dump::prepare_source).layer(DefaultBodyLimit::max(
routes::table_import::import_request_body_limit_for_upload(web_body_limit_bytes()),
)),
)
.route("/mongo/dump/source/release", post(routes::mongodb_dump::release_source))
.route("/mongo/dump/upload-limit", get(routes::mongodb_dump::upload_limit))
.route(
"/mongo/dump/source/upload",
post(routes::mongodb_dump::upload_restore_source).layer(DefaultBodyLimit::max(
routes::table_import::import_request_body_limit_for_upload(web_body_limit_bytes()),
)),
)
.route("/mongo/dump/export", post(routes::mongodb_dump::start_dump))
.route("/mongo/dump/restore", post(routes::mongodb_dump::start_restore))
.route("/mongo/dump/progress/{taskId}", get(routes::mongodb_dump::progress))
.route("/mongo/dump/cancel", post(routes::mongodb_dump::cancel))
// History
.route("/history", get(routes::history::load_history).delete(routes::history::clear_history))
.route("/history/save", post(routes::history::save_history))
+1
View File
@@ -17,6 +17,7 @@ pub mod jdbc;
pub mod layout;
pub mod mcp_policy;
pub mod mongo;
pub mod mongodb_dump;
pub mod mongodb_import_export;
#[cfg(feature = "mq-admin")]
pub mod mq;
+402
View File
@@ -0,0 +1,402 @@
use std::path::{Component, Path as FilePath, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{Multipart, Path, State},
response::Sse,
Json,
};
use dbx_core::{
mongodb_dump::{
self, MongoDatabaseDumpProgress, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDumpFormat,
MongoRestoreSourceRequest,
},
transfer,
};
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use super::export_download::export_download_filename;
use crate::{
error::AppError,
sse::{TransferProgressChannel, TransferReplayEventKind},
state::{WebExportFile, WebState},
};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogRequest {
connection_id: String,
database: String,
}
pub async fn catalog(
State(state): State<Arc<WebState>>,
Json(request): Json<CatalogRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let catalog = mongodb_dump::inspect_mongodb_database_dump(&state.app, &request.connection_id, &request.database)
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::to_value(catalog).map_err(|e| AppError::from(e.to_string()))?))
}
fn upload_path(root: &FilePath, filename: &str) -> Result<PathBuf, AppError> {
let path = FilePath::new(filename);
if filename.is_empty()
|| filename.contains(['\\', ':', '\0'])
|| path.components().any(|c| !matches!(c, Component::Normal(_)))
{
return Err(AppError::from("Invalid dump upload path".to_string()));
}
if path.components().count() > 4 {
return Err(AppError::from("Dump directory nesting is too deep".to_string()));
}
Ok(root.join(path))
}
pub async fn prepare_source(
State(state): State<Arc<WebState>>,
multipart: Multipart,
) -> Result<Json<serde_json::Value>, AppError> {
receive_source(state, multipart, false).await
}
pub async fn upload_restore_source(
State(state): State<Arc<WebState>>,
multipart: Multipart,
) -> Result<Json<serde_json::Value>, AppError> {
receive_source(state, multipart, true).await
}
pub async fn upload_limit() -> Json<usize> {
Json(crate::web_body_limit_bytes())
}
async fn field_text(mut field: axum::extract::multipart::Field<'_>, limit: usize) -> Result<String, AppError> {
let mut bytes = Vec::new();
while let Some(chunk) = field.chunk().await.map_err(|e| AppError::bad_request(e.to_string()))? {
if bytes.len().saturating_add(chunk.len()) > limit {
return Err(AppError::bad_request("Dump metadata field is too large"));
}
bytes.extend_from_slice(&chunk);
}
String::from_utf8(bytes).map_err(|e| AppError::bad_request(e.to_string()))
}
async fn receive_source(
state: Arc<WebState>,
mut multipart: Multipart,
acquiring: bool,
) -> Result<Json<serde_json::Value>, AppError> {
let tmp = state.data_dir.join("tmp");
tokio::fs::create_dir_all(&tmp).await.map_err(|e| AppError::from(e.to_string()))?;
let upload =
tempfile::Builder::new().prefix("mongo-upload-").tempdir_in(&tmp).map_err(|e| AppError::from(e.to_string()))?;
let mut format = None;
let mut gzip = false;
let mut paths = Vec::new();
let mut bytes = 0usize;
let mut manifest: Option<Vec<mongodb_dump::MongoDumpFile>> = None;
let mut request: Option<MongoDatabaseRestoreRequest> = None;
let mut expected_upload = std::collections::HashMap::<String, u64>::new();
while let Some(mut field) = multipart.next_field().await.map_err(|e| AppError::from(e.to_string()))? {
match field.name().unwrap_or("") {
"file" => {
if paths.len() >= 100_000 {
return Err(AppError::from("Too many dump files".to_string()));
}
let filename =
field.file_name().ok_or_else(|| AppError::from("Missing dump filename".to_string()))?.to_string();
if format.is_none() {
return Err(AppError::bad_request("Dump format must precede files"));
}
if !acquiring
&& format == Some(MongoDumpFormat::Directory)
&& !filename.ends_with(".metadata.json")
&& !filename.ends_with(".metadata.json.gz")
{
return Err(AppError::bad_request(
"Directory preview accepts metadata only; upload BSON after confirmation",
));
}
if acquiring && request.is_none() {
return Err(AppError::bad_request("Restore plan must precede files"));
}
let expected_size = if acquiring {
Some(
expected_upload
.remove(&filename)
.ok_or_else(|| AppError::bad_request("File is not in the selected restore plan"))?,
)
} else {
None
};
let mut file_bytes = 0u64;
let path = upload_path(upload.path(), &filename)?;
tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| AppError::from(e.to_string()))?;
let mut output = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.await
.map_err(|e| AppError::from(e.to_string()))?;
while let Some(chunk) = field.chunk().await.map_err(|e| AppError::from(e.to_string()))? {
if let Some(request) = &request {
if transfer::is_cancelled(&request.task_id).await {
return Err(AppError::bad_request("MongoDB dump/restore cancelled"));
}
}
bytes = bytes.saturating_add(chunk.len());
file_bytes = file_bytes.saturating_add(chunk.len() as u64);
if expected_size.is_some_and(|size| file_bytes > size) {
return Err(AppError::bad_request("File size changed after preview"));
}
let limit = if !acquiring && format == Some(MongoDumpFormat::Directory) {
32 * 1024 * 1024
} else {
crate::web_body_limit_bytes()
};
if bytes > limit {
let mut error =
AppError::from(format!("Dump upload exceeds the configured {} byte limit", limit));
error.status = axum::http::StatusCode::PAYLOAD_TOO_LARGE;
return Err(error);
}
output.write_all(&chunk).await.map_err(|e| AppError::from(e.to_string()))?;
}
output.flush().await.map_err(|e| AppError::from(e.to_string()))?;
if expected_size.is_some_and(|size| file_bytes != size) {
return Err(AppError::bad_request("File size changed after preview"));
}
paths.push((filename, path));
}
"format" => {
let value = field_text(field, 32).await?;
format = Some(
serde_json::from_value::<MongoDumpFormat>(serde_json::Value::String(value))
.map_err(|e| AppError::from(e.to_string()))?,
);
}
"gzip" => {
let value = field_text(field, 16).await?;
gzip = value.parse::<bool>().map_err(|_| AppError::from("Invalid gzip option".to_string()))?;
}
"manifest" if !acquiring => {
let value = field_text(field, 16 * 1024 * 1024).await?;
manifest = Some(serde_json::from_str(&value).map_err(|e| AppError::bad_request(e.to_string()))?);
}
"request" if acquiring => {
let value = field_text(field, 1024 * 1024).await?;
if request.is_some() {
return Err(AppError::bad_request("Duplicate restore plan"));
}
request = Some(serde_json::from_str(&value).map_err(|e| AppError::bad_request(e.to_string()))?);
expected_upload = mongodb_dump::mongodb_restore_upload_files(request.as_ref().unwrap())
.map_err(AppError::from)?
.into_iter()
.map(|f| (f.path, f.size_bytes))
.collect();
}
_ => return Err(AppError::from("Unexpected dump upload field".to_string())),
}
}
let format = format.ok_or_else(|| AppError::from("Dump format is required".to_string()))?;
if paths.is_empty() && manifest.is_none() {
return Err(AppError::from("No dump files uploaded".to_string()));
}
if acquiring {
if !expected_upload.is_empty() {
return Err(AppError::bad_request("Selected restore files are missing"));
}
let request = request.ok_or_else(|| AppError::bad_request("Missing restore plan"))?;
if transfer::is_cancelled(&request.task_id).await {
return Err(AppError::bad_request("MongoDB dump/restore cancelled"));
}
let preview = mongodb_dump::attach_mongodb_restore_upload(&request, upload).await.map_err(AppError::from)?;
return Ok(Json(serde_json::to_value(preview).map_err(|e| AppError::from(e.to_string()))?));
}
if format == MongoDumpFormat::Directory {
let manifest = manifest.ok_or_else(|| AppError::bad_request("Directory preview requires a file manifest"))?;
let expected = manifest
.iter()
.filter(|f| f.path.ends_with(".metadata.json") || f.path.ends_with(".metadata.json.gz"))
.collect::<Vec<_>>();
if expected.len() != paths.len()
|| paths.iter().any(|(name, path)| {
!expected
.iter()
.any(|f| f.path == *name && std::fs::metadata(path).is_ok_and(|m| m.len() == f.size_bytes))
})
{
return Err(AppError::bad_request("Uploaded metadata does not match the file manifest"));
}
let preview =
mongodb_dump::prepare_mongodb_directory_catalog(upload, manifest, gzip).await.map_err(AppError::from)?;
return Ok(Json(serde_json::to_value(preview).map_err(|e| AppError::from(e.to_string()))?));
}
let path = match format {
MongoDumpFormat::Archive if paths.len() == 1 => paths[0].1.clone(),
MongoDumpFormat::Archive => return Err(AppError::from("Archive restore accepts one file".to_string())),
MongoDumpFormat::Directory => {
if paths.iter().any(|(name, _)| FilePath::new(name).components().count() < 2) {
return Err(AppError::from("Directory uploads require database-relative paths".to_string()));
}
upload.path().to_owned()
}
};
let preview = mongodb_dump::prepare_owned_mongodb_restore_source(
MongoRestoreSourceRequest { path: path.to_string_lossy().into_owned(), format, gzip },
upload,
)
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::to_value(preview).map_err(|e| AppError::from(e.to_string()))?))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceRef {
source_ref: String,
}
pub async fn release_source(Json(request): Json<SourceRef>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "released": mongodb_dump::release_mongodb_restore_source(&request.source_ref) }))
}
#[derive(Deserialize)]
pub struct DumpWrapper {
request: MongoDatabaseDumpRequest,
}
#[derive(Deserialize)]
pub struct RestoreWrapper {
request: MongoDatabaseRestoreRequest,
}
enum Task {
Dump(MongoDatabaseDumpRequest, WebExportFile),
Restore(MongoDatabaseRestoreRequest),
}
async fn start_task(state: Arc<WebState>, task: Task) -> Result<Json<serde_json::Value>, AppError> {
let id = match &task {
Task::Dump(request, _) => &request.task_id,
Task::Restore(request) => &request.task_id,
}
.clone();
let key = format!("mongo-database:{id}");
let channel = Arc::new(TransferProgressChannel::new());
{
let mut channels = state.transfer_progress_channels.write().await;
if channels.contains_key(&key) {
return Err(AppError::from("Database dump/restore task already exists".to_string()));
}
channels.insert(key.clone(), channel.clone());
}
let task_id = id.clone();
dbx_core::export_runtime::spawn_export_task(async move {
let mut latest = MongoDatabaseDumpProgress::initial(&task_id);
let mut callback = |progress: MongoDatabaseDumpProgress| {
latest = progress;
if latest.status == "running" {
if let Ok(json) = serde_json::to_string(&latest) {
channel.send(json, TransferReplayEventKind::Progress);
}
}
};
let result = match task {
Task::Dump(request, file) => {
let result = mongodb_dump::dump_mongodb_database(
&state.app,
&request,
|id| {
let id = id.to_string();
Box::pin(async move { transfer::is_cancelled(&id).await })
},
&mut callback,
)
.await;
if result.is_ok() {
state.export_files.write().await.insert(task_id.clone(), file);
}
result
}
Task::Restore(request) => {
mongodb_dump::restore_mongodb_database(
&state.app,
&request,
|id| {
let id = id.to_string();
Box::pin(async move { transfer::is_cancelled(&id).await })
},
&mut callback,
)
.await
}
};
if let Err(error) = result {
latest.error_message = Some(error);
if latest.status == "running" {
latest.status = "error".into();
latest.phase = "done".into();
}
}
if let Ok(json) = serde_json::to_string(&latest) {
channel.send(json, TransferReplayEventKind::Terminal);
}
transfer::clear_cancelled(&task_id).await;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
state.transfer_progress_channels.write().await.remove(&key);
});
});
Ok(Json(serde_json::json!({ "taskId": id })))
}
pub async fn start_dump(
State(state): State<Arc<WebState>>,
Json(body): Json<DumpWrapper>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut request = body.request;
if request.format != MongoDumpFormat::Archive {
return Err(AppError::from("Web database exports use MongoDB archive format".to_string()));
}
let tmp = state.data_dir.join("tmp");
tokio::fs::create_dir_all(&tmp).await.map_err(|e| AppError::from(e.to_string()))?;
let ext = if request.gzip { "archive.gz" } else { "archive" };
let filename = export_download_filename(&request.file_path, &request.database, ext);
request.file_path =
tmp.join(format!("mongo_database_{}.{ext}", uuid::Uuid::new_v4())).to_string_lossy().into_owned();
let file = WebExportFile { file_path: request.file_path.clone(), download_filename: filename, format: ext.into() };
start_task(state, Task::Dump(request, file)).await
}
pub async fn start_restore(
State(state): State<Arc<WebState>>,
Json(body): Json<RestoreWrapper>,
) -> Result<Json<serde_json::Value>, AppError> {
start_task(state, Task::Restore(body.request)).await
}
pub async fn progress(
State(state): State<Arc<WebState>>,
Path(id): Path<String>,
) -> Result<Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, AppError> {
let channel = state
.transfer_progress_channels
.read()
.await
.get(&format!("mongo-database:{id}"))
.cloned()
.ok_or_else(|| AppError::from("Database task not found".to_string()))?;
Ok(crate::sse::sse_from_transfer_channel(channel))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelRequest {
task_id: String,
}
pub async fn cancel(Json(request): Json<CancelRequest>) -> Json<serde_json::Value> {
transfer::set_cancelled(&request.task_id).await;
Json(serde_json::json!({ "cancelled": true }))
}
@@ -17,13 +17,40 @@ use futures::stream::Stream;
use futures::StreamExt;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use crate::error::AppError;
use crate::routes::export_download::{attachment_content_disposition, export_download_filename};
use crate::sse::{TransferProgressChannel, TransferReplayEventKind};
use crate::state::{WebExportFile, WebState};
const MONGO_IMPORT_PROGRESS_TTL: Duration = Duration::from_secs(30);
struct RemoveFileOnDrop(PathBuf);
impl Drop for RemoveFileOnDrop {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
struct ExportDownloadStream {
// Fields are dropped in declaration order, so Windows closes the file before deleting it.
chunks: ReaderStream<tokio::fs::File>,
_cleanup: RemoveFileOnDrop,
}
impl Stream for ExportDownloadStream {
type Item = std::io::Result<Bytes>;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
std::pin::Pin::new(&mut self.get_mut().chunks).poll_next(cx)
}
}
fn initial_import_progress(import_id: &str, started_at: Instant) -> MongoImportProgress {
MongoImportProgress {
import_id: import_id.to_string(),
@@ -153,11 +180,11 @@ pub async fn preview_import(
if let Some((source_ref, file_path)) = uploaded_file {
let file_path_str = file_path.to_string_lossy().to_string();
let format = match format.or_else(|| mongodb_import_export::format_from_path(&file_path_str).ok()) {
let format = match format {
Some(format) => format,
None => {
cleanup_uploaded_import_path(&file_path).await;
return Err(AppError::from("Unsupported MongoDB import file type".to_string()));
return Err(AppError::from("MongoDB import format is required".to_string()));
}
};
let preview = mongodb_import_export::preview_mongodb_import_file(&MongoImportPreviewRequest {
@@ -269,7 +296,7 @@ pub async fn execute_import(
let state_clone = state.clone();
tokio::spawn(async move {
let tx_clone = tx.clone();
let _result = mongodb_import_export::import_mongodb_file_core(
let result = mongodb_import_export::import_mongodb_file_core(
&app,
&req,
|id| {
@@ -279,6 +306,17 @@ pub async fn execute_import(
|progress| send_import_progress(&tx_clone, &progress),
)
.await;
if let Err(error) = result {
let current = tx.borrow().clone();
if let Ok(mut progress) = serde_json::from_str::<MongoImportProgress>(&current) {
if progress.status == MongoImportStatus::Running {
progress.phase = MongoImportPhase::Done;
progress.status = MongoImportStatus::Error;
progress.error_message = Some(error.display_message());
send_import_progress(&tx, &progress);
}
}
}
cleanup_uploaded_import_source(&req.file_path).await;
schedule_import_progress_cleanup(state_clone, req.import_id.clone());
});
@@ -402,26 +440,35 @@ pub async fn start_export(
let ext = match req.format {
MongoExportFormat::Csv => "csv",
MongoExportFormat::Ndjson => "ndjson",
MongoExportFormat::Bson if req.gzip => "bson.gz",
MongoExportFormat::Bson => "bson",
};
let tmp_file = tmp_dir.join(format!("mongo_export_{export_id}.{ext}"));
let tmp_file = tmp_dir.join(format!("mongo_export_{}.{ext}", uuid::Uuid::new_v4()));
let file_path = tmp_file.to_string_lossy().to_string();
let download_filename = export_download_filename(&req.file_path, &req.collection, ext);
req.file_path = file_path.clone();
state
.export_files
.write()
.await
.insert(export_id.clone(), WebExportFile { file_path, download_filename, format: ext.to_string() });
let export_file = WebExportFile { file_path, download_filename, format: ext.to_string() };
let channel_key = format!("mongo-export:{export_id}");
let tx = {
let mut channels = state.sse_channels.write().await;
channels.entry(export_id.clone()).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
let mut channels = state.transfer_progress_channels.write().await;
let channel = Arc::new(TransferProgressChannel::new());
channels.insert(channel_key.clone(), channel.clone());
channel
};
let app = state.app.clone();
let state_clone = state.clone();
dbx_core::export_runtime::spawn_export_task(async move {
let mut latest = MongoExportProgress {
export_id: req.export_id.clone(),
status: MongoExportStatus::Running,
documents_read: 0,
bytes_written: 0,
total_documents: None,
error_message: None,
elapsed_ms: 0,
};
let result = mongodb_import_export::export_mongodb_query_core(
&app,
&req,
@@ -430,32 +477,38 @@ pub async fn start_export(
Box::pin(async move { transfer::is_cancelled(&id).await })
},
|progress| {
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx.send(json);
latest = progress;
if latest.status == MongoExportStatus::Running {
if let Ok(json) = serde_json::to_string(&latest) {
tx.send(json, TransferReplayEventKind::Progress);
}
}
},
)
.await;
if let Err(error) = result {
let _ = tokio::fs::remove_file(&req.file_path).await;
state_clone.export_files.write().await.remove(&req.export_id);
let progress = MongoExportProgress {
export_id: req.export_id.clone(),
status: MongoExportStatus::Error,
documents_read: 0,
bytes_written: 0,
total_documents: None,
error_message: Some(error),
elapsed_ms: 0,
};
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx.send(json);
match result {
Ok(_) => {
// The download must exist before a terminal event can reach the browser.
state_clone.export_files.write().await.insert(req.export_id.clone(), export_file);
}
Err(error) => {
let _ = tokio::fs::remove_file(&req.file_path).await;
if latest.status != MongoExportStatus::Cancelled {
latest.status = MongoExportStatus::Error;
}
latest.error_message = Some(error);
}
}
if let Ok(json) = serde_json::to_string(&latest) {
tx.send(json, TransferReplayEventKind::Terminal);
}
transfer::clear_cancelled(&req.export_id).await;
tokio::time::sleep(Duration::from_secs(5)).await;
state_clone.remove_sse_channel(&req.export_id).await;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
state_clone.transfer_progress_channels.write().await.remove(&channel_key);
});
});
Ok(Json(serde_json::json!({ "exportId": export_id })))
@@ -465,12 +518,14 @@ pub async fn export_progress(
State(state): State<Arc<WebState>>,
Path(export_id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, AppError> {
let tx = {
let mut channels = state.sse_channels.write().await;
channels.entry(export_id).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
};
let rx = tx.subscribe();
Ok(crate::sse::sse_from_channel(rx))
let channel = state
.transfer_progress_channels
.read()
.await
.get(&format!("mongo-export:{export_id}"))
.cloned()
.ok_or_else(|| AppError::from("Export not found".to_string()))?;
Ok(crate::sse::sse_from_transfer_channel(channel))
}
pub async fn cancel_export(
@@ -491,17 +546,27 @@ pub async fn export_download(
.await
.remove(&export_id)
.ok_or_else(|| AppError::from("Export file not found".to_string()))?;
let data = tokio::fs::read(&export_file.file_path).await.map_err(|e| AppError::from(e.to_string()))?;
let _ = tokio::fs::remove_file(&export_file.file_path).await;
export_file_response(export_file).await
}
pub(crate) async fn export_file_response(export_file: WebExportFile) -> Result<Response, AppError> {
let file = tokio::fs::File::open(&export_file.file_path).await.map_err(|e| AppError::from(e.to_string()))?;
let content_type = match export_file.format.as_str() {
"csv" => "text/csv; charset=utf-8",
_ => "application/x-ndjson; charset=utf-8",
"ndjson" => "application/x-ndjson; charset=utf-8",
"bson.gz" | "archive.gz" => "application/gzip",
"bson" => "application/bson",
_ => "application/octet-stream",
};
let download = ExportDownloadStream {
chunks: ReaderStream::new(file),
_cleanup: RemoveFileOnDrop(PathBuf::from(&export_file.file_path)),
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, attachment_content_disposition(&export_file.download_filename))
.body(Body::from(data))
.body(Body::from_stream(download))
.map_err(|error| AppError::from(error.to_string()))
}
@@ -509,6 +574,152 @@ pub async fn export_download(
mod tests {
use super::*;
#[tokio::test]
async fn download_keeps_file_until_body_is_consumed_or_abandoned() {
for extension in ["bson", "bson.gz"] {
for consume in [true, false] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(format!("records.{extension}"));
let bytes = vec![42u8; 100_000];
std::fs::write(&path, &bytes).unwrap();
let response = export_file_response(WebExportFile {
file_path: path.to_str().unwrap().into(),
download_filename: format!("records.{extension}"),
format: extension.into(),
})
.await
.unwrap_or_else(|error| panic!("{}", error.message));
assert!(path.exists(), "download file was removed before streaming");
assert_eq!(
response.headers()[header::CONTENT_TYPE],
if extension == "bson" { "application/bson" } else { "application/gzip" }
);
assert!(response.headers()[header::CONTENT_DISPOSITION]
.to_str()
.unwrap()
.contains(&format!("records.{extension}")));
if consume {
assert_eq!(axum::body::to_bytes(response.into_body(), bytes.len()).await.unwrap(), bytes);
} else {
let mut stream = response.into_body().into_data_stream();
assert!(!stream.next().await.unwrap().unwrap().is_empty());
drop(stream);
}
assert!(!path.exists(), "download file leaked after the response ended");
}
}
}
#[tokio::test]
async fn export_failure_and_cancellation_are_replayed_to_late_subscribers() {
use axum::response::IntoResponse;
use dbx_core::{connection::AppState, storage::Storage};
for cancelled in [false, true] {
let directory = tempfile::tempdir().unwrap();
let storage = Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = Arc::new(WebState::for_tests(Arc::new(AppState::new(storage)), directory.path().into()));
let id = uuid::Uuid::new_v4().to_string();
let expected = if cancelled { MongoExportStatus::Cancelled } else { MongoExportStatus::Error };
if cancelled {
transfer::set_cancelled(&id).await;
}
let Json(started) = start_export(
State(state.clone()),
Json(StartExportRequest {
request: MongoExportRequest {
export_id: id.clone(),
connection_id: "missing".into(),
database: "test".into(),
collection: "empty".into(),
filter: None,
projection: None,
sort: None,
collation: None,
format: MongoExportFormat::Bson,
gzip: false,
include_header: false,
file_path: "empty.bson".into(),
execution_id: None,
},
}),
)
.await
.unwrap_or_else(|error| panic!("{}", error.message));
assert_eq!(started["exportId"], id);
let key = format!("mongo-export:{id}");
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let latest = state.transfer_progress_channels.read().await.get(&key).unwrap().latest();
if latest.is_some_and(|json| {
serde_json::from_str::<MongoExportProgress>(&json).unwrap().status == expected
}) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(!state.export_files.read().await.contains_key(&id));
let response = export_progress(State(state.clone()), Path(id))
.await
.unwrap_or_else(|error| panic!("{}", error.message));
let mut stream = response.into_response().into_body().into_data_stream();
let chunk = tokio::time::timeout(Duration::from_secs(1), stream.next()).await.unwrap().unwrap().unwrap();
assert!(String::from_utf8_lossy(&chunk)
.contains(&format!("\"status\":{}", serde_json::to_string(&expected).unwrap())));
}
}
#[tokio::test]
async fn early_import_failure_finishes_progress_and_cleans_source() {
use dbx_core::{connection::AppState, storage::Storage};
let directory = tempfile::tempdir().unwrap();
let storage = Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = Arc::new(WebState::for_tests(Arc::new(AppState::new(storage)), directory.path().into()));
let upload_dir = import_upload_dir(directory.path());
std::fs::create_dir_all(&upload_dir).unwrap();
let path = upload_dir.join("empty.bson");
std::fs::write(&path, []).unwrap();
let id = uuid::Uuid::new_v4().to_string();
let Json(started) = execute_import(
State(state.clone()),
Json(ExecuteImportWrapper {
request: MongoImportRequest {
import_id: id.clone(),
connection_id: "missing".into(),
database: "test".into(),
collection: "empty".into(),
file_path: path.to_str().unwrap().into(),
source_ref: None,
format: mongodb_import_export::MongoImportFormat::Bson,
parse_options: Default::default(),
batch_size: 1,
execution_id: None,
},
}),
)
.await
.unwrap_or_else(|error| panic!("{}", error.message));
assert_eq!(started["importId"], id);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let progress = state.table_import_channels.read().await.get(&id).unwrap().borrow().clone();
let progress: MongoImportProgress = serde_json::from_str(&progress).unwrap();
if progress.status == MongoImportStatus::Error && !path.exists() {
assert_eq!(progress.phase, MongoImportPhase::Done);
assert!(progress.error_message.unwrap().contains("Batch size"));
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
}
#[test]
fn source_ref_rejects_path_traversal() {
let data_dir = std::env::temp_dir().join(format!("dbx-mongo-import-data-{}", uuid::Uuid::new_v4()));
+118
View File
@@ -0,0 +1,118 @@
use futures::StreamExt;
use reqwest::multipart::{Form, Part};
use std::process::Stdio;
use std::time::Duration;
#[tokio::test]
async fn database_dump_upload_and_progress_http_contract() {
let directory = tempfile::tempdir().unwrap();
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let log_path = directory.path().join("server.log");
let mut server = tokio::process::Command::new(env!("CARGO_BIN_EXE_dbx-web"))
.env("DBX_DATA_DIR", directory.path())
.env("DBX_PORT", port.to_string())
.env("DBX_DISABLE_PASSWORD", "1")
.kill_on_drop(true)
.stdout(Stdio::null())
.stderr(std::fs::File::create(&log_path).unwrap())
.spawn()
.unwrap();
let base = format!("http://127.0.0.1:{port}/api/mongo/dump");
let client = reqwest::Client::new();
tokio::time::timeout(Duration::from_secs(20), async {
loop {
if client.get(format!("http://127.0.0.1:{port}/api/auth/check")).send().await.is_ok() {
break;
}
if let Some(status) = server.try_wait().unwrap() {
panic!("Web server exited {status}: {}", std::fs::read_to_string(&log_path).unwrap());
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap();
let bad = Form::new()
.text("format", "archive")
.text("gzip", "false")
.part("file", Part::bytes(vec![0]).file_name("../escape.archive"));
let response = client.post(format!("{base}/source")).multipart(bad).send().await.unwrap();
assert!(!response.status().is_success());
assert!(response.text().await.unwrap().contains("Invalid dump upload path"));
let form = Form::new()
.text("format", "directory")
.text("gzip", "false")
.text("manifest", serde_json::json!([
{"path":"source/records.bson","sizeBytes":0},
{"path":"source/records.metadata.json","sizeBytes":r#"{"collectionName":"records","options":{},"indexes":[],"type":"collection"}"#.len()}
]).to_string())
.part(
"file",
Part::text(r#"{"collectionName":"records","options":{},"indexes":[],"type":"collection"}"#)
.file_name("source/records.metadata.json"),
);
let response = client.post(format!("{base}/source")).multipart(form).send().await.unwrap();
assert!(response.status().is_success(), "{}", response.text().await.unwrap());
let preview: serde_json::Value = response.json().await.unwrap();
assert_eq!(preview["databases"], serde_json::json!(["source"]));
assert_eq!(preview["collections"][0]["name"], "records");
assert!(preview["collections"][0]["documents"].is_null());
assert_eq!(preview["collections"][0]["sourceFiles"].as_array().unwrap().len(), 2);
let source_ref = preview["sourceRef"].as_str().unwrap();
let request = serde_json::json!({ "taskId":"acquire", "connectionId":"missing", "database":"target", "sourceDatabase":"source", "sourceRef":source_ref, "collections":["records"] });
let acquisition = Form::new()
.text("format", "directory")
.text("gzip", "false")
.text("request", request.to_string())
.part("file", Part::bytes(Vec::new()).file_name("source/records.bson"))
.part(
"file",
Part::text(r#"{"collectionName":"records","options":{},"indexes":[],"type":"collection"}"#)
.file_name("source/records.metadata.json"),
);
let response = client.post(format!("{base}/source/upload")).multipart(acquisition).send().await.unwrap();
assert!(response.status().is_success(), "{}", response.text().await.unwrap());
let acquired: serde_json::Value = response.json().await.unwrap();
let acquired_ref = acquired["sourceRef"].as_str().unwrap();
let id = uuid::Uuid::new_v4().to_string();
let response = client.post(format!("{base}/restore")).json(&serde_json::json!({ "request": {
"taskId": id, "connectionId": "missing", "database": "target", "sourceDatabase": "source", "sourceRef": acquired_ref
}})).send().await.unwrap();
assert!(response.status().is_success());
tokio::time::sleep(Duration::from_millis(100)).await;
let events = client.get(format!("{base}/progress/{id}")).send().await.unwrap();
assert!(events.status().is_success());
tokio::time::timeout(Duration::from_secs(5), async {
let mut stream = events.bytes_stream();
let mut text = String::new();
while let Some(chunk) = stream.next().await {
text.push_str(&String::from_utf8_lossy(&chunk.unwrap()));
if text.contains("\"status\":\"error\"") {
return;
}
}
panic!("Missing terminal restore event: {text}");
})
.await
.unwrap();
let response: serde_json::Value = client
.post(format!("{base}/source/release"))
.json(&serde_json::json!({ "sourceRef": source_ref }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["released"], true);
client
.post(format!("{base}/source/release"))
.json(&serde_json::json!({"sourceRef":acquired_ref}))
.send()
.await
.unwrap();
server.kill().await.unwrap();
server.wait().await.unwrap();
}