fix(export): persist macos destination volume identity

This commit is contained in:
Guoyu Su
2026-08-31 23:49:01 +08:00
committed by GitHub
parent d411f04975
commit bc41e8d76d
3 changed files with 366 additions and 47 deletions
@@ -305,6 +305,8 @@ async function startExport() {
});
if (!path) return;
filePath = path;
const { dirname } = await import("@tauri-apps/api/path");
await api.recordDatabaseExportDestination(await dirname(path));
} catch (e: any) {
toast(e?.message || String(e), 5000);
return;
@@ -415,6 +417,7 @@ async function startAllDatabasesExport() {
});
if (!path || Array.isArray(path)) return;
directoryPath = path;
await api.recordDatabaseExportDestination(path);
} catch (e: any) {
toast(e?.message || String(e), 5000);
return;
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../DatabaseExportDialog.vue", import.meta.url), "utf8");
function functionSource(name: string, nextName: string) {
const start = dialogSource.indexOf(`async function ${name}(`);
const end = dialogSource.indexOf(`async function ${nextName}(`, start);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return dialogSource.slice(start, end);
}
describe("DatabaseExportDialog destination identity", () => {
it("records a manually selected file destination before starting a single-database export", () => {
const source = functionSource("startExport", "startAllDatabasesExport");
const selectedPath = source.indexOf("const path = await save(");
const recordedDestination = source.indexOf("recordDatabaseExportDestination(await dirname(path))");
const exportStarted = source.indexOf("isExporting.value = true");
expect(selectedPath).toBeGreaterThanOrEqual(0);
expect(recordedDestination).toBeGreaterThan(selectedPath);
expect(exportStarted).toBeGreaterThan(recordedDestination);
});
it("records a manually selected directory before starting an all-databases export", () => {
const source = functionSource("startAllDatabasesExport", "cancelExport");
const selectedPath = source.indexOf("const path = await openDialog(");
const recordedDestination = source.indexOf("recordDatabaseExportDestination(path)");
const exportStarted = source.indexOf("isExporting.value = true");
expect(selectedPath).toBeGreaterThanOrEqual(0);
expect(recordedDestination).toBeGreaterThan(selectedPath);
expect(exportStarted).toBeGreaterThan(recordedDestination);
});
});
+327 -47
View File
@@ -2074,6 +2074,76 @@ fn export_destination_state_key(dir: &std::path::Path) -> String {
format!("database_export_destination:{}", dir.to_string_lossy())
}
const EXPORT_DESTINATION_IDENTITY_MAGIC: &[u8; 5] = b"DBXEI";
const EXPORT_DESTINATION_IDENTITY_VERSION: u8 = 1;
const EXPORT_DESTINATION_IDENTITY_DEVICE_KIND: u8 = 1;
const EXPORT_DESTINATION_IDENTITY_VOLUME_UUID_KIND: u8 = 2;
#[derive(Clone, Debug, PartialEq, Eq)]
enum ExportDestinationIdentity {
Device(u64),
PersistentVolumeUuid([u8; 16]),
LegacyDevice(u64),
}
impl ExportDestinationIdentity {
fn encode(&self) -> Vec<u8> {
let mut encoded = Vec::with_capacity(EXPORT_DESTINATION_IDENTITY_MAGIC.len() + 2 + 16);
encoded.extend_from_slice(EXPORT_DESTINATION_IDENTITY_MAGIC);
encoded.push(EXPORT_DESTINATION_IDENTITY_VERSION);
match self {
Self::Device(device) => {
encoded.push(EXPORT_DESTINATION_IDENTITY_DEVICE_KIND);
encoded.extend_from_slice(&device.to_le_bytes());
}
Self::PersistentVolumeUuid(uuid) => {
encoded.push(EXPORT_DESTINATION_IDENTITY_VOLUME_UUID_KIND);
encoded.extend_from_slice(uuid);
}
Self::LegacyDevice(_) => unreachable!("legacy identities are decoded for migration, never written"),
}
encoded
}
fn decode(encoded: &[u8]) -> Result<Option<Self>, String> {
if encoded.is_empty() {
return Ok(None);
}
if let Ok(bytes) = <[u8; 8]>::try_from(encoded) {
return Ok(Some(Self::LegacyDevice(u64::from_le_bytes(bytes))));
}
let Some((header, payload)) = encoded.split_at_checked(EXPORT_DESTINATION_IDENTITY_MAGIC.len() + 2) else {
return Err("Stored database export destination identity is truncated".to_string());
};
if &header[..EXPORT_DESTINATION_IDENTITY_MAGIC.len()] != EXPORT_DESTINATION_IDENTITY_MAGIC {
return Err("Stored database export destination identity has an unknown format".to_string());
}
if header[EXPORT_DESTINATION_IDENTITY_MAGIC.len()] != EXPORT_DESTINATION_IDENTITY_VERSION {
return Err("Stored database export destination identity uses an unsupported version".to_string());
}
match header[EXPORT_DESTINATION_IDENTITY_MAGIC.len() + 1] {
EXPORT_DESTINATION_IDENTITY_DEVICE_KIND => <[u8; 8]>::try_from(payload)
.map(|bytes| Some(Self::Device(u64::from_le_bytes(bytes))))
.map_err(|_| "Stored database export destination device identity has an invalid length".to_string()),
EXPORT_DESTINATION_IDENTITY_VOLUME_UUID_KIND => <[u8; 16]>::try_from(payload)
.map(|bytes| Some(Self::PersistentVolumeUuid(bytes)))
.map_err(|_| "Stored database export destination volume UUID has an invalid length".to_string()),
_ => Err("Stored database export destination identity has an unknown kind".to_string()),
}
}
fn matches(&self, current: &Self) -> bool {
match (self, current) {
(Self::Device(expected), Self::Device(actual))
| (Self::LegacyDevice(expected), Self::Device(actual))
| (Self::Device(expected), Self::LegacyDevice(actual))
| (Self::LegacyDevice(expected), Self::LegacyDevice(actual)) => expected == actual,
(Self::PersistentVolumeUuid(expected), Self::PersistentVolumeUuid(actual)) => expected == actual,
_ => false,
}
}
}
/// Records the destination identity for a scheduled backup as soon as it is
/// configured, not just after its first successful export. Scheduled plans
/// live in the frontend and may not run for hours after being saved; without
@@ -2094,17 +2164,16 @@ pub async fn record_export_destination_identity(
dir.display()
));
}
save_export_destination_dir_device_id(state, dir).await
let identity = export_destination_identity_for_path(dir);
save_export_destination_identity(state, dir, identity.as_ref()).await
}
async fn save_export_destination_dir_device_id(
async fn save_export_destination_identity(
state: &crate::connection::AppState,
dir: &std::path::Path,
identity: Option<&ExportDestinationIdentity>,
) -> Result<(), String> {
let value = match export_destination_device_id_for_path(dir) {
Some(dev) => dev.to_le_bytes().to_vec(),
None => Vec::new(),
};
let value = identity.map(ExportDestinationIdentity::encode).unwrap_or_default();
state.storage.save_state(&export_destination_state_key(dir), &value, "application/octet-stream").await
}
@@ -2129,30 +2198,32 @@ async fn save_export_destination_dir_device_id(
async fn ensure_export_destination_dir(
state: &crate::connection::AppState,
dir: &std::path::Path,
) -> Result<Option<u64>, String> {
) -> Result<Option<ExportDestinationIdentity>, String> {
let key = export_destination_state_key(dir);
let recorded_dev: Option<Option<u64>> = state
let recorded_identity = state
.storage
.load_state(&key)
.await?
.map(|(bytes, _content_type)| <[u8; 8]>::try_from(bytes.as_slice()).ok().map(u64::from_le_bytes));
.map(|(bytes, _content_type)| ExportDestinationIdentity::decode(&bytes))
.transpose()?;
if dir.is_dir() {
if let Some(Some(recorded_dev)) = recorded_dev {
if let Some(current_dev) = export_destination_device_id_for_path(dir) {
if current_dev != recorded_dev {
return Err(format!(
"Backup directory {} now resolves to a different filesystem than the last \
successful export to this location. Refusing to write here automatically -- \
if this directory is on a removable or network drive, make sure the correct \
drive is connected before running the backup.",
dir.display()
));
}
let current_identity = export_destination_identity_for_path(dir);
if let Some(Some(recorded_identity)) = recorded_identity.as_ref() {
if recorded_export_destination_identity_mismatch(recorded_identity, current_identity.as_ref()) {
return Err(format!(
"Backup directory {} now resolves to a different filesystem than the last \
successful export to this location. Refusing to write here automatically -- \
if this directory is on a removable or network drive, make sure the correct \
drive is connected before running the backup.",
dir.display()
));
}
}
save_export_destination_identity(state, dir, current_identity.as_ref()).await?;
return Ok(current_identity);
} else {
if recorded_dev.is_some() {
if recorded_identity.is_some() {
return Err(format!(
"Backup directory {} is missing. It was configured or previously used for exports to \
this location, so dbx will not recreate it automatically -- if this is on a removable \
@@ -2163,8 +2234,25 @@ async fn ensure_export_destination_dir(
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup directory: {e}"))?;
}
save_export_destination_dir_device_id(state, dir).await?;
Ok(export_destination_device_id_for_path(dir))
let current_identity = export_destination_identity_for_path(dir);
save_export_destination_identity(state, dir, current_identity.as_ref()).await?;
Ok(current_identity)
}
fn recorded_export_destination_identity_mismatch(
recorded: &ExportDestinationIdentity,
current: Option<&ExportDestinationIdentity>,
) -> bool {
#[cfg(target_os = "macos")]
if matches!(recorded, ExportDestinationIdentity::LegacyDevice(_)) {
// Legacy macOS state contains a transient st_dev value, not a durable
// volume identity. Even an equal number after remount cannot prove it
// is the volume the user selected, so only an explicit record action
// may replace it with the persistent UUID.
return true;
}
export_destination_identity_mismatch(Some(recorded), current)
}
/// Whether a destination's identity, checked once via [`ensure_export_destination_dir`]
@@ -2172,47 +2260,101 @@ async fn ensure_export_destination_dir(
/// changed in between. An unknown expected identity has nothing to compare,
/// but once an expected identity is known, failing to identify the opened
/// handle must fail closed rather than allowing an unverified write.
fn export_destination_identity_mismatch(expected: Option<u64>, actual: Option<u64>) -> bool {
expected.is_some_and(|expected| actual != Some(expected))
fn export_destination_identity_mismatch(
expected: Option<&ExportDestinationIdentity>,
actual: Option<&ExportDestinationIdentity>,
) -> bool {
expected.is_some_and(|expected| actual.is_none_or(|actual| !expected.matches(actual)))
}
#[cfg(unix)]
fn export_destination_device_id_for_path(dir: &std::path::Path) -> Option<u64> {
#[cfg(all(unix, not(target_os = "macos")))]
fn export_destination_identity_for_path(dir: &std::path::Path) -> Option<ExportDestinationIdentity> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(dir).ok().map(|metadata| metadata.dev())
std::fs::metadata(dir).ok().map(|metadata| ExportDestinationIdentity::Device(metadata.dev()))
}
#[cfg(unix)]
fn export_destination_device_id_for_file(file: &std::fs::File) -> Option<u64> {
#[cfg(all(unix, not(target_os = "macos")))]
fn export_destination_identity_for_file(file: &std::fs::File) -> Option<ExportDestinationIdentity> {
use std::os::unix::fs::MetadataExt;
file.metadata().ok().map(|metadata| metadata.dev())
file.metadata().ok().map(|metadata| ExportDestinationIdentity::Device(metadata.dev()))
}
#[cfg(target_os = "macos")]
fn export_destination_identity_for_path(dir: &std::path::Path) -> Option<ExportDestinationIdentity> {
let directory = std::fs::File::open(dir).ok()?;
export_destination_identity_for_file(&directory)
}
#[cfg(target_os = "macos")]
fn export_destination_identity_for_file(file: &std::fs::File) -> Option<ExportDestinationIdentity> {
macos_export_destination::persistent_volume_uuid(file).map(ExportDestinationIdentity::PersistentVolumeUuid).or_else(
|| {
use std::os::unix::fs::MetadataExt;
file.metadata().ok().map(|metadata| ExportDestinationIdentity::Device(metadata.dev()))
},
)
}
#[cfg(windows)]
fn export_destination_device_id_for_path(dir: &std::path::Path) -> Option<u64> {
windows_export_destination::device_id_for_path(dir)
fn export_destination_identity_for_path(dir: &std::path::Path) -> Option<ExportDestinationIdentity> {
windows_export_destination::device_id_for_path(dir).map(ExportDestinationIdentity::Device)
}
#[cfg(windows)]
fn export_destination_device_id_for_file(file: &std::fs::File) -> Option<u64> {
windows_export_destination::device_id_for_handle(file)
fn export_destination_identity_for_file(file: &std::fs::File) -> Option<ExportDestinationIdentity> {
windows_export_destination::device_id_for_handle(file).map(ExportDestinationIdentity::Device)
}
#[cfg(not(any(unix, windows)))]
fn export_destination_device_id_for_path(_dir: &std::path::Path) -> Option<u64> {
fn export_destination_identity_for_path(_dir: &std::path::Path) -> Option<ExportDestinationIdentity> {
None
}
#[cfg(not(any(unix, windows)))]
fn export_destination_device_id_for_file(_file: &std::fs::File) -> Option<u64> {
fn export_destination_identity_for_file(_file: &std::fs::File) -> Option<ExportDestinationIdentity> {
None
}
#[cfg(target_os = "macos")]
mod macos_export_destination {
use std::os::fd::AsRawFd;
#[repr(C)]
struct VolumeUuidBuffer {
length: u32,
uuid: [u8; 16],
}
pub(super) fn persistent_volume_uuid(file: &std::fs::File) -> Option<[u8; 16]> {
let mut attributes = nix::libc::attrlist {
bitmapcount: nix::libc::ATTR_BIT_MAP_COUNT,
reserved: 0,
commonattr: 0,
volattr: nix::libc::ATTR_VOL_INFO | nix::libc::ATTR_VOL_UUID,
dirattr: 0,
fileattr: 0,
forkattr: 0,
};
let mut buffer = VolumeUuidBuffer { length: 0, uuid: [0; 16] };
let result = unsafe {
nix::libc::fgetattrlist(
file.as_raw_fd(),
std::ptr::from_mut(&mut attributes).cast(),
std::ptr::from_mut(&mut buffer).cast(),
std::mem::size_of::<VolumeUuidBuffer>(),
0,
)
};
(result == 0 && buffer.length as usize >= std::mem::size_of::<VolumeUuidBuffer>() && buffer.uuid != [0; 16])
.then_some(buffer.uuid)
}
}
/// Windows has no stable `std` API for a directory or file's volume identity
/// (`MetadataExt::volume_serial_number` is still gated behind the unstable
/// `windows_by_handle` feature), so this queries `dwVolumeSerialNumber` from
/// `BY_HANDLE_FILE_INFORMATION` directly. Using the *handle* rather than
/// re-resolving the path is what makes `export_destination_device_id_for_file`
/// re-resolving the path is what makes `export_destination_identity_for_file`
/// safe to call on an already-open `File`: it reports the volume the handle
/// was actually opened against, not whatever currently sits at that path.
#[cfg(windows)]
@@ -2335,10 +2477,10 @@ async fn export_database_sql_core_inner(
))
.await?;
// 4. Create file
let mut expected_destination_dev = None;
let mut expected_destination_identity = None;
if let Some(parent) = std::path::Path::new(&request.file_path).parent() {
if !parent.as_os_str().is_empty() {
expected_destination_dev = ensure_export_destination_dir(state, parent).await?;
expected_destination_identity = ensure_export_destination_dir(state, parent).await?;
}
}
let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?;
@@ -2347,7 +2489,11 @@ async fn export_database_sql_core_inner(
// at the same path in between. Re-check the identity of the handle we
// actually opened, not just the path, and refuse to keep a backup that
// landed on the wrong filesystem. See #6327.
if export_destination_identity_mismatch(expected_destination_dev, export_destination_device_id_for_file(&file)) {
let opened_destination_identity = export_destination_identity_for_file(&file);
if export_destination_identity_mismatch(
expected_destination_identity.as_ref(),
opened_destination_identity.as_ref(),
) {
drop(file);
let _ = std::fs::remove_file(&request.file_path);
return Err(format!(
@@ -5194,6 +5340,47 @@ mod tests {
let _ = std::fs::remove_dir_all(&scratch);
}
#[cfg(any(unix, windows))]
#[tokio::test]
async fn ensure_export_destination_dir_refuses_a_changed_recorded_identity() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-changed-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let state = test_app_state(&scratch).await;
let destination = scratch.join("backups");
std::fs::create_dir_all(&destination).unwrap();
let current = super::export_destination_identity_for_path(&destination)
.expect("the platform should identify a local export destination");
let different = match current {
super::ExportDestinationIdentity::Device(device) => {
super::ExportDestinationIdentity::Device(device.wrapping_add(1))
}
super::ExportDestinationIdentity::PersistentVolumeUuid(mut uuid) => {
uuid[0] ^= 0xff;
super::ExportDestinationIdentity::PersistentVolumeUuid(uuid)
}
super::ExportDestinationIdentity::LegacyDevice(_) => {
unreachable!("freshly resolved identities are never legacy values")
}
};
state
.storage
.save_state(
&super::export_destination_state_key(&destination),
&different.encode(),
"application/octet-stream",
)
.await
.unwrap();
let error = ensure_export_destination_dir(&state, &destination)
.await
.expect_err("an unattended export must reject a changed persistent destination identity");
assert!(error.contains("different filesystem"));
let _ = std::fs::remove_dir_all(&scratch);
}
// Regression test for review feedback on #6327: the directory identity
// check and the later `File::create` are separate operations, so the
// mount can disappear and be replaced by something else at the same path
@@ -5203,19 +5390,112 @@ mod tests {
// mid-write is not something a portable unit test can simulate.
#[test]
fn export_destination_identity_mismatch_detects_a_changed_device() {
assert!(export_destination_identity_mismatch(Some(1), Some(2)));
assert!(!export_destination_identity_mismatch(Some(1), Some(1)));
let device_one = super::ExportDestinationIdentity::Device(1);
let same_device = super::ExportDestinationIdentity::Device(1);
let device_two = super::ExportDestinationIdentity::Device(2);
assert!(export_destination_identity_mismatch(Some(&device_one), Some(&device_two)));
assert!(!export_destination_identity_mismatch(Some(&device_one), Some(&same_device)));
assert!(
!export_destination_identity_mismatch(None, Some(2)),
!export_destination_identity_mismatch(None, Some(&device_two)),
"an unknown expected device has nothing to compare against"
);
assert!(
export_destination_identity_mismatch(Some(1), None),
export_destination_identity_mismatch(Some(&device_one), None),
"an opened file with unknown identity must not bypass a known expected device"
);
assert!(!export_destination_identity_mismatch(None, None));
}
#[test]
fn export_destination_identity_encoding_distinguishes_legacy_devices_and_volume_uuids() {
let device = super::ExportDestinationIdentity::Device(42);
let uuid = super::ExportDestinationIdentity::PersistentVolumeUuid([7; 16]);
assert_eq!(
super::ExportDestinationIdentity::decode(&device.encode()).unwrap(),
Some(device),
"new device identities should use the tagged format"
);
assert_eq!(
super::ExportDestinationIdentity::decode(&uuid.encode()).unwrap(),
Some(uuid),
"persistent UUID identities should round trip"
);
assert_eq!(
super::ExportDestinationIdentity::decode(&42_u64.to_le_bytes()).unwrap(),
Some(super::ExportDestinationIdentity::LegacyDevice(42)),
"existing untagged state must remain recognizable as legacy data"
);
assert!(super::ExportDestinationIdentity::decode(b"invalid").is_err());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn explicitly_recording_a_macos_destination_replaces_legacy_device_state() {
use std::os::unix::fs::MetadataExt;
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-macos-migration-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let state = test_app_state(&scratch).await;
let destination = scratch.join("backups");
std::fs::create_dir_all(&destination).unwrap();
state
.storage
.save_state(
&super::export_destination_state_key(&destination),
&std::fs::metadata(&destination).unwrap().dev().to_le_bytes(),
"application/octet-stream",
)
.await
.unwrap();
assert!(
ensure_export_destination_dir(&state, &destination).await.is_err(),
"an unattended export must not silently replace legacy identity state, even when st_dev still matches"
);
record_export_destination_identity(&state, &destination)
.await
.expect("explicitly confirming the destination should replace legacy state");
ensure_export_destination_dir(&state, &destination)
.await
.expect("the confirmed persistent volume identity should match");
let (encoded, _) =
state.storage.load_state(&super::export_destination_state_key(&destination)).await.unwrap().unwrap();
assert!(matches!(
super::ExportDestinationIdentity::decode(&encoded).unwrap(),
Some(super::ExportDestinationIdentity::PersistentVolumeUuid(_))
));
let _ = std::fs::remove_dir_all(&scratch);
}
#[cfg(target_os = "macos")]
#[test]
fn export_destination_identity_for_path_and_open_file_agree_on_macos() {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-macos-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let dir_identity = super::export_destination_identity_for_path(&scratch);
assert!(
matches!(dir_identity, Some(super::ExportDestinationIdentity::PersistentVolumeUuid(_))),
"a local macOS directory should report its persistent volume UUID"
);
let file_path = scratch.join("probe.txt");
let file = std::fs::File::create(&file_path).unwrap();
let file_identity = super::export_destination_identity_for_file(&file);
drop(file);
assert_eq!(
dir_identity, file_identity,
"an open file and its parent directory must resolve to the same volume"
);
let _ = std::fs::remove_dir_all(&scratch);
}
// Regression test for review feedback on #6327: the non-Unix path used
// to report no device identity at all, so replacing a Windows drive or
// mount at the same path went undetected. This only runs on native
@@ -5228,12 +5508,12 @@ mod tests {
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-win-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&scratch).unwrap();
let dir_dev = super::export_destination_device_id_for_path(&scratch);
let dir_dev = super::export_destination_identity_for_path(&scratch);
assert!(dir_dev.is_some(), "a real local directory should report a volume serial number");
let file_path = scratch.join("probe.txt");
let file = std::fs::File::create(&file_path).unwrap();
let file_dev = super::export_destination_device_id_for_file(&file);
let file_dev = super::export_destination_identity_for_file(&file);
drop(file);
assert_eq!(dir_dev, file_dev, "a file and its parent directory must resolve to the same volume");