Skip to main content

common/mount/conflict/
mod.rs

1//! Conflict resolution for PathOpLog merges
2//!
3//! This module provides pluggable conflict resolution strategies for handling
4//! concurrent edits from different peers. When two peers edit the same path
5//! concurrently, the resolver determines how to reconcile the conflict.
6//!
7//! # Built-in Strategies
8//!
9//! - **[`LastWriteWins`]**: Higher timestamp wins (default CRDT behavior)
10//! - **[`BaseWins`]**: Local operations win over incoming ones
11//! - **[`ForkOnConflict`]**: Keep both versions, returning conflicts for manual resolution
12//! - **[`ConflictFile`]**: Rename incoming to `<name>@<content-hash>` to preserve both versions
13//!
14//! # Custom Resolvers
15//!
16//! Implement the [`ConflictResolver`] trait to create custom resolution strategies.
17
18mod base_wins;
19mod conflict_file;
20mod fork_on_conflict;
21mod last_write_wins;
22mod types;
23
24pub use base_wins::BaseWins;
25pub use conflict_file::ConflictFile;
26pub use fork_on_conflict::ForkOnConflict;
27pub use last_write_wins::LastWriteWins;
28pub use types::{Conflict, MergeResult, Resolution, ResolvedConflict};
29
30use std::path::PathBuf;
31
32use crate::crypto::PublicKey;
33
34use super::path_ops::{OpType, PathOperation};
35
36/// Trait for conflict resolution strategies
37///
38/// Implementors define how to resolve conflicts when merging PathOpLogs
39/// from different peers.
40pub trait ConflictResolver: std::fmt::Debug + Send + Sync {
41    /// Resolve a conflict between two operations on the same path
42    ///
43    /// # Arguments
44    ///
45    /// * `conflict` - The conflict to resolve
46    /// * `local_peer` - The local peer's identity (useful for deterministic tie-breaking)
47    ///
48    /// # Returns
49    ///
50    /// The resolution decision
51    fn resolve(&self, conflict: &Conflict, local_peer: &PublicKey) -> Resolution;
52}
53
54/// Check if two operations conflict
55///
56/// Two operations conflict if:
57/// 1. They affect the same path
58/// 2. They have different OpIds
59/// 3. At least one is a destructive operation (Remove, Mv, or Add that overwrites)
60pub fn operations_conflict(base: &PathOperation, incoming: &PathOperation) -> bool {
61    // Same OpId means same operation, no conflict
62    if base.id == incoming.id {
63        return false;
64    }
65
66    // Must affect the same path
67    if base.path != incoming.path {
68        return false;
69    }
70
71    // Check if either operation is destructive
72    let base_destructive = is_destructive(&base.op_type);
73    let incoming_destructive = is_destructive(&incoming.op_type);
74
75    // Conflict if either is destructive, or both are Add (concurrent creates)
76    base_destructive
77        || incoming_destructive
78        || (matches!(base.op_type, OpType::Add) && matches!(incoming.op_type, OpType::Add))
79}
80
81/// Check if an operation type is destructive
82fn is_destructive(op_type: &OpType) -> bool {
83    matches!(op_type, OpType::Remove | OpType::Mv { .. })
84}
85
86/// Check if an operation at this path would conflict with a move operation
87///
88/// Move operations are special because they affect two paths: source and destination.
89/// This checks if an operation conflicts with a move's source path.
90pub fn conflicts_with_mv_source(op: &PathOperation, mv_from: &PathBuf) -> bool {
91    // Check if op affects the mv source path
92    &op.path == mv_from
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::crypto::SecretKey;
99    use crate::mount::path_ops::OpId;
100
101    fn make_peer_id(seed: u8) -> PublicKey {
102        let mut seed_bytes = [0u8; 32];
103        seed_bytes[0] = seed;
104        let secret = SecretKey::from(seed_bytes);
105        secret.public()
106    }
107
108    fn make_op(peer_id: PublicKey, timestamp: u64, op_type: OpType, path: &str) -> PathOperation {
109        PathOperation {
110            id: OpId { timestamp, peer_id },
111            op_type,
112            path: PathBuf::from(path),
113            content_link: None,
114            is_dir: false,
115        }
116    }
117
118    #[test]
119    fn test_conflict_detection() {
120        let peer1 = make_peer_id(1);
121        let peer2 = make_peer_id(2);
122
123        let op1 = make_op(peer1, 1, OpType::Add, "file.txt");
124        let op2 = make_op(peer2, 1, OpType::Add, "file.txt");
125
126        // Same path, different peers, both Add -> conflict
127        assert!(operations_conflict(&op1, &op2));
128    }
129
130    #[test]
131    fn test_no_conflict_different_paths() {
132        let peer1 = make_peer_id(1);
133        let peer2 = make_peer_id(2);
134
135        let op1 = make_op(peer1, 1, OpType::Add, "file1.txt");
136        let op2 = make_op(peer2, 1, OpType::Add, "file2.txt");
137
138        // Different paths -> no conflict
139        assert!(!operations_conflict(&op1, &op2));
140    }
141
142    #[test]
143    fn test_no_conflict_same_operation() {
144        let peer1 = make_peer_id(1);
145
146        let op1 = make_op(peer1, 1, OpType::Add, "file.txt");
147        let op2 = op1.clone();
148
149        // Same OpId -> no conflict (same operation)
150        assert!(!operations_conflict(&op1, &op2));
151    }
152
153    #[test]
154    fn test_conflict_add_vs_remove() {
155        let peer1 = make_peer_id(1);
156        let peer2 = make_peer_id(2);
157
158        let op1 = make_op(peer1, 1, OpType::Add, "file.txt");
159        let op2 = make_op(peer2, 1, OpType::Remove, "file.txt");
160
161        // Add vs Remove on same path -> conflict
162        assert!(operations_conflict(&op1, &op2));
163    }
164
165    #[test]
166    fn test_conflict_mkdir_vs_remove() {
167        let peer1 = make_peer_id(1);
168        let peer2 = make_peer_id(2);
169
170        let op1 = make_op(peer1, 1, OpType::Mkdir, "dir");
171        let op2 = make_op(peer2, 1, OpType::Remove, "dir");
172
173        // Mkdir vs Remove on same path -> conflict
174        assert!(operations_conflict(&op1, &op2));
175    }
176
177    #[test]
178    fn test_no_conflict_mkdir_vs_mkdir() {
179        let peer1 = make_peer_id(1);
180        let peer2 = make_peer_id(2);
181
182        let op1 = make_op(peer1, 1, OpType::Mkdir, "dir");
183        let op2 = make_op(peer2, 1, OpType::Mkdir, "dir");
184
185        // Mkdir vs Mkdir is idempotent -> no conflict
186        assert!(!operations_conflict(&op1, &op2));
187    }
188
189    #[test]
190    fn test_conflict_is_concurrent() {
191        let peer1 = make_peer_id(1);
192        let peer2 = make_peer_id(2);
193
194        let base = make_op(peer1, 5, OpType::Add, "file.txt");
195        let incoming = make_op(peer2, 5, OpType::Remove, "file.txt");
196
197        let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming);
198
199        // Same timestamp -> concurrent
200        assert!(conflict.is_concurrent());
201    }
202
203    #[test]
204    fn test_conflict_not_concurrent() {
205        let peer1 = make_peer_id(1);
206        let peer2 = make_peer_id(2);
207
208        let base = make_op(peer1, 3, OpType::Add, "file.txt");
209        let incoming = make_op(peer2, 5, OpType::Remove, "file.txt");
210
211        let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming);
212
213        // Different timestamps -> not concurrent
214        assert!(!conflict.is_concurrent());
215    }
216
217    #[test]
218    fn test_crdt_winner() {
219        let peer1 = make_peer_id(1);
220        let peer2 = make_peer_id(2);
221
222        let base = make_op(peer1, 3, OpType::Add, "file.txt");
223        let incoming = make_op(peer2, 5, OpType::Remove, "file.txt");
224
225        let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming.clone());
226
227        // Incoming has higher timestamp
228        assert_eq!(conflict.crdt_winner().id, incoming.id);
229    }
230
231    #[test]
232    fn test_merge_result() {
233        let mut result = MergeResult::new();
234
235        assert_eq!(result.operations_added, 0);
236        assert!(!result.has_unresolved());
237        assert_eq!(result.total_conflicts(), 0);
238
239        // Add an unresolved conflict
240        let peer1 = make_peer_id(1);
241        let peer2 = make_peer_id(2);
242        let base = make_op(peer1, 1, OpType::Add, "file.txt");
243        let incoming = make_op(peer2, 1, OpType::Add, "file.txt");
244
245        result
246            .unresolved_conflicts
247            .push(Conflict::new(PathBuf::from("file.txt"), base, incoming));
248
249        assert!(result.has_unresolved());
250        assert_eq!(result.total_conflicts(), 1);
251    }
252}