Skip to main content

blossom_rs/locks/
mod.rs

1//! LFS file locking database (BUD-19).
2//!
3//! Provides lock storage for Git LFS file locking support. Locks are scoped
4//! by repo ID and owned by Nostr pubkeys. Includes an in-memory
5//! implementation for testing and as a default.
6
7#[cfg(feature = "db-sqlite")]
8mod sqlite;
9
10#[cfg(feature = "db-postgres")]
11mod postgres;
12
13#[cfg(feature = "db-sqlite")]
14pub use sqlite::SqliteLockDatabase;
15
16#[cfg(feature = "db-postgres")]
17pub use postgres::PostgresLockDatabase;
18
19use std::collections::HashMap;
20
21use serde::{Deserialize, Serialize};
22
23/// A single lock record.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LockRecord {
26    /// UUID v4, server-assigned.
27    pub id: String,
28    /// Repository namespace (e.g., "github.com/user/repo").
29    pub repo_id: String,
30    /// File path relative to repo root.
31    pub path: String,
32    /// Hex-encoded x-only Nostr pubkey of the lock owner.
33    pub pubkey: String,
34    /// Unix timestamp of lock creation.
35    pub locked_at: u64,
36}
37
38/// Filters for listing locks.
39#[derive(Debug, Clone, Default)]
40pub struct LockFilters {
41    pub path: Option<String>,
42    pub id: Option<String>,
43    pub cursor: Option<String>,
44    pub limit: Option<u32>,
45}
46
47/// Errors from lock database operations.
48#[derive(Debug, thiserror::Error)]
49pub enum LockError {
50    #[error("path already locked: {0}")]
51    Conflict(String),
52    #[error("not found")]
53    NotFound,
54    #[error("forbidden: {0}")]
55    Forbidden(String),
56    #[error("lock database error: {0}")]
57    Internal(String),
58}
59
60/// Trait for lock persistence.
61///
62/// Implementations store LFS locks scoped by repo ID. All methods are
63/// synchronous; the server wraps in `Arc<Mutex<>>`.
64pub trait LockDatabase: Send + Sync {
65    /// Create a new lock for `path` in `repo`, owned by `pubkey`.
66    /// Returns the created lock or `LockError::Conflict` if already locked.
67    fn create_lock(
68        &mut self,
69        repo: &str,
70        path: &str,
71        pubkey: &str,
72    ) -> Result<LockRecord, LockError>;
73
74    /// Delete a lock by ID. If `force` is false, only the owner can unlock.
75    /// Admins can always force-unlock.
76    fn delete_lock(
77        &mut self,
78        repo: &str,
79        id: &str,
80        force: bool,
81        requester: &str,
82    ) -> Result<LockRecord, LockError>;
83
84    /// List locks for a repo with optional filters.
85    /// Returns (locks, next_cursor).
86    fn list_locks(
87        &self,
88        repo: &str,
89        filters: &LockFilters,
90    ) -> Result<(Vec<LockRecord>, Option<String>), LockError>;
91
92    /// Get a lock by ID.
93    fn get_lock(&self, repo: &str, id: &str) -> Result<LockRecord, LockError>;
94
95    /// Get a lock by path (for conflict checking).
96    fn get_lock_by_path(&self, repo: &str, path: &str) -> Result<LockRecord, LockError>;
97}
98
99/// In-memory lock database for testing.
100#[derive(Clone)]
101pub struct MemoryLockDatabase {
102    locks: HashMap<String, LockRecord>,
103}
104
105impl MemoryLockDatabase {
106    pub fn new() -> Self {
107        Self {
108            locks: HashMap::new(),
109        }
110    }
111}
112
113impl Default for MemoryLockDatabase {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119fn lock_key(repo: &str, id: &str) -> String {
120    format!("{}:{}", repo, id)
121}
122
123fn path_key(repo: &str, path: &str) -> String {
124    format!("{}:{}", repo, path)
125}
126
127impl LockDatabase for MemoryLockDatabase {
128    fn create_lock(
129        &mut self,
130        repo: &str,
131        path: &str,
132        pubkey: &str,
133    ) -> Result<LockRecord, LockError> {
134        let pk = path_key(repo, path);
135        if let Some(existing) = self
136            .locks
137            .values()
138            .find(|l| path_key(&l.repo_id, &l.path) == pk)
139        {
140            return Err(LockError::Conflict(existing.id.clone()));
141        }
142
143        let id = uuid::Uuid::new_v4().to_string();
144        let locked_at = std::time::SystemTime::now()
145            .duration_since(std::time::UNIX_EPOCH)
146            .unwrap_or_default()
147            .as_secs();
148
149        let record = LockRecord {
150            id: id.clone(),
151            repo_id: repo.to_string(),
152            path: path.to_string(),
153            pubkey: pubkey.to_string(),
154            locked_at,
155        };
156
157        let key = lock_key(repo, &id);
158        self.locks.insert(key, record.clone());
159        Ok(record)
160    }
161
162    fn delete_lock(
163        &mut self,
164        repo: &str,
165        id: &str,
166        force: bool,
167        requester: &str,
168    ) -> Result<LockRecord, LockError> {
169        let key = lock_key(repo, id);
170        let lock = self.locks.get(&key).cloned().ok_or(LockError::NotFound)?;
171
172        if !force && lock.pubkey != requester {
173            return Err(LockError::Forbidden(
174                "only the lock owner or an admin can unlock".to_string(),
175            ));
176        }
177
178        self.locks.remove(&key);
179        Ok(lock)
180    }
181
182    fn list_locks(
183        &self,
184        repo: &str,
185        filters: &LockFilters,
186    ) -> Result<(Vec<LockRecord>, Option<String>), LockError> {
187        let mut locks: Vec<LockRecord> = self
188            .locks
189            .values()
190            .filter(|l| l.repo_id == repo)
191            .filter(|l| filters.path.as_ref().map_or(true, |p| l.path == *p))
192            .filter(|l| filters.id.as_ref().map_or(true, |id| l.id == *id))
193            .cloned()
194            .collect();
195
196        locks.sort_by_key(|l| l.locked_at);
197
198        let limit = filters.limit.unwrap_or(100) as usize;
199        let cursor_val = filters
200            .cursor
201            .as_ref()
202            .and_then(|c| c.parse::<usize>().ok());
203
204        let start = cursor_val.unwrap_or(0);
205        let end = std::cmp::min(start + limit, locks.len());
206
207        if start >= locks.len() {
208            return Ok((vec![], None));
209        }
210
211        let next_cursor = if end < locks.len() {
212            Some(end.to_string())
213        } else {
214            None
215        };
216
217        Ok((locks[start..end].to_vec(), next_cursor))
218    }
219
220    fn get_lock(&self, repo: &str, id: &str) -> Result<LockRecord, LockError> {
221        let key = lock_key(repo, id);
222        self.locks.get(&key).cloned().ok_or(LockError::NotFound)
223    }
224
225    fn get_lock_by_path(&self, repo: &str, path: &str) -> Result<LockRecord, LockError> {
226        self.locks
227            .values()
228            .find(|l| l.repo_id == repo && l.path == path)
229            .cloned()
230            .ok_or(LockError::NotFound)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_create_lock() {
240        let mut db = MemoryLockDatabase::new();
241        let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
242        assert_eq!(lock.repo_id, "repo1");
243        assert_eq!(lock.path, "file.txt");
244        assert_eq!(lock.pubkey, "pk1");
245        assert!(!lock.id.is_empty());
246    }
247
248    #[test]
249    fn test_create_lock_conflict() {
250        let mut db = MemoryLockDatabase::new();
251        db.create_lock("repo1", "file.txt", "pk1").unwrap();
252        let result = db.create_lock("repo1", "file.txt", "pk2");
253        assert!(matches!(result, Err(LockError::Conflict(_))));
254    }
255
256    #[test]
257    fn test_create_lock_different_repos_same_path() {
258        let mut db = MemoryLockDatabase::new();
259        db.create_lock("repo1", "file.txt", "pk1").unwrap();
260        let result = db.create_lock("repo2", "file.txt", "pk2");
261        assert!(result.is_ok());
262    }
263
264    #[test]
265    fn test_delete_lock_owner() {
266        let mut db = MemoryLockDatabase::new();
267        let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
268        let deleted = db.delete_lock("repo1", &lock.id, false, "pk1").unwrap();
269        assert_eq!(deleted.id, lock.id);
270    }
271
272    #[test]
273    fn test_delete_lock_non_owner_no_force() {
274        let mut db = MemoryLockDatabase::new();
275        let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
276        let result = db.delete_lock("repo1", &lock.id, false, "pk2");
277        assert!(matches!(result, Err(LockError::Forbidden(_))));
278    }
279
280    #[test]
281    fn test_delete_lock_non_owner_force() {
282        let mut db = MemoryLockDatabase::new();
283        let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
284        let deleted = db.delete_lock("repo1", &lock.id, true, "pk2").unwrap();
285        assert_eq!(deleted.id, lock.id);
286    }
287
288    #[test]
289    fn test_delete_lock_not_found() {
290        let mut db = MemoryLockDatabase::new();
291        let result = db.delete_lock("repo1", "nonexistent", false, "pk1");
292        assert!(matches!(result, Err(LockError::NotFound)));
293    }
294
295    #[test]
296    fn test_list_locks() {
297        let mut db = MemoryLockDatabase::new();
298        db.create_lock("repo1", "a.txt", "pk1").unwrap();
299        db.create_lock("repo1", "b.txt", "pk1").unwrap();
300        db.create_lock("repo2", "c.txt", "pk1").unwrap();
301
302        let (locks, cursor) = db.list_locks("repo1", &LockFilters::default()).unwrap();
303        assert_eq!(locks.len(), 2);
304        assert!(cursor.is_none());
305    }
306
307    #[test]
308    fn test_list_locks_with_path_filter() {
309        let mut db = MemoryLockDatabase::new();
310        db.create_lock("repo1", "a.txt", "pk1").unwrap();
311        db.create_lock("repo1", "b.txt", "pk1").unwrap();
312
313        let filters = LockFilters {
314            path: Some("a.txt".to_string()),
315            ..Default::default()
316        };
317        let (locks, _) = db.list_locks("repo1", &filters).unwrap();
318        assert_eq!(locks.len(), 1);
319        assert_eq!(locks[0].path, "a.txt");
320    }
321
322    #[test]
323    fn test_list_locks_pagination() {
324        let mut db = MemoryLockDatabase::new();
325        for i in 0..5 {
326            db.create_lock("repo1", &format!("file{}.txt", i), "pk1")
327                .unwrap();
328        }
329
330        let filters = LockFilters {
331            limit: Some(2),
332            ..Default::default()
333        };
334        let (locks, cursor) = db.list_locks("repo1", &filters).unwrap();
335        assert_eq!(locks.len(), 2);
336        assert!(cursor.is_some());
337
338        let filters2 = LockFilters {
339            limit: Some(2),
340            cursor,
341            ..Default::default()
342        };
343        let (locks2, cursor2) = db.list_locks("repo1", &filters2).unwrap();
344        assert_eq!(locks2.len(), 2);
345        assert!(cursor2.is_some());
346
347        let filters3 = LockFilters {
348            limit: Some(2),
349            cursor: cursor2,
350            ..Default::default()
351        };
352        let (locks3, cursor3) = db.list_locks("repo1", &filters3).unwrap();
353        assert_eq!(locks3.len(), 1);
354        assert!(cursor3.is_none());
355    }
356
357    #[test]
358    fn test_get_lock_by_path() {
359        let mut db = MemoryLockDatabase::new();
360        let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
361        let found = db.get_lock_by_path("repo1", "file.txt").unwrap();
362        assert_eq!(found.id, lock.id);
363    }
364
365    #[test]
366    fn test_get_lock_by_path_not_found() {
367        let db = MemoryLockDatabase::new();
368        let result = db.get_lock_by_path("repo1", "nonexistent.txt");
369        assert!(matches!(result, Err(LockError::NotFound)));
370    }
371}