mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(export): persist macos destination volume identity
This commit is contained in:
@@ -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;
|
||||
|
||||
Vendored
+36
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user