fix: harden sync safety — conflict backup, timestamp parsing, credential zeroization
Back up local files before overwriting during ConflictRemoteWins so data is never silently lost. Fix false-positive change detection by parsing timestamps before comparing (different formats like RFC3339 vs HTTP date were never equal as strings). Add zeroize crate to zero WebDAV credentials in memory on drop, preventing exposure in core dumps.
This commit is contained in:
parent
fa1125bfeb
commit
e0c7292a7e
1
apps/tauri/src-tauri/Cargo.lock
generated
1
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -2394,6 +2394,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ sha2 = { workspace = true }
|
|||
quick-xml = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ pub fn compute_sync_actions(
|
|||
// Both present, base known: check for changes
|
||||
(Some(l), Some(r), Some(b)) => {
|
||||
let local_changed = l.checksum != b.checksum;
|
||||
let remote_changed = r.size != b.size || r.last_modified.as_deref() != b.modified_at.as_deref();
|
||||
// Compare remote vs base using parsed timestamps to avoid format mismatches
|
||||
let remote_changed = r.size != b.size || !timestamps_equal(r.last_modified.as_deref(), b.modified_at.as_deref());
|
||||
|
||||
match (local_changed, remote_changed) {
|
||||
(false, false) => {} // Skip, unchanged
|
||||
|
|
@ -173,7 +174,7 @@ pub fn compute_sync_actions(
|
|||
|
||||
// Remote present, local gone, base known: local was deleted
|
||||
(None, Some(_), Some(b)) => {
|
||||
let remote_changed = remote.is_some_and(|r| r.size != b.size || r.last_modified.as_deref() != b.modified_at.as_deref());
|
||||
let remote_changed = remote.is_some_and(|r| r.size != b.size || !timestamps_equal(r.last_modified.as_deref(), b.modified_at.as_deref()));
|
||||
if remote_changed {
|
||||
// deleted locally + modified remotely -> download (remote wins)
|
||||
actions.push(SyncAction::Download { path: path.to_string() });
|
||||
|
|
@ -197,6 +198,23 @@ pub fn compute_sync_actions(
|
|||
actions
|
||||
}
|
||||
|
||||
/// Compare two timestamps for equality by parsing both, tolerating format differences.
|
||||
fn timestamps_equal(a: Option<&str>, b: Option<&str>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
// Try string equality first (fast path)
|
||||
if a == b { return true; }
|
||||
// Parse both and compare as DateTime
|
||||
match (parse_timestamp(a), parse_timestamp(b)) {
|
||||
(Some(ta), Some(tb)) => ta == tb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if local wins based on timestamps. True means local wins.
|
||||
fn local_wins(local_modified: Option<&str>, remote_modified: Option<&str>) -> bool {
|
||||
// Try parsing both; if we can't parse, local wins by default
|
||||
|
|
@ -582,12 +600,11 @@ async fn execute_action(
|
|||
report: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
SyncAction::Upload { path } | SyncAction::ConflictLocalWins { path } => {
|
||||
SyncAction::Upload { path } => {
|
||||
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let data = std::fs::read(&local_path)?;
|
||||
let checksum = compute_checksum(&data);
|
||||
|
||||
// Ensure remote parent directory exists
|
||||
if let Some(parent) = path_parent(path) {
|
||||
client.ensure_dir(parent).await?;
|
||||
}
|
||||
|
|
@ -602,7 +619,25 @@ async fn execute_action(
|
|||
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
|
||||
}
|
||||
|
||||
SyncAction::Download { path } | SyncAction::ConflictRemoteWins { path } => {
|
||||
SyncAction::ConflictLocalWins { path } => {
|
||||
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let data = std::fs::read(&local_path)?;
|
||||
let checksum = compute_checksum(&data);
|
||||
|
||||
if let Some(parent) = path_parent(path) {
|
||||
client.ensure_dir(parent).await?;
|
||||
}
|
||||
|
||||
report(&format!(" ^ Conflict: uploading local version of {}", path));
|
||||
client.put_file(path, data.clone()).await?;
|
||||
|
||||
let modified = std::fs::metadata(&local_path).ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
|
||||
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
|
||||
}
|
||||
|
||||
SyncAction::Download { path } => {
|
||||
report(&format!(" v Downloading {}", path));
|
||||
let data = client.get_file(path).await?;
|
||||
let checksum = compute_checksum(&data);
|
||||
|
|
@ -620,6 +655,29 @@ async fn execute_action(
|
|||
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
|
||||
}
|
||||
|
||||
SyncAction::ConflictRemoteWins { path } => {
|
||||
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
// Back up local version before overwriting with remote
|
||||
if local_path.exists() {
|
||||
let backup_path = local_path.with_extension("conflict-backup");
|
||||
let _ = std::fs::copy(&local_path, &backup_path);
|
||||
report(&format!(" ! Backed up local version to {}", backup_path.display()));
|
||||
}
|
||||
report(&format!(" v Conflict: downloading remote version of {}", path));
|
||||
let data = client.get_file(path).await?;
|
||||
let checksum = compute_checksum(&data);
|
||||
|
||||
if let Some(parent) = local_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&local_path, &data)?;
|
||||
|
||||
let modified = std::fs::metadata(&local_path).ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
|
||||
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
|
||||
}
|
||||
|
||||
SyncAction::DeleteLocal { path } => {
|
||||
report(&format!(" x Deleting local {}", path));
|
||||
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use reqwest::Client;
|
||||
use zeroize::Zeroize;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Information about a file on the remote WebDAV server.
|
||||
|
|
@ -18,6 +19,13 @@ pub struct WebDavClient {
|
|||
_password: String,
|
||||
}
|
||||
|
||||
impl Drop for WebDavClient {
|
||||
fn drop(&mut self) {
|
||||
self._password.zeroize();
|
||||
self._username.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl WebDavClient {
|
||||
pub fn new(base_url: &str, username: &str, password: &str) -> Self {
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
|
|
|
|||
Loading…
Reference in a new issue