mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
A mirrored folder assumed it was the only client writing to its folder on Drive. With a second computer syncing the same folder, edits could be lost: - The first sync took a same-size file at the same path for the same file. It now records the baseline only if the local file's SHA-1 matches Drive's digest, and otherwise keeps a conflict copy (B248). - An upload replaced a revision another client had committed since the pass looked. The upload now checks Drive's active revision first, and an edit that read back with someone else's digest is left for the next pass to keep as a conflict copy (B249). - A download recorded a revision committed while it ran as synced, so that edit never arrived. The baseline now records the revision the pass planned to download (B250). - Conflict copies now carry the computer's hostname, so two computers do not make the same name on Drive (B251). The baseline gains the revision id it was synced at (schema 44), and a new revision counts as a remote change even at the same mtime and size.
537 lines
22 KiB
Rust
537 lines
22 KiB
Rust
//! Background conflict sweep.
|
|
//!
|
|
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
|
|
//! engine cut one when they cannot prove a queued write is safe to apply, so the
|
|
//! user never loses bytes. Most of those copies turn out to be byte-identical to
|
|
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
|
|
//! first-sync of an already-identical file. This sweep reconciles them after the
|
|
//! fact:
|
|
//!
|
|
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
|
|
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
|
|
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
|
|
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
|
|
//! user can decide which version wins.
|
|
//!
|
|
//! Identity is proved without downloading anything: the SHA-1 comes from the
|
|
//! revision's decrypted extended attributes, which enumeration already reads. A
|
|
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
|
|
//! never removes a file it cannot prove is a duplicate.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use pdfs_core::batch;
|
|
use pdfs_core::config::SweepMode;
|
|
use pdfs_core::control::ActivityKind;
|
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use super::{Core, node_content_sha1, node_revision_id, node_size};
|
|
|
|
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
|
|
/// the sweep off the hot path while still cleaning up within a few minutes.
|
|
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
|
|
|
/// Delay before the first sweep, so it does not compete with the burst of work a
|
|
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
|
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
|
|
|
/// Strip a ` (sync-conflict <label> <stamp>)` (optionally `-<n>`) marker from
|
|
/// `name`, returning the original file name it was a copy of. The label names
|
|
/// the computer that made the copy (B251); copies made before it have the bare
|
|
/// stamp. `None` when `name` is not a conflict copy. Inverse of the
|
|
/// `conflict_name` / `conflict_path_with_suffix` formats, so the two halves of
|
|
/// the product agree on what a conflict is.
|
|
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
|
const MARKER: &str = " (sync-conflict ";
|
|
let start = name.find(MARKER)?;
|
|
let after = start + MARKER.len();
|
|
let close_rel = name[after..].find(')')?;
|
|
let close = after + close_rel;
|
|
let inner = &name[after..close];
|
|
// A label is one token of the characters `conflict_label_of` keeps.
|
|
let inner = match inner.rsplit_once(' ') {
|
|
Some((label, token)) => {
|
|
let label_ok = !label.is_empty()
|
|
&& label
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
|
|
if !label_ok {
|
|
return None;
|
|
}
|
|
token
|
|
}
|
|
None => inner,
|
|
};
|
|
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
|
// file that merely happens to contain the phrase is not mistaken for a copy.
|
|
let mut parts = inner.splitn(2, '-');
|
|
let stamp = parts.next().unwrap_or_default();
|
|
let suffix = parts.next();
|
|
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
|
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
|
|
return None;
|
|
}
|
|
let mut base = String::with_capacity(start + name.len() - close);
|
|
base.push_str(&name[..start]);
|
|
base.push_str(&name[close + 1..]);
|
|
Some(base)
|
|
}
|
|
|
|
impl Core {
|
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
|
/// docs for the identical/divergent policy.
|
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
|
// Both waits are interruptible, so teardown does not have to sit out a
|
|
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
|
return;
|
|
}
|
|
loop {
|
|
self.sweep_conflicts_once();
|
|
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One sweep pass over every node the daemon currently knows.
|
|
fn sweep_conflicts_once(&self) {
|
|
let stored = match self.db.load_all() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
warn!(error = ?e, "conflict sweep: load nodes failed");
|
|
return;
|
|
}
|
|
};
|
|
let nodes: Vec<_> = stored
|
|
.into_iter()
|
|
.map(|stored| stored.node)
|
|
.filter(|node| self.is_own_or_virtual(&node.uid))
|
|
.collect();
|
|
|
|
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
|
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
|
for node in &nodes {
|
|
if node.trashed || node.is_folder() {
|
|
continue;
|
|
}
|
|
let parent = node
|
|
.parent_uid
|
|
.as_ref()
|
|
.map(|u| u.to_string())
|
|
.unwrap_or_default();
|
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
|
}
|
|
|
|
let roots = self.search_roots();
|
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
|
for node in &nodes {
|
|
if node.trashed || node.is_folder() {
|
|
continue;
|
|
}
|
|
let Some(base) = conflict_base_name(&node.name) else {
|
|
continue;
|
|
};
|
|
let parent = node
|
|
.parent_uid
|
|
.as_ref()
|
|
.map(|u| u.to_string())
|
|
.unwrap_or_default();
|
|
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
|
|
|
|
match decide(node, sibling, &base, self.sweep_mode) {
|
|
SweepAction::Remove => {
|
|
// Only while online — the remote trash is the point. Offline,
|
|
// it waits for a later pass.
|
|
if online {
|
|
self.remove_conflict_copy(node, &base);
|
|
}
|
|
}
|
|
// Only flag what the conflict listing can show. Anything else
|
|
// would sit in the feed as "resolved" with no way to act on
|
|
// it (`docs/BUGS.md` B99).
|
|
SweepAction::Flag(detail) => {
|
|
if self.conflict_path(&roots, &node.uid).is_some() {
|
|
self.flag_conflict(&node.uid, &node.name, detail);
|
|
} else {
|
|
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trash a conflict copy proven identical to its live sibling, and unhook it
|
|
/// locally — the same sequence the interactive unlink path uses after a trash.
|
|
///
|
|
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
|
|
/// enumerated every node up front and has been doing network I/O since. Before
|
|
/// touching anything remote this re-reads the node and refuses to act unless
|
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
|
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
|
/// or its staged blob: a write that arrives after that check is left for the
|
|
/// drain to resolve against the now-trashed node.
|
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
|
let uid = &snapshot.uid;
|
|
let name = snapshot.name.as_str();
|
|
if !self.duplicate_still_removable(snapshot) {
|
|
return;
|
|
}
|
|
match self
|
|
.rt
|
|
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
|
|
.and_then(batch::into_unit)
|
|
{
|
|
Ok(()) => {}
|
|
Err(e) if super::is_gone(&e) => {}
|
|
Err(e) => {
|
|
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
|
|
return;
|
|
}
|
|
}
|
|
// Leaving a conflict copy hooked up in the tree keeps a node we have
|
|
// just trashed remotely visible and readable in whichever mount shows
|
|
// it (`docs/BUGS.md` B74).
|
|
self.for_each_state(|st| {
|
|
st.forget_or_unlink(uid);
|
|
});
|
|
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
|
// proved the queue empty before the trash above, but that proof is stale
|
|
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
|
// that window stages bytes, and discarding here would unlink the staged
|
|
// blob holding the only copy of them. Leaving the op queued is the
|
|
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
|
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
|
self.cache.evict(uid);
|
|
self.evict_reader(uid);
|
|
if let Err(e) = self.db.delete_node(uid) {
|
|
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
|
|
}
|
|
self.invalidate_trash();
|
|
self.conflict_notified.lock().remove(uid);
|
|
self.log_activity(
|
|
ActivityKind::Trash,
|
|
name,
|
|
format!("auto-removed duplicate of {base}"),
|
|
true,
|
|
);
|
|
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
|
|
}
|
|
|
|
/// Re-check, immediately before the destructive call, everything the pass
|
|
/// decided from its stale snapshot. Any doubt means "leave it alone": a
|
|
/// conflict copy that survives to the next pass costs nothing, a wrongly
|
|
/// removed one costs the user data.
|
|
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
|
|
let uid = &snapshot.uid;
|
|
let name = snapshot.name.as_str();
|
|
|
|
// A queued op means bytes are still owed an upload; trashing the node
|
|
// under them turns an ordinary upload into a conflict fork.
|
|
match self.db.has_any_op(&uid.to_string()) {
|
|
Ok(false) => {}
|
|
Ok(true) => {
|
|
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
|
|
return false;
|
|
}
|
|
// Cannot prove the queue is empty — assume it is not.
|
|
Err(e) => {
|
|
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Someone is writing to it, or holds it open. Trashing now would pull the
|
|
// node out from under a live handle.
|
|
if self.is_busy(uid) {
|
|
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
|
|
return false;
|
|
}
|
|
|
|
// Finally, the node itself must not have moved on since the snapshot.
|
|
match self.db.node_by_uid(&uid.to_string()) {
|
|
Ok(Some(fresh)) => {
|
|
if fresh.trashed {
|
|
return false;
|
|
}
|
|
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
|
|
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
|
|
return false;
|
|
}
|
|
if node_revision_id(&fresh) != node_revision_id(snapshot)
|
|
|| node_size(&fresh) != node_size(snapshot)
|
|
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
|
|
{
|
|
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
// Already gone locally, or unreadable — either way, not ours to remove.
|
|
Ok(None) => false,
|
|
Err(e) => {
|
|
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a node has a write in flight or a file handle open against it.
|
|
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
|
/// writer/holder check.
|
|
/// Asked across **every** mount. The sweep runs on the primary Core but
|
|
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
|
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
|
/// answered "idle" for every such file and let the sweep delete one with a
|
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
|
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
|
let mut busy = false;
|
|
self.for_each_state(|st| {
|
|
let Some(&ino) = st.by_uid.get(uid) else {
|
|
return;
|
|
};
|
|
busy |=
|
|
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
|
});
|
|
busy
|
|
}
|
|
|
|
/// Surface a conflict copy that needs the user's attention, once. The
|
|
/// in-memory set spares the database each pass; the activity log itself is
|
|
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
|
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
|
return;
|
|
}
|
|
match self.db.activity_has(ActivityKind::Conflict, name) {
|
|
Ok(false) => {}
|
|
Ok(true) => return,
|
|
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
|
// conflict.
|
|
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
|
}
|
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
|
}
|
|
}
|
|
|
|
/// What the sweep concluded about one `(sync-conflict …)` copy.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum SweepAction {
|
|
/// Provably redundant: trash it, subject to the runtime interlocks in
|
|
/// [`Core::duplicate_still_removable`].
|
|
Remove,
|
|
/// Only the user can resolve this one; surface it with this detail.
|
|
Flag(String),
|
|
}
|
|
|
|
/// Decide what to do about conflict copy `node` given its live sibling (if any)
|
|
/// and the configured `mode`.
|
|
///
|
|
/// Pure, so the policy that governs deletion is testable without a mount, a
|
|
/// runtime, or an account — the same split used for `revision_changed` and
|
|
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
|
|
fn decide(
|
|
node: &proton_drive_rs::Node,
|
|
sibling: Option<&proton_drive_rs::Node>,
|
|
base: &str,
|
|
mode: SweepMode,
|
|
) -> SweepAction {
|
|
match sibling {
|
|
Some(sib) if is_identical(node, sib) => {
|
|
if mode.is_enforcing() {
|
|
SweepAction::Remove
|
|
} else {
|
|
// Report-only: say what would happen, remove nothing. The point is
|
|
// to accumulate field evidence that the identity check is right
|
|
// before it is trusted with deletion (docs/BUGS.md B71).
|
|
SweepAction::Flag(format!(
|
|
"identical to {base}; would be removed (sweep is report-only)"
|
|
))
|
|
}
|
|
}
|
|
Some(_) => SweepAction::Flag(format!("differs from {base}")),
|
|
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
|
|
}
|
|
}
|
|
|
|
/// Whether two file nodes provably hold the same bytes: equal size and equal,
|
|
/// present content SHA-1. A missing digest on either side is not proof, so the
|
|
/// sweep treats it as divergent rather than risk removing a real difference.
|
|
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
|
|
if node_size(a) != node_size(b) {
|
|
return false;
|
|
}
|
|
match (node_content_sha1(a), node_content_sha1(b)) {
|
|
(Some(x), Some(y)) => x == y,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use proton_drive_rs::NodeKind;
|
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
|
|
|
/// A file node with the two fields the sweep's identity check reads.
|
|
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
|
|
proton_drive_rs::Node {
|
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
|
|
parent_uid: None,
|
|
kind: NodeKind::File {
|
|
media_type: "text/plain".into(),
|
|
total_size_on_storage: size,
|
|
active_revision_state: None,
|
|
active_revision_id: Some("rev".into()),
|
|
claimed_size: Some(size),
|
|
claimed_modification_time: None,
|
|
content_sha1: sha1.map(String::from),
|
|
},
|
|
name: name.into(),
|
|
creation_time: 0,
|
|
modification_time: 0,
|
|
trashed: false,
|
|
is_shared: false,
|
|
is_shared_publicly: false,
|
|
signature_email: None,
|
|
membership: None,
|
|
photo: None,
|
|
album: None,
|
|
verification: Default::default(),
|
|
direct_role: None,
|
|
share_id: None,
|
|
}
|
|
}
|
|
|
|
/// The only input shape that may ever produce a removal: same size, same
|
|
/// present digest, live sibling, enforcing mode.
|
|
#[test]
|
|
fn removes_only_a_provably_identical_copy_when_enforcing() {
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("aaa"));
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Remove
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_only_never_removes_but_still_says_what_it_would_do() {
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("aaa"));
|
|
// Same inputs as the removal case above; only the mode differs.
|
|
let SweepAction::Flag(detail) = decide(©, Some(&live), "f.txt", SweepMode::Report)
|
|
else {
|
|
panic!("report mode must never remove");
|
|
};
|
|
assert!(detail.contains("would be removed"), "{detail}");
|
|
assert!(detail.contains("report-only"), "{detail}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_differing_size_is_a_real_conflict() {
|
|
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("aaa"));
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("differs from f.txt".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_digest_is_never_proof_of_identity() {
|
|
// Equal sizes are not enough: two different 100-byte files are common.
|
|
// A copy the sweep cannot fingerprint must survive, in either direction.
|
|
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
|
|
let copy = file("f (sync-conflict 1).txt", 100, a);
|
|
let live = file("f.txt", 100, b);
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("differs from f.txt".into()),
|
|
"{a:?} vs {b:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("bbb"));
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("differs from f.txt".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_orphaned_copy_is_surfaced_not_removed() {
|
|
// No live original left to reconcile against — the copy may be the only
|
|
// surviving version of the file, so it is never the sweep's to remove.
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
assert_eq!(
|
|
decide(©, None, "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("no original f.txt to reconcile against".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_needs_both_size_and_digest_to_match() {
|
|
let a = file("a", 100, Some("aaa"));
|
|
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
|
|
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
|
|
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
|
|
assert!(!is_identical(&a, &file("b", 100, None)));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_conflict_names_and_leaves_others_alone() {
|
|
assert_eq!(
|
|
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
|
|
Some("test_export.xml")
|
|
);
|
|
assert_eq!(
|
|
conflict_base_name("README (sync-conflict 42)").as_deref(),
|
|
Some("README")
|
|
);
|
|
// The sync engine's de-duplicated suffix form.
|
|
assert_eq!(
|
|
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
|
Some("notes.txt")
|
|
);
|
|
// Named after the computer that made it (B251).
|
|
assert_eq!(
|
|
conflict_base_name("notes (sync-conflict laptop 1700).txt").as_deref(),
|
|
Some("notes.txt")
|
|
);
|
|
assert_eq!(
|
|
conflict_base_name("notes (sync-conflict my-pc.local 1700-2).txt").as_deref(),
|
|
Some("notes.txt")
|
|
);
|
|
assert_eq!(
|
|
conflict_base_name(&crate::conflict_name("notes.txt", 1700)).as_deref(),
|
|
Some("notes.txt")
|
|
);
|
|
assert_eq!(
|
|
conflict_base_name("x (sync-conflict two words 1).txt"),
|
|
None
|
|
);
|
|
assert_eq!(conflict_base_name("x (sync-conflict 1).txt"), None);
|
|
// Not a conflict copy.
|
|
assert_eq!(conflict_base_name("report.xml"), None);
|
|
// Looks close but the token is not a stamp.
|
|
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dotted_stem_keeps_its_inner_dots() {
|
|
assert_eq!(
|
|
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
|
|
Some("archive.tar.gz")
|
|
);
|
|
}
|
|
}
|