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)))?; .map_err(|e| Error::Database(format!("upsert failed: {}", e)))?;
let id = conn.last_insert_rowid(); 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>> { pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
@@ -424,6 +434,140 @@ impl Database {
.optional() .optional()
.map_err(|e| Error::Database(format!("query failed: {}", e))) .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 { fn parse_audio_format(s: &str) -> AudioFormat {
@@ -782,4 +926,124 @@ mod tests {
let files = db.get_files_by_prefix("/Other/").unwrap(); let files = db.get_files_by_prefix("/Other/").unwrap();
assert_eq!(files.len(), 1); 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; mod tree;
pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork}; pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork};
pub use db::Database; pub use db::{Database, TrashedFile, TrashedFilter};
pub use eviction::{EvictionError, EvictionPolicy, LruEviction}; pub use eviction::{EvictionError, EvictionPolicy, LruEviction};
pub use metadata::MetadataCache; pub use metadata::MetadataCache;
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore}; pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle}; pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
pub use tree::{ pub use tree::{
DirNode, FileNode, Inode, RefreshPolicy, RenameError, TreeBuilder, VirtualNode, VirtualTree, DirNode, FileNode, Inode, RefreshPolicy, RemoveError, RenameError, TreeBuilder, VirtualNode,
ROOT_INODE, VirtualTree, ROOT_INODE,
}; };
+5
View File
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS files (
chunk_manifest BLOB, chunk_manifest BLOB,
last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 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) 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_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) 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)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -889,4 +1090,135 @@ mod tests {
assert_eq!(result, Err(RenameError::TargetExists)); 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
);
}
} }
+314 -7
View File
@@ -1,6 +1,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use musicfs_cache::{Database, TreeBuilder}; use musicfs_cache::{Database, RenameError, TrashedFilter, TreeBuilder, VirtualTree};
use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader}; use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader};
use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath}; use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath};
use musicfs_fuse::MusicFs; use musicfs_fuse::MusicFs;
@@ -66,6 +66,14 @@ enum Commands {
#[arg(short, long, default_value = "30")] #[arg(short, long, default_value = "30")]
timeout: u32, timeout: u32,
}, },
Trash {
#[arg(short, long, help = "Config file path")]
config: Option<PathBuf>,
#[arg(short = 'd', long, help = "Cache directory")]
cache_dir: Option<PathBuf>,
#[command(subcommand)]
command: TrashCommands,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -88,6 +96,30 @@ enum OriginCommands {
Rescan { origin_id: String }, Rescan { origin_id: String },
} }
#[derive(Subcommand)]
enum TrashCommands {
List {
#[arg(long, help = "Filter by origin")]
origin: Option<String>,
#[arg(long, help = "Show files deleted within duration (e.g., 7d, 24h)")]
since: Option<String>,
#[arg(long, help = "Filter by path prefix")]
path: Option<String>,
},
Restore {
#[arg(help = "Path to restore (restores folder recursively)")]
path: Option<String>,
#[arg(long, help = "Restore all deleted files")]
all: bool,
},
Empty {
#[arg(long, help = "Delete files older than duration (e.g., 30d)")]
older_than: Option<String>,
#[arg(long, help = "Delete files matching pattern")]
pattern: Option<String>,
},
}
struct LockFile { struct LockFile {
_file: File, _file: File,
} }
@@ -195,6 +227,14 @@ fn main() -> Result<()> {
init_basic_logging(&cli.log_level); init_basic_logging(&cli.log_level);
run_shutdown(graceful, timeout) run_shutdown(graceful, timeout)
} }
Commands::Trash {
config,
cache_dir,
command,
} => {
init_basic_logging(&cli.log_level);
run_trash(config, cache_dir, command)
}
} }
} }
@@ -307,6 +347,14 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> {
.context("Failed to acquire lock — is another instance running?")?; .context("Failed to acquire lock — is another instance running?")?;
info!(lock_path = ?lock_path, "Lock acquired"); info!(lock_path = ?lock_path, "Lock acquired");
let pid_path = config.cache_dir.join("musicfs.pid");
std::fs::write(&pid_path, std::process::id().to_string())
.context("Failed to write PID file")?;
info!(pid_path = ?pid_path, "PID file written");
let tree_for_restore = tree.clone();
let db_for_restore = db.clone();
let fs = MusicFs::with_reader(tree, reader, handle.clone()).with_db(db); let fs = MusicFs::with_reader(tree, reader, handle.clone()).with_db(db);
info!("Mounting filesystem at {:?}", config.mount_point); info!("Mounting filesystem at {:?}", config.mount_point);
@@ -329,13 +377,22 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> {
let mut sigterm = let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
tokio::select! { loop {
_ = sigterm.recv() => { tokio::select! {
info!("Received SIGTERM, shutting down"); _ = sigterm.recv() => {
} info!("Received SIGTERM, shutting down");
_ = sigint.recv() => { break;
info!("Received SIGINT, shutting down"); }
_ = sigint.recv() => {
info!("Received SIGINT, shutting down");
break;
}
_ = sighup.recv() => {
info!("Received SIGHUP, processing pending restores");
process_pending_restores(&tree_for_restore, &db_for_restore);
}
} }
} }
@@ -355,6 +412,8 @@ fn run_mount(config: musicfs_core::Config) -> Result<()> {
} }
info!("Unmounting filesystem"); info!("Unmounting filesystem");
drop(session); drop(session);
let _ = std::fs::remove_file(&pid_path);
info!("Shutdown complete"); info!("Shutdown complete");
Ok(()) Ok(())
@@ -422,6 +481,254 @@ fn run_shutdown(graceful: bool, timeout: u32) -> Result<()> {
Ok(()) Ok(())
} }
fn run_trash(
config: Option<PathBuf>,
cache_dir: Option<PathBuf>,
command: TrashCommands,
) -> Result<()> {
let cache_dir = if let Some(dir) = cache_dir {
dir
} else if let Some(cfg_path) = config {
let content = std::fs::read_to_string(&cfg_path).context("Failed to read config file")?;
let config: Value = toml::from_str(&content).context("Failed to parse config file")?;
PathBuf::from(
config
.get("cache_dir")
.and_then(|v| v.as_str())
.context("cache_dir not found in config")?,
)
} else {
return Err(anyhow::anyhow!(
"Either --config or --cache-dir must be provided"
));
};
let db_path = cache_dir.join("musicfs.db");
let db = Database::open(&db_path).context("Failed to open database")?;
match command {
TrashCommands::List {
origin,
since,
path,
} => {
let filter = TrashedFilter {
origin_id: origin.map(|s| OriginId::from(s.as_str())),
path_prefix: path,
since: since.and_then(|s| parse_duration(&s)),
};
let trashed = db.list_trashed(&filter)?;
if trashed.is_empty() {
println!("No deleted files found.");
return Ok(());
}
println!("{:<6} {:<20} PATH", "IDX", "DELETED");
println!("{}", "-".repeat(80));
for (i, file) in trashed.iter().enumerate() {
let ago = format_time_ago(file.trashed_at);
println!("{:<6} {:<20} {}", i, ago, file.original_path.as_str());
}
println!("\nTotal: {} deleted files", trashed.len());
}
TrashCommands::Restore { path, all } => {
let trashed = if all {
db.list_trashed(&TrashedFilter::default())?
} else if let Some(ref p) = path {
db.get_trashed_by_prefix(p)?
} else {
return Err(anyhow::anyhow!("Either --all or a path must be provided"));
};
if trashed.is_empty() {
println!("No files to restore.");
return Ok(());
}
let restore_file = cache_dir.join("pending_restore.txt");
let paths: Vec<String> = trashed
.iter()
.map(|f| f.original_path.as_str().to_string())
.collect();
std::fs::write(&restore_file, paths.join("\n"))?;
let pid_path = cache_dir.join("musicfs.pid");
if pid_path.exists() {
let pid_str = std::fs::read_to_string(&pid_path)?;
let pid: i32 = pid_str.trim().parse().context("Invalid PID in pid file")?;
std::env::set_var("MUSICFS_RESTORE_FILE", &restore_file);
unsafe {
libc::kill(pid, libc::SIGHUP);
}
println!("Restore signal sent for {} files.", trashed.len());
println!("Files will appear at their original locations.");
} else {
println!(
"Daemon not running. Marked {} files for restore.",
trashed.len()
);
println!("Start the daemon to complete restore, or restore manually with 'mv'.");
}
}
TrashCommands::Empty {
older_than,
pattern,
} => {
let filter = TrashedFilter {
since: older_than.and_then(|s| parse_duration(&s)),
path_prefix: pattern,
..Default::default()
};
let count = db.purge_trashed(&filter)?;
println!("Permanently deleted {} files from trash.", count);
}
}
Ok(())
}
fn parse_duration(s: &str) -> Option<std::time::Duration> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num_str, unit) = if s.ends_with('d') {
(&s[..s.len() - 1], 'd')
} else if s.ends_with('h') {
(&s[..s.len() - 1], 'h')
} else if s.ends_with('m') {
(&s[..s.len() - 1], 'm')
} else if s.ends_with('s') {
(&s[..s.len() - 1], 's')
} else {
return None;
};
let num: u64 = num_str.parse().ok()?;
let secs = match unit {
'd' => num * 86400,
'h' => num * 3600,
'm' => num * 60,
's' => num,
_ => return None,
};
Some(std::time::Duration::from_secs(secs))
}
fn format_time_ago(timestamp: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let diff = now - timestamp;
if diff < 60 {
format!("{}s ago", diff)
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
}
}
fn process_pending_restores(tree: &Arc<RwLock<VirtualTree>>, db: &Arc<Database>) {
let restore_file = match std::env::var("MUSICFS_RESTORE_FILE") {
Ok(path) => PathBuf::from(path),
Err(_) => {
debug!("MUSICFS_RESTORE_FILE not set, no restores to process");
return;
}
};
let restore_paths: Vec<String> = match std::fs::read_to_string(&restore_file) {
Ok(content) => content.lines().map(|s| s.to_string()).collect(),
Err(e) => {
warn!(error = %e, path = ?restore_file, "failed to read restore file");
return;
}
};
if restore_paths.is_empty() {
debug!("no paths to restore");
return;
}
let trashed = match db.list_trashed(&TrashedFilter::default()) {
Ok(files) => files,
Err(e) => {
warn!(error = %e, "failed to list trashed files");
return;
}
};
let mut restored = 0;
for original_path_str in &restore_paths {
let matching: Vec<_> = trashed
.iter()
.filter(|f| {
f.original_path.as_str() == original_path_str
|| f.original_path
.as_str()
.starts_with(&format!("{}/", original_path_str))
})
.collect();
for file in matching {
let parent_path = std::path::Path::new(file.original_path.as_str())
.parent()
.map(|p| {
let s = p.to_string_lossy();
if s.is_empty() {
VirtualPath::new("/")
} else {
VirtualPath::new(s.into_owned())
}
})
.unwrap_or_else(|| VirtualPath::new("/"));
let mut tree_guard = tree.write();
if let Err(e) = tree_guard.mkdir_p(&parent_path) {
if !matches!(e, RenameError::TargetExists) {
warn!(error = ?e, path = %parent_path.as_str(), "failed to create parent for restore");
continue;
}
}
if let Err(e) = tree_guard.rename_file(&file.current_path, &file.original_path) {
warn!(error = ?e, from = %file.current_path.as_str(), to = %file.original_path.as_str(), "failed to restore file");
continue;
}
drop(tree_guard);
if let Err(e) = db.update_virtual_path(file.file_id, &file.original_path) {
warn!(error = %e, "failed to update virtual path after restore");
}
if let Err(e) = db.unmark_trashed(file.file_id) {
warn!(error = %e, "failed to unmark trashed after restore");
}
restored += 1;
info!(path = %file.original_path.as_str(), "restored file from trash");
}
}
let _ = std::fs::remove_file(&restore_file);
info!(count = restored, "restore complete");
}
fn init_logging(config: &LoggingConfig) -> Result<WorkerGuard> { fn init_logging(config: &LoggingConfig) -> Result<WorkerGuard> {
std::fs::create_dir_all(&config.log_dir)?; std::fs::create_dir_all(&config.log_dir)?;
+116 -5
View File
@@ -3,7 +3,7 @@ use fuser::{
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen, FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
Request, Request,
}; };
use musicfs_cache::{Database, RenameError, VirtualNode, VirtualTree, ROOT_INODE}; use musicfs_cache::{Database, RemoveError, RenameError, VirtualNode, VirtualTree, ROOT_INODE};
use musicfs_cas::FileReader; use musicfs_cas::FileReader;
use musicfs_core::{Result, VirtualPath}; use musicfs_core::{Result, VirtualPath};
use parking_lot::RwLock; use parking_lot::RwLock;
@@ -546,12 +546,115 @@ impl Filesystem for MusicFs {
} }
} }
fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) { fn unlink(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS); let path = match self.resolve_path(parent, name) {
Some(p) => p,
None => {
reply.error(libc::ENOENT);
return;
}
};
let (file_id, is_dir) = {
let tree = self.tree.read();
match tree.get_by_path(&path) {
Some(VirtualNode::File(f)) => (Some(f.file_id), false),
Some(VirtualNode::Directory(_)) => (None, true),
None => {
reply.error(libc::ENOENT);
return;
}
}
};
if is_dir {
reply.error(libc::EISDIR);
return;
}
let trash_path = VirtualPath::new(format!("/.trash{}", path.as_str()));
{
let mut tree = self.tree.write();
tree.ensure_trash_dir();
let trash_parent = std::path::Path::new(trash_path.as_str())
.parent()
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
.unwrap_or_else(|| VirtualPath::new("/.trash"));
if let Err(e) = tree.mkdir_p(&trash_parent) {
if !matches!(e, RenameError::TargetExists) {
warn!(error = ?e, "failed to create trash parent directories");
reply.error(libc::EIO);
return;
}
}
if let Err(e) = tree.rename_file(&path, &trash_path) {
match e {
RenameError::SourceNotFound => reply.error(libc::ENOENT),
RenameError::TargetExists => reply.error(libc::EEXIST),
_ => reply.error(libc::EIO),
}
return;
}
}
if let (Some(ref db), Some(id)) = (&self.db, file_id) {
if let Err(e) = db.update_virtual_path(id, &trash_path) {
warn!(error = %e, "failed to update virtual path in database");
}
if let Err(e) = db.mark_trashed(id, &path) {
warn!(error = %e, "failed to mark file as trashed in database");
}
}
debug!(path = %path.as_str(), trash = %trash_path.as_str(), "file moved to trash");
reply.ok();
} }
fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) { fn rmdir(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS); let path = match self.resolve_path(parent, name) {
Some(p) => p,
None => {
reply.error(libc::ENOENT);
return;
}
};
if VirtualTree::is_trash_path(&path) {
reply.error(libc::EPERM);
return;
}
{
let mut tree = self.tree.write();
match tree.remove_directory(&path) {
Ok(()) => {}
Err(RemoveError::NotFound) => {
reply.error(libc::ENOENT);
return;
}
Err(RemoveError::NotEmpty) => {
reply.error(libc::ENOTEMPTY);
return;
}
Err(RemoveError::NotDirectory) => {
reply.error(libc::ENOTDIR);
return;
}
}
}
if let Some(ref db) = self.db {
if let Err(e) = db.delete_directory(&path) {
warn!(error = %e, "failed to delete directory from database");
}
}
debug!(path = %path.as_str(), "directory removed");
reply.ok();
} }
fn rename( fn rename(
@@ -635,6 +738,14 @@ impl Filesystem for MusicFs {
if let Err(e) = db.update_virtual_path(id, &new_path) { if let Err(e) = db.update_virtual_path(id, &new_path) {
warn!(error = %e, "failed to persist file rename to database"); warn!(error = %e, "failed to persist file rename to database");
} }
let was_in_trash = VirtualTree::is_trash_path(&old_path);
let now_in_trash = VirtualTree::is_trash_path(&new_path);
if was_in_trash && !now_in_trash {
if let Err(e) = db.unmark_trashed(id) {
warn!(error = %e, "failed to unmark trashed after restore");
}
debug!(path = %new_path.as_str(), "file restored from trash");
}
} }
debug!(old = %old_path.as_str(), new = %new_path.as_str(), "file renamed"); debug!(old = %old_path.as_str(), new = %new_path.as_str(), "file renamed");
Ok(()) Ok(())
+166
View File
@@ -0,0 +1,166 @@
**Date**: 2026-05-17
**Status**: Shipped
# Feature: Remove (rm)
## Overview
MusicFS supports removing files and directories. Deleted files are moved to a virtual `/.trash/` directory and can be restored. The trash is browsable — users can manually move files out.
## Behavior
### Remove File
```bash
rm "/mnt/music/Artist/Album/track.flac"
```
- File moves to `/.trash/Artist/Album/track.flac`
- Original directory structure preserved in trash
- File still accessible via `/.trash/` path
- Database marks file as `trashed=1` with original path stored
### Remove Empty Directory
```bash
rmdir "/mnt/music/Empty Folder"
```
- Removes empty directory from tree
- Removes from `directories` table if user-created
- Fails with `ENOTEMPTY` if directory has children
### Remove Directory Recursively
```bash
rm -rf "/mnt/music/Artist"
```
- Shell handles recursion (depth-first unlink + rmdir)
- All files moved to `/.trash/Artist/...`
- Empty directories removed after files are trashed
## The `.trash/` Directory
Deleted files live in `/.trash/` with their original path structure:
```
/.trash/
├── Artist/
│ └── Album/
│ ├── track1.flac
│ └── track2.flac
└── Other Artist/
└── song.flac
```
### Browse Trash
```bash
ls "/.trash/"
ls "/.trash/Artist/Album/"
```
### Manual Restore
```bash
# Move file back manually - trashed flag is automatically cleared
mv "/.trash/Artist/Album/track.flac" "/Artist/Album/"
```
When moving a file out of `/.trash/`, the database `trashed` flag is automatically cleared.
## CLI Commands
All trash commands require either `--config` or `--cache-dir`:
```bash
musicfs trash -c config.toml <command>
musicfs trash --cache-dir ./dev/cache/musicfs <command>
```
### List Deleted Files
```bash
musicfs trash -c config.toml list
musicfs trash -c config.toml list --origin local-storage
musicfs trash -c config.toml list --since 7d
musicfs trash -c config.toml list --path "/Artist"
```
Output shows index, deletion time, and original path.
### Restore Files
```bash
# Restore single file or folder
musicfs trash -c config.toml restore "/Artist/Album/track.flac"
# Restore entire folder recursively
musicfs trash -c config.toml restore "/Artist"
# Restore everything
musicfs trash -c config.toml restore --all
```
CLI restore writes paths to a pending restore file and sends SIGHUP to the daemon.
The daemon processes pending restores and moves files back from `/.trash/`.
### Empty Trash
```bash
# Permanently delete all trashed files
musicfs trash -c config.toml empty
# Delete old items only
musicfs trash -c config.toml empty --older-than 30d
# Delete by path pattern
musicfs trash -c config.toml empty --pattern "/Artist"
```
**Warning:** Empty permanently removes files from MusicFS database. Origin files are unaffected.
## Error Codes
| Condition | Error |
|-----------|-------|
| Path doesn't exist | `ENOENT` |
| `rm` on directory (without `-r`) | `EISDIR` |
| `rmdir` on file | `ENOTDIR` |
| `rmdir` on non-empty directory | `ENOTEMPTY` |
| `rmdir` on `/.trash/` | `EPERM` |
## Database Schema
Files table extended with trash columns:
```sql
trashed INTEGER NOT NULL DEFAULT 0,
original_path TEXT,
trashed_at INTEGER
```
Partial index for efficient trash queries:
```sql
CREATE INDEX idx_files_trashed ON files(trashed) WHERE trashed = 1;
```
## How It Works
1. **Delete (`rm`)**: FUSE `unlink` moves file to `/.trash/`, marks `trashed=1` in DB
2. **Manual restore (`mv`)**: Moving out of `/.trash/` automatically clears `trashed` flag
3. **CLI restore**: Writes pending paths, sends SIGHUP to daemon, daemon processes restores
4. **Empty**: Deletes matching records from database
## Persistence
- Trashed files persist across remounts (stored in `/.trash/` subtree)
- Files marked with `trashed=1`, `original_path`, `trashed_at` in database
- PID file at `{cache_dir}/musicfs.pid` for CLI→daemon communication
## Limitations
- **No hard delete of remote files**: Origin content is never modified
- **Trash uses virtual space**: Files still in tree under `/.trash/` until emptied
- **CLI restore requires running daemon**: Manual `mv` works without daemon