feat(fuse): implement rm with virtual .trash/ directory

- Add trashed/original_path/trashed_at columns to files table
- Implement FUSE unlink: moves files to /.trash/ preserving path structure
- Implement FUSE rmdir: removes empty directories
- Add trash CLI commands: list, restore, empty
- Add SIGHUP handler for CLI-triggered restore
- Fix upsert_file returning 0 on UPDATE (query actual ID)
- Auto-clear trashed flag when moving files out of /.trash/
This commit is contained in:
Alexander
2026-05-17 15:42:30 +02:00
parent 9d74f1a7a3
commit 66cd4e945c
7 changed files with 1202 additions and 17 deletions
+266 -2
View File
@@ -145,9 +145,19 @@ impl Database {
.map_err(|e| Error::Database(format!("upsert failed: {}", e)))?;
let id = conn.last_insert_rowid();
debug!(id, vpath = virtual_path.as_str(), "Upserted file");
let file_id = if id == 0 {
conn.query_row(
"SELECT id FROM files WHERE origin_id = ?1 AND real_path = ?2",
params![&origin_id.0, real_path.to_string_lossy()],
|row| row.get::<_, i64>(0),
)
.map_err(|e| Error::Database(format!("failed to get file id after upsert: {}", e)))?
} else {
id
};
debug!(id = file_id, vpath = virtual_path.as_str(), "Upserted file");
Ok(FileId(id))
Ok(FileId(file_id))
}
pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
@@ -424,6 +434,140 @@ impl Database {
.optional()
.map_err(|e| Error::Database(format!("query failed: {}", e)))
}
pub fn mark_trashed(&self, id: FileId, original_path: &VirtualPath) -> Result<()> {
let conn = self.conn.lock().unwrap();
let rows = conn
.execute(
"UPDATE files SET trashed = 1, original_path = ?1, trashed_at = strftime('%s', 'now') WHERE id = ?2",
params![original_path.as_str(), id.0],
)
.map_err(|e| Error::Database(format!("mark_trashed failed: {}", e)))?;
if rows == 0 {
return Err(Error::FileNotFound(format!("file id {} not found", id.0)));
}
debug!(
id = id.0,
original_path = original_path.as_str(),
"marked file as trashed"
);
Ok(())
}
pub fn unmark_trashed(&self, id: FileId) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE files SET trashed = 0, original_path = NULL, trashed_at = NULL WHERE id = ?1",
params![id.0],
)
.map_err(|e| Error::Database(format!("unmark_trashed failed: {}", e)))?;
debug!(id = id.0, "unmarked file as trashed");
Ok(())
}
pub fn list_trashed(&self, filter: &TrashedFilter) -> Result<Vec<TrashedFile>> {
let conn = self.conn.lock().unwrap();
let mut sql = String::from(
"SELECT id, virtual_path, original_path, trashed_at, origin_id FROM files WHERE trashed = 1",
);
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref origin) = filter.origin_id {
sql.push_str(" AND origin_id = ?");
params_vec.push(Box::new(origin.0.clone()));
}
if let Some(ref prefix) = filter.path_prefix {
sql.push_str(" AND original_path LIKE ?");
params_vec.push(Box::new(format!("{}%", prefix)));
}
if let Some(since) = filter.since {
let cutoff = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
- since.as_secs() as i64;
sql.push_str(" AND trashed_at >= ?");
params_vec.push(Box::new(cutoff));
}
sql.push_str(" ORDER BY trashed_at DESC");
let mut stmt = conn
.prepare(&sql)
.map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let files: Vec<TrashedFile> = stmt
.query_map(params_refs.as_slice(), |row| {
Ok(TrashedFile {
file_id: FileId(row.get(0)?),
current_path: VirtualPath::new(row.get::<_, String>(1)?),
original_path: VirtualPath::new(row.get::<_, String>(2)?),
trashed_at: row.get(3)?,
origin_id: OriginId(row.get(4)?),
})
})
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
.filter_map(|r| r.ok())
.collect();
Ok(files)
}
pub fn get_trashed_by_prefix(&self, prefix: &str) -> Result<Vec<TrashedFile>> {
self.list_trashed(&TrashedFilter {
path_prefix: Some(prefix.to_string()),
..Default::default()
})
}
pub fn is_trashed(&self, path: &VirtualPath) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM files WHERE virtual_path = ?1 AND trashed = 1",
params![path.as_str()],
|row| row.get(0),
)
.map_err(|e| Error::Database(format!("is_trashed query failed: {}", e)))?;
Ok(count > 0)
}
pub fn purge_trashed(&self, filter: &TrashedFilter) -> Result<u64> {
let trashed = self.list_trashed(filter)?;
let count = trashed.len() as u64;
let conn = self.conn.lock().unwrap();
for file in trashed {
conn.execute("DELETE FROM files WHERE id = ?1", params![file.file_id.0])
.map_err(|e| Error::Database(format!("purge delete failed: {}", e)))?;
}
debug!(count, "purged trashed files");
Ok(count)
}
}
#[derive(Debug, Clone)]
pub struct TrashedFile {
pub file_id: FileId,
pub current_path: VirtualPath,
pub original_path: VirtualPath,
pub trashed_at: i64,
pub origin_id: OriginId,
}
#[derive(Debug, Clone, Default)]
pub struct TrashedFilter {
pub origin_id: Option<OriginId>,
pub path_prefix: Option<String>,
pub since: Option<Duration>,
}
fn parse_audio_format(s: &str) -> AudioFormat {
@@ -782,4 +926,124 @@ mod tests {
let files = db.get_files_by_prefix("/Other/").unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn test_mark_trashed() {
let db = Database::open_memory().unwrap();
let id = db
.upsert_file(
&OriginId::from("local"),
Path::new("/test.flac"),
&VirtualPath::new("/Artist/Track.flac"),
&AudioMeta::default(),
UNIX_EPOCH,
100,
)
.unwrap();
db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac"))
.unwrap();
let trashed = db.list_trashed(&TrashedFilter::default()).unwrap();
assert_eq!(trashed.len(), 1);
assert_eq!(trashed[0].original_path.as_str(), "/Artist/Track.flac");
}
#[test]
fn test_unmark_trashed() {
let db = Database::open_memory().unwrap();
let id = db
.upsert_file(
&OriginId::from("local"),
Path::new("/test.flac"),
&VirtualPath::new("/Artist/Track.flac"),
&AudioMeta::default(),
UNIX_EPOCH,
100,
)
.unwrap();
db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac"))
.unwrap();
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1);
db.unmark_trashed(id).unwrap();
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
}
#[test]
fn test_list_trashed_with_filter() {
let db = Database::open_memory().unwrap();
let origin1 = OriginId::from("local1");
let origin2 = OriginId::from("local2");
let id1 = db
.upsert_file(
&origin1,
Path::new("/a.flac"),
&VirtualPath::new("/Artist1/Track.flac"),
&AudioMeta::default(),
UNIX_EPOCH,
100,
)
.unwrap();
let id2 = db
.upsert_file(
&origin2,
Path::new("/b.flac"),
&VirtualPath::new("/Artist2/Track.flac"),
&AudioMeta::default(),
UNIX_EPOCH,
100,
)
.unwrap();
db.mark_trashed(id1, &VirtualPath::new("/Artist1/Track.flac"))
.unwrap();
db.mark_trashed(id2, &VirtualPath::new("/Artist2/Track.flac"))
.unwrap();
let all = db.list_trashed(&TrashedFilter::default()).unwrap();
assert_eq!(all.len(), 2);
let filtered = db
.list_trashed(&TrashedFilter {
origin_id: Some(origin1.clone()),
..Default::default()
})
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].origin_id, origin1);
let by_path = db.get_trashed_by_prefix("/Artist1").unwrap();
assert_eq!(by_path.len(), 1);
}
#[test]
fn test_purge_trashed() {
let db = Database::open_memory().unwrap();
let id = db
.upsert_file(
&OriginId::from("local"),
Path::new("/test.flac"),
&VirtualPath::new("/Track.flac"),
&AudioMeta::default(),
UNIX_EPOCH,
100,
)
.unwrap();
db.mark_trashed(id, &VirtualPath::new("/Track.flac"))
.unwrap();
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1);
let count = db.purge_trashed(&TrashedFilter::default()).unwrap();
assert_eq!(count, 1);
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
assert_eq!(db.file_count().unwrap(), 0);
}
}
+3 -3
View File
@@ -7,12 +7,12 @@ mod prefetch;
mod tree;
pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork};
pub use db::Database;
pub use db::{Database, TrashedFile, TrashedFilter};
pub use eviction::{EvictionError, EvictionPolicy, LruEviction};
pub use metadata::MetadataCache;
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
pub use tree::{
DirNode, FileNode, Inode, RefreshPolicy, RenameError, TreeBuilder, VirtualNode, VirtualTree,
ROOT_INODE,
DirNode, FileNode, Inode, RefreshPolicy, RemoveError, RenameError, TreeBuilder, VirtualNode,
VirtualTree, ROOT_INODE,
};
+5
View File
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS files (
chunk_manifest BLOB,
last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
trashed INTEGER NOT NULL DEFAULT 0,
original_path TEXT,
trashed_at INTEGER,
UNIQUE(origin_id, real_path)
);
@@ -64,3 +68,4 @@ CREATE TABLE IF NOT EXISTS directories (
);
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(trashed) WHERE trashed = 1;
+332
View File
@@ -571,6 +571,207 @@ impl VirtualTree {
);
Ok(count)
}
pub fn is_trash_path(path: &VirtualPath) -> bool {
path.as_str().starts_with("/.trash") || path.as_str() == "/.trash"
}
pub fn ensure_trash_dir(&mut self) -> Inode {
let trash_path = VirtualPath::new("/.trash");
if let Some(&inode) = self.path_to_inode.get(&trash_path) {
return inode;
}
let inode = self.alloc_inode();
let dir_node = DirNode {
inode,
parent: ROOT_INODE,
name: OsString::from(".trash"),
children: BTreeMap::new(),
mtime: SystemTime::now(),
};
self.nodes.insert(inode, VirtualNode::Directory(dir_node));
self.path_to_inode.insert(trash_path, inode);
if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) {
root.children.insert(OsString::from(".trash"), inode);
}
debug!(inode, "created .trash directory");
inode
}
pub fn mkdir_p(&mut self, path: &VirtualPath) -> std::result::Result<Inode, RenameError> {
if let Some(&existing) = self.path_to_inode.get(path) {
if self
.nodes
.get(&existing)
.map(|n| n.is_dir())
.unwrap_or(false)
{
return Ok(existing);
}
return Err(RenameError::TargetExists);
}
let components: Vec<&str> = path
.as_str()
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
let mut current_inode = ROOT_INODE;
let mut current_path = String::from("/");
for component in &components {
if !current_path.ends_with('/') {
current_path.push('/');
}
current_path.push_str(component);
let vpath = VirtualPath::new(&current_path);
if let Some(&existing) = self.path_to_inode.get(&vpath) {
current_inode = existing;
} else {
let new_inode = self.alloc_inode();
let name = OsString::from(*component);
let dir_node = DirNode {
inode: new_inode,
parent: current_inode,
name: name.clone(),
children: BTreeMap::new(),
mtime: SystemTime::now(),
};
self.nodes
.insert(new_inode, VirtualNode::Directory(dir_node));
self.path_to_inode.insert(vpath, new_inode);
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&current_inode) {
parent.children.insert(name, new_inode);
}
current_inode = new_inode;
}
}
Ok(current_inode)
}
pub fn remove_directory(&mut self, path: &VirtualPath) -> std::result::Result<(), RemoveError> {
let inode = self
.path_to_inode
.get(path)
.copied()
.ok_or(RemoveError::NotFound)?;
let node = self.nodes.get(&inode).ok_or(RemoveError::NotFound)?;
match node {
VirtualNode::File(_) => return Err(RemoveError::NotDirectory),
VirtualNode::Directory(dir) => {
if !dir.children.is_empty() {
return Err(RemoveError::NotEmpty);
}
}
}
let parent_path = std::path::Path::new(path.as_str())
.parent()
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
.unwrap_or_else(|| VirtualPath::new("/"));
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) {
let name = std::path::Path::new(path.as_str())
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
parent.children.remove(&name);
}
}
self.path_to_inode.remove(path);
self.nodes.remove(&inode);
debug!(path = path.as_str(), inode, "removed directory");
Ok(())
}
pub fn remove_directory_recursive(
&mut self,
path: &VirtualPath,
) -> std::result::Result<Vec<FileId>, RemoveError> {
let inode = self
.path_to_inode
.get(path)
.copied()
.ok_or(RemoveError::NotFound)?;
if !self.nodes.get(&inode).map(|n| n.is_dir()).unwrap_or(false) {
return Err(RemoveError::NotDirectory);
}
let prefix = path.as_str();
let paths_to_remove: Vec<(VirtualPath, Inode)> = self
.path_to_inode
.iter()
.filter(|(p, _)| p.as_str().starts_with(prefix))
.map(|(p, &i)| (p.clone(), i))
.collect();
let mut removed_files = Vec::new();
for (p, ino) in &paths_to_remove {
if let Some(VirtualNode::File(f)) = self.nodes.get(ino) {
removed_files.push(f.file_id);
}
self.path_to_inode.remove(p);
self.nodes.remove(ino);
}
let parent_path = std::path::Path::new(path.as_str())
.parent()
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
.unwrap_or_else(|| VirtualPath::new("/"));
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) {
let name = std::path::Path::new(path.as_str())
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
parent.children.remove(&name);
}
}
debug!(
path = path.as_str(),
file_count = removed_files.len(),
"removed directory recursively"
);
Ok(removed_files)
}
pub fn is_directory_empty(&self, path: &VirtualPath) -> Option<bool> {
let inode = self.path_to_inode.get(path)?;
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(inode) {
Some(dir.children.is_empty())
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoveError {
NotFound,
NotEmpty,
NotDirectory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -889,4 +1090,135 @@ mod tests {
assert_eq!(result, Err(RenameError::TargetExists));
}
#[test]
fn test_is_trash_path() {
assert!(VirtualTree::is_trash_path(&VirtualPath::new("/.trash")));
assert!(VirtualTree::is_trash_path(&VirtualPath::new(
"/.trash/Artist/Track.flac"
)));
assert!(!VirtualTree::is_trash_path(&VirtualPath::new(
"/Artist/Track.flac"
)));
assert!(!VirtualTree::is_trash_path(&VirtualPath::new(
"/trash/Artist/Track.flac"
)));
}
#[test]
fn test_ensure_trash_dir() {
let mut tree = VirtualTree::new();
assert!(tree.get_by_path(&VirtualPath::new("/.trash")).is_none());
let inode = tree.ensure_trash_dir();
assert!(inode > ROOT_INODE);
let node = tree.get_by_path(&VirtualPath::new("/.trash"));
assert!(node.is_some());
assert!(node.unwrap().is_dir());
let inode2 = tree.ensure_trash_dir();
assert_eq!(inode, inode2);
}
#[test]
fn test_mkdir_p() {
let mut tree = VirtualTree::new();
tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap();
assert!(tree.get_by_path(&VirtualPath::new("/A")).is_some());
assert!(tree.get_by_path(&VirtualPath::new("/A/B")).is_some());
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some());
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some());
}
#[test]
fn test_mkdir_p_partial_exists() {
let mut tree = VirtualTree::new();
tree.mkdir(&VirtualPath::new("/A")).unwrap();
tree.mkdir(&VirtualPath::new("/A/B")).unwrap();
tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap();
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some());
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some());
}
#[test]
fn test_remove_directory_empty() {
let mut tree = VirtualTree::new();
tree.mkdir(&VirtualPath::new("/EmptyDir")).unwrap();
assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_some());
tree.remove_directory(&VirtualPath::new("/EmptyDir"))
.unwrap();
assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_none());
}
#[test]
fn test_remove_directory_not_empty() {
let mut tree = VirtualTree::new();
tree.insert_file(&make_file_meta(1, "/Artist/Track.flac"));
let result = tree.remove_directory(&VirtualPath::new("/Artist"));
assert_eq!(result, Err(RemoveError::NotEmpty));
}
#[test]
fn test_remove_directory_not_found() {
let mut tree = VirtualTree::new();
let result = tree.remove_directory(&VirtualPath::new("/NonExistent"));
assert_eq!(result, Err(RemoveError::NotFound));
}
#[test]
fn test_remove_directory_is_file() {
let mut tree = VirtualTree::new();
tree.insert_file(&make_file_meta(1, "/Track.flac"));
let result = tree.remove_directory(&VirtualPath::new("/Track.flac"));
assert_eq!(result, Err(RemoveError::NotDirectory));
}
#[test]
fn test_remove_directory_recursive() {
let mut tree = VirtualTree::new();
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac"));
tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac"));
tree.insert_file(&make_file_meta(3, "/Artist/Other/Track3.flac"));
let removed = tree
.remove_directory_recursive(&VirtualPath::new("/Artist"))
.unwrap();
assert_eq!(removed.len(), 3);
assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_none());
}
#[test]
fn test_is_directory_empty() {
let mut tree = VirtualTree::new();
tree.mkdir(&VirtualPath::new("/Empty")).unwrap();
assert_eq!(
tree.is_directory_empty(&VirtualPath::new("/Empty")),
Some(true)
);
tree.insert_file(&make_file_meta(1, "/NonEmpty/Track.flac"));
assert_eq!(
tree.is_directory_empty(&VirtualPath::new("/NonEmpty")),
Some(false)
);
assert_eq!(
tree.is_directory_empty(&VirtualPath::new("/NonExistent")),
None
);
}
}