mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
fixed fuse bugs from BUGS.md
- Durable scratch/staging publication and preservation on failure.
- Transactional pending-operation supersession.
- Conflict copies cannot overwrite older copies or lose the source.
- Single-entry wipe protection and incomplete-scan aborts.
- Failed local deletion retains its baseline.
- Downloads are fsynced before baseline settlement.
- Future database schemas are refused.
- Event cursors advance only after successful invalidation and persistence.
- IPC frame, timeout, and concurrency limits.
- Atomic config saves; malformed configs are preserved.
- Sensitive directories require correct ownership, real directories, and 0700.
- Debian package now includes service and tray autostart files.
- Release workflow enforces versions, tests, lint, formatting, and offline acceptance.
- CI now runs offline filesystem acceptance.
This commit is contained in:
1 parent
a76f36bdff
commit
8be0f04b61
16 files changed
+976
-125
No files matched your search
@@ -126,12 +126,16 @@ pub(super) async fn run_event_sync(
|
||||
match client.enumerate_events(&scope, None).await {
|
||||
Ok(events) => {
|
||||
let head = events.last().map(|e| e.id().clone());
|
||||
if let Some(c) = &head
|
||||
&& let Err(e) = db.set_event_cursor(c.as_str())
|
||||
{
|
||||
warn!(error = %e, "persist seed cursor failed");
|
||||
let Some(c) = &head else {
|
||||
break None;
|
||||
};
|
||||
if let Err(e) = db.set_event_cursor(c.as_str()) {
|
||||
warn!(error = %e, ?delay, "persist seed cursor failed; retrying");
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(ONLINE_PROBE_MAX);
|
||||
continue;
|
||||
}
|
||||
break head;
|
||||
break Some(c.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
||||
@@ -168,15 +172,19 @@ pub(super) async fn run_event_sync(
|
||||
// the life of the daemon (SDK plan #9). `apply_event` only touches
|
||||
// our FUSE state, so nothing else does this.
|
||||
if let Err(e) = client.invalidate_caches_for_event(event).await {
|
||||
warn!(error = %e, "sdk cache invalidation for event failed");
|
||||
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
||||
break;
|
||||
}
|
||||
apply_event(&state, &content, &pending, ¬ifier, event);
|
||||
}
|
||||
cursor = events.last().map(|e| e.id().clone());
|
||||
if let Some(c) = &cursor
|
||||
&& let Err(e) = db.set_event_cursor(c.as_str())
|
||||
{
|
||||
warn!(error = %e, "persist event cursor failed");
|
||||
let applied = event.id().clone();
|
||||
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
||||
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
||||
break;
|
||||
}
|
||||
// Advance only after this exact event is durably acknowledged. If a
|
||||
// later event fails, polling resumes from here and replays nothing
|
||||
// that was not already made visible locally.
|
||||
cursor = Some(applied);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
@@ -27,6 +27,67 @@ use tracing::{info, warn};
|
||||
use super::transfers::CountingReader;
|
||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_CONTROL_HANDLERS: usize = 64;
|
||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct ControlHandlerPermit;
|
||||
|
||||
impl ControlHandlerPermit {
|
||||
fn acquire() -> Option<Self> {
|
||||
ACTIVE_CONTROL_HANDLERS
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
||||
})
|
||||
.ok()
|
||||
.map(|_| Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlHandlerPermit {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE_CONTROL_HANDLERS.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
break;
|
||||
}
|
||||
let take = available
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\n')
|
||||
.map_or(available.len(), |index| index + 1);
|
||||
if bytes.len().saturating_add(take) > MAX_CONTROL_REQUEST_BYTES as usize {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"control request exceeds size limit",
|
||||
));
|
||||
}
|
||||
bytes.extend_from_slice(&available[..take]);
|
||||
reader.consume(take);
|
||||
if bytes.ends_with(b"\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if bytes.len() as u64 > MAX_CONTROL_REQUEST_BYTES || !bytes.ends_with(b"\n") {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"control request exceeds limit or is not newline terminated",
|
||||
));
|
||||
}
|
||||
String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||
/// the mount root.
|
||||
@@ -44,6 +105,10 @@ fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||
/// Handle one control-socket connection: read a single JSON request line,
|
||||
/// dispatch it against `core`, and write back a JSON response line.
|
||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set request timeout");
|
||||
return;
|
||||
}
|
||||
let mut reader = BufReader::new(match stream.try_clone() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -51,10 +116,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
return;
|
||||
}
|
||||
});
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let line = match read_request_line(&mut reader) {
|
||||
Ok(Some(line)) if !line.trim().is_empty() => line,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "control: rejected request");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
||||
Ok(CtlRequest::Status) => {
|
||||
let pins = core.cache.list_pins();
|
||||
@@ -792,12 +861,22 @@ pub(crate) fn run_control_socket(
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
let Some(permit) = ControlHandlerPermit::acquire() else {
|
||||
warn!(
|
||||
limit = MAX_CONTROL_HANDLERS,
|
||||
"control: rejecting connection at handler limit"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.clone();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || handle_control_conn(&core, &username, &mountpoint, stream))
|
||||
.spawn(move || {
|
||||
let _permit = permit;
|
||||
handle_control_conn(&core, &username, &mountpoint, stream);
|
||||
})
|
||||
{
|
||||
warn!(error = %e, "control: spawn handler failed");
|
||||
}
|
||||
@@ -808,3 +887,38 @@ pub(crate) fn run_control_socket(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod request_limit_tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn accepts_one_bounded_newline_terminated_request() {
|
||||
let mut input = Cursor::new(b"{\"Status\":null}\n".to_vec());
|
||||
assert_eq!(
|
||||
read_request_line(&mut input).unwrap().as_deref(),
|
||||
Some("{\"Status\":null}\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_request_without_a_terminating_newline() {
|
||||
let mut input = Cursor::new(b"{\"Status\":null}".to_vec());
|
||||
assert_eq!(
|
||||
read_request_line(&mut input).unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_request_larger_than_the_limit() {
|
||||
let mut bytes = vec![b'x'; MAX_CONTROL_REQUEST_BYTES as usize + 1];
|
||||
bytes.push(b'\n');
|
||||
let mut input = Cursor::new(bytes);
|
||||
assert_eq!(
|
||||
read_request_line(&mut input).unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1129,17 +1129,16 @@ impl Filesystem for ProtonFs {
|
||||
// A failure past this point has already trashed any node the rename was
|
||||
// replacing, so put it back rather than leaving the caller with neither
|
||||
// the source moved nor the destination intact.
|
||||
if newname != name {
|
||||
if let Err(e) = self
|
||||
if newname != name
|
||||
&& let Err(e) = self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.rename_node(&uid, &newname, None))
|
||||
{
|
||||
error!(%uid, error = %e, "rename failed");
|
||||
self.core.restore_replaced(victim.as_ref(), &newname);
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
{
|
||||
error!(%uid, error = %e, "rename failed");
|
||||
self.core.restore_replaced(victim.as_ref(), &newname);
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
if newparent != parent {
|
||||
let mut attempts = 0u32;
|
||||
|
||||
@@ -1548,8 +1548,32 @@ impl Core {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%uid, error = %e, "staging write failed; bytes lost");
|
||||
let _ = std::fs::remove_file(src);
|
||||
// `src` is the only known copy of bytes the kernel already
|
||||
// accepted. A failure to publish them into staging must never
|
||||
// turn into permission to delete them: leave the scratch file
|
||||
// in place so startup recovery or a human can still salvage it.
|
||||
error!(
|
||||
%uid,
|
||||
error = %e,
|
||||
scratch = %src.display(),
|
||||
"staging write failed; retaining the only scratch copy"
|
||||
);
|
||||
let name = {
|
||||
let st = self.state.lock();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map(|entry| entry.node.name.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
&name,
|
||||
format!(
|
||||
"write not queued and could not be staged; recovery copy retained at {}",
|
||||
src.display()
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+146
-29
@@ -55,9 +55,11 @@ use local_scan::is_write_mode;
|
||||
use local_scan::open_for_write_set;
|
||||
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
|
||||
pub(crate) use planner::base_name;
|
||||
#[cfg(test)]
|
||||
use planner::conflict_path;
|
||||
use planner::{
|
||||
FilePlan, classification_order, conflict_path, filter_baseline, guard_local_wipe, join_rel,
|
||||
parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
||||
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
|
||||
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
||||
};
|
||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||
/// engine thread (never inside a task).
|
||||
@@ -711,7 +713,17 @@ impl Core {
|
||||
}
|
||||
},
|
||||
FilePlan::DeleteLocal => {
|
||||
let _ = std::fs::remove_file(local_root.join(rel_to_path(rel)));
|
||||
if let Err(e) = std::fs::remove_file(local_root.join(rel_to_path(rel))) {
|
||||
warn!(rel, error = %e, "sync: remove local file failed");
|
||||
self.log_activity(
|
||||
ActivityKind::Trash,
|
||||
base_name(rel),
|
||||
e.to_string(),
|
||||
false,
|
||||
);
|
||||
outcome.errors += 1;
|
||||
continue;
|
||||
}
|
||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||
self.log_activity(ActivityKind::Trash, base_name(rel), "removed locally", true);
|
||||
outcome.deleted += 1;
|
||||
@@ -758,7 +770,12 @@ impl Core {
|
||||
// Deferred folder deletions, deepest first.
|
||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||
for rel in delete_local_dirs {
|
||||
let _ = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel)));
|
||||
if let Err(e) = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))) {
|
||||
warn!(rel, error = %e, "sync: remove local folder failed");
|
||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||
outcome.errors += 1;
|
||||
continue;
|
||||
}
|
||||
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
||||
self.log_activity(
|
||||
ActivityKind::Trash,
|
||||
@@ -802,18 +819,24 @@ impl Core {
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Ok(stripped) = path.strip_prefix(root) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rel) = stripped.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||
format!(
|
||||
"local path {} escaped sync root {}: {e}",
|
||||
path.display(),
|
||||
root.display()
|
||||
)
|
||||
})?;
|
||||
let rel = stripped.to_str().ok_or_else(|| {
|
||||
format!(
|
||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
// Ignore our own in-flight download temp files.
|
||||
if rel.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
@@ -1153,12 +1176,9 @@ impl Core {
|
||||
// pass), then take the remote version as the shared truth.
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
if path.exists() {
|
||||
let conflict = conflict_path(&path, now_secs());
|
||||
if let Err(e) = std::fs::rename(&path, &conflict) {
|
||||
warn!(rel, error = %e, "sync: could not set aside conflict copy");
|
||||
} else {
|
||||
info!(rel, "sync: kept local changes as a conflict copy");
|
||||
}
|
||||
preserve_conflict_copy(&path, now_secs())
|
||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||
info!(rel, "sync: kept local changes as a conflict copy");
|
||||
}
|
||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
||||
.await?;
|
||||
@@ -1171,8 +1191,7 @@ impl Core {
|
||||
// evicted. Push the conflict copy now, while it still exists, and
|
||||
// leave the remote file holding the original path.
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
let conflict = conflict_path(&path, now_secs());
|
||||
std::fs::rename(&path, &conflict)
|
||||
let conflict = preserve_conflict_copy(&path, now_secs())
|
||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||
let name = conflict
|
||||
.file_name()
|
||||
@@ -1297,13 +1316,24 @@ impl Core {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("download {rel}: {e}")
|
||||
})?;
|
||||
out.into_inner().flush().ok();
|
||||
let mut out = out.into_inner();
|
||||
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
||||
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
||||
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
||||
if let Ok(f) = std::fs::File::options().write(true).open(&path) {
|
||||
let _ =
|
||||
f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime.max(0) as u64));
|
||||
let f = std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("open placed file {rel}: {e}"))?;
|
||||
f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime.max(0) as u64))
|
||||
.map_err(|e| format!("set mtime for {rel}: {e}"))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| format!("sync placed file {rel}: {e}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::File::open(parent)
|
||||
.and_then(|dir| dir.sync_all())
|
||||
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
||||
}
|
||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
||||
}
|
||||
@@ -1350,6 +1380,46 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||
/// same timestamp. The original is removed only after the new copy is durable.
|
||||
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
for suffix in 0..=u32::MAX {
|
||||
let conflict = conflict_path_with_suffix(path, stamp, suffix);
|
||||
let mut source = std::fs::File::open(path)?;
|
||||
let mut target = match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&conflict)
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if let Err(e) = std::io::copy(&mut source, &mut target).and_then(|_| target.sync_all()) {
|
||||
let _ = std::fs::remove_file(&conflict);
|
||||
return Err(e);
|
||||
}
|
||||
target.set_permissions(metadata.permissions())?;
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
target.set_modified(modified)?;
|
||||
}
|
||||
target.sync_all()?;
|
||||
sync_parent_dir(&conflict)?;
|
||||
std::fs::remove_file(path)?;
|
||||
sync_parent_dir(&conflict)?;
|
||||
return Ok(conflict);
|
||||
}
|
||||
unreachable!("u32 conflict suffix space exhausted")
|
||||
}
|
||||
|
||||
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||
match path.parent() {
|
||||
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
/// A remote node's `(mtime, size)` change signature. Size prefers the plaintext
|
||||
@@ -1609,9 +1679,10 @@ mod tests {
|
||||
guard_local_wipe(&base, &one).is_ok(),
|
||||
"one survivor means the tree is readable; the rest are real deletions"
|
||||
);
|
||||
// A one-entry baseline is a genuine single delete, not a wipe to detect.
|
||||
// A one-entry folder can disappear because its mount or parent became
|
||||
// unavailable just as easily as a larger tree can.
|
||||
let single: HashMap<String, ()> = [("a.txt".to_string(), ())].into_iter().collect();
|
||||
assert!(guard_local_wipe(&single, &none).is_ok());
|
||||
assert!(guard_local_wipe(&single, &none).is_err());
|
||||
// Nothing synced yet: a first pass over an empty folder is not a wipe.
|
||||
assert!(guard_local_wipe(&none, &none).is_ok());
|
||||
// Local paths that are not in the baseline are new uploads, and do not
|
||||
@@ -1781,6 +1852,52 @@ mod tests {
|
||||
assert_eq!(no_ext, PathBuf::from("/home/me/README (sync-conflict 42)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_preservation_never_replaces_an_existing_copy() {
|
||||
let dir = sync_test_dir("conflict-collision");
|
||||
let source = dir.join("notes.txt");
|
||||
let existing = conflict_path(&source, 1700);
|
||||
std::fs::write(&source, b"new local edit").unwrap();
|
||||
std::fs::write(&existing, b"older conflict").unwrap();
|
||||
|
||||
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&existing).unwrap(), b"older conflict");
|
||||
assert_eq!(std::fs::read(&preserved).unwrap(), b"new local edit");
|
||||
assert_eq!(
|
||||
preserved.file_name().unwrap(),
|
||||
"notes (sync-conflict 1700-1).txt"
|
||||
);
|
||||
assert!(!source.exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_preservation_failure_keeps_the_source() {
|
||||
let dir = sync_test_dir("conflict-failure");
|
||||
// A directory cannot be copied through the regular-file preservation
|
||||
// path, deterministically forcing publication to fail after the source
|
||||
// has been discovered.
|
||||
let source = dir.join("not-a-file.txt");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
|
||||
assert!(preserve_conflict_copy(&source, 1700).is_err());
|
||||
assert!(source.is_dir(), "failure must not remove the source");
|
||||
assert!(!conflict_path(&source, 1700).exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn sync_test_dir(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("pdfs-sync-{label}-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
// ---- open-for-write detection tests -----------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(super) fn guard_local_wipe<B, L>(
|
||||
baseline: &HashMap<String, B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> Result<(), String> {
|
||||
if baseline.len() >= 2 && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
||||
them on Drive. Check that the folder is mounted and readable.",
|
||||
@@ -162,12 +162,24 @@ pub(super) fn rel_to_path(rel: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
/// The name for a conflict copy of `path`, e.g. `notes (sync-conflict 1700000000).txt`.
|
||||
#[cfg(test)]
|
||||
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||
conflict_path_with_suffix(path, stamp, 0)
|
||||
}
|
||||
|
||||
/// As [`conflict_path`], with a deterministic suffix used when that name already
|
||||
/// exists. This keeps conflict preservation from ever replacing an older copy.
|
||||
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = path.extension().and_then(|s| s.to_str());
|
||||
let suffix = if suffix == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{suffix}")
|
||||
};
|
||||
let name = match ext {
|
||||
Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {stamp})"),
|
||||
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
|
||||
};
|
||||
match path.parent() {
|
||||
Some(dir) => dir.join(name),
|
||||
|
||||
Reference in new issue
Block a user