mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
272 lines
9.8 KiB
Rust
272 lines
9.8 KiB
Rust
//! Filesystem walker behind the daemon's index of *local* (non-Drive) files.
|
|
//!
|
|
//! The launcher prompt searches Proton Drive and the machine's own files side by
|
|
//! side. Drive names come from the `nodes` FTS index; local names come from the
|
|
//! `local_files` index this module feeds. The daemon runs [`scan`] on a
|
|
//! background thread — never on a FUSE or control-socket thread — and streams
|
|
//! batches into [`crate::db::Db::local_upsert_batch`].
|
|
//!
|
|
//! Walking is deliberately shallow on cost: we stat each entry once (the walker
|
|
//! already has the `DirEntry` metadata) and skip the directories that dominate a
|
|
//! home directory's inode count without ever being interesting to search
|
|
//! (`node_modules`, `target`, caches, VCS internals). The Drive mountpoint is
|
|
//! always excluded — walking it would fault in every remote node through FUSE.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
use ignore::{WalkBuilder, WalkState};
|
|
|
|
/// Directory names skipped anywhere in the tree. These hold build artefacts,
|
|
/// dependency trees, and caches: high inode count, no search value.
|
|
const SKIP_DIRS: &[&str] = &[
|
|
"node_modules",
|
|
"target",
|
|
"__pycache__",
|
|
"venv",
|
|
".venv",
|
|
"vendor",
|
|
"dist",
|
|
"dist-newstyle",
|
|
"build",
|
|
"Trash",
|
|
];
|
|
|
|
/// Directory *paths* skipped when they end with one of these, for caches whose
|
|
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
|
|
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
|
|
/// the index, crowding real files out of the bounded candidate pool the fuzzy
|
|
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
|
|
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
|
|
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
|
|
|
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
|
/// database without bound. Reaching it stops the walk early.
|
|
const MAX_ENTRIES: usize = 400_000;
|
|
|
|
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
|
/// amortises, small enough that the writer lock is released often (FUSE
|
|
/// write-through shares the same connection).
|
|
const BATCH: usize = 2_000;
|
|
|
|
/// One indexed local file or directory.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocalEntry {
|
|
/// Absolute path.
|
|
pub path: String,
|
|
/// Final path component.
|
|
pub name: String,
|
|
pub is_dir: bool,
|
|
/// Size in bytes (0 for directories).
|
|
pub size: i64,
|
|
/// Modification time, epoch seconds.
|
|
pub mtime: i64,
|
|
}
|
|
|
|
/// Walk `roots` in parallel, calling `sink` with batches of entries.
|
|
///
|
|
/// Hidden files and anything under `excludes` (the Drive mountpoint, our own
|
|
/// state/cache dirs) are skipped, as are the [`SKIP_DIRS`] names at any depth.
|
|
/// Symlinks are not followed, so a link loop cannot hang the scan. Returns the
|
|
/// number of entries handed to `sink`.
|
|
///
|
|
/// `sink` is called from the walker's worker threads, serialized by an internal
|
|
/// mutex; it must not block for long.
|
|
pub fn scan(
|
|
roots: &[PathBuf],
|
|
excludes: &[PathBuf],
|
|
sink: impl FnMut(Vec<LocalEntry>) + Send,
|
|
) -> usize {
|
|
let Some((first, rest)) = roots.split_first() else {
|
|
return 0;
|
|
};
|
|
|
|
let mut builder = WalkBuilder::new(first);
|
|
for root in rest {
|
|
builder.add(root);
|
|
}
|
|
let excludes: Vec<PathBuf> = excludes.to_vec();
|
|
builder
|
|
// `standard_filters` would honour .gitignore/.ignore files: a source
|
|
// tree's ignored-but-present files are still files the user may want to
|
|
// find, so only the hidden filter stays on.
|
|
.standard_filters(false)
|
|
.hidden(true)
|
|
.follow_links(false)
|
|
.same_file_system(true)
|
|
.threads(std::thread::available_parallelism().map_or(4, |n| n.get().min(8)))
|
|
.filter_entry(move |entry| {
|
|
let path = entry.path();
|
|
if excludes.iter().any(|e| path.starts_with(e)) {
|
|
return false;
|
|
}
|
|
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
|
|
return false;
|
|
}
|
|
!is_skipped_path(path)
|
|
});
|
|
|
|
let state = parking_lot::Mutex::new(SinkState {
|
|
buf: Vec::with_capacity(BATCH),
|
|
total: 0,
|
|
sink,
|
|
});
|
|
|
|
builder.build_parallel().run(|| {
|
|
Box::new(|result| {
|
|
let Ok(entry) = result else {
|
|
return WalkState::Continue;
|
|
};
|
|
// Depth 0 is a root itself; indexing it adds nothing to search.
|
|
if entry.depth() == 0 {
|
|
return WalkState::Continue;
|
|
}
|
|
let Some(local) = to_entry(&entry) else {
|
|
return WalkState::Continue;
|
|
};
|
|
|
|
let mut state = state.lock();
|
|
state.push(local);
|
|
if state.total >= MAX_ENTRIES {
|
|
return WalkState::Quit;
|
|
}
|
|
WalkState::Continue
|
|
})
|
|
});
|
|
|
|
let mut state = state.into_inner();
|
|
state.flush();
|
|
state.total
|
|
}
|
|
|
|
/// Batching buffer shared by the walker's worker threads.
|
|
struct SinkState<F: FnMut(Vec<LocalEntry>)> {
|
|
buf: Vec<LocalEntry>,
|
|
total: usize,
|
|
sink: F,
|
|
}
|
|
|
|
impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
|
fn push(&mut self, entry: LocalEntry) {
|
|
self.buf.push(entry);
|
|
self.total += 1;
|
|
if self.buf.len() >= BATCH {
|
|
self.flush();
|
|
}
|
|
}
|
|
|
|
fn flush(&mut self) {
|
|
if !self.buf.is_empty() {
|
|
let batch = std::mem::take(&mut self.buf);
|
|
(self.sink)(batch);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
|
|
/// Matched on the path's own components so a directory merely *named* `mod`
|
|
/// keeps being indexed.
|
|
fn is_skipped_path(path: &Path) -> bool {
|
|
let path = path.to_string_lossy();
|
|
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
|
|
path.strip_suffix(suffix)
|
|
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
|
|
})
|
|
}
|
|
|
|
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
|
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
|
/// JSON wire format) is UTF-8 only.
|
|
fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
|
let path = entry.path().to_str()?.to_string();
|
|
let name = entry.file_name().to_str()?.to_string();
|
|
let meta = entry.metadata().ok()?;
|
|
let is_dir = meta.is_dir();
|
|
let mtime = meta
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
|
.map_or(0, |d| d.as_secs() as i64);
|
|
Some(LocalEntry {
|
|
path,
|
|
name,
|
|
is_dir,
|
|
size: if is_dir { 0 } else { meta.len() as i64 },
|
|
mtime,
|
|
})
|
|
}
|
|
|
|
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
|
/// would fault every remote node in through FUSE) plus our own state and cache
|
|
/// dirs, which hold blobs no user searches for by name.
|
|
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
|
vec![
|
|
mountpoint.to_path_buf(),
|
|
state_dir.to_path_buf(),
|
|
cache_dir.to_path_buf(),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A scan indexes plain files, skips the excluded subtree and the junk dirs,
|
|
/// and reports names/paths the search index can match on.
|
|
#[test]
|
|
fn scan_indexes_files_and_honours_excludes() {
|
|
let tmp = std::env::temp_dir().join(format!("pdfs-scan-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
std::fs::create_dir_all(tmp.join("docs")).unwrap();
|
|
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap();
|
|
std::fs::create_dir_all(tmp.join("mnt")).unwrap();
|
|
std::fs::write(tmp.join("docs/report.pdf"), b"x").unwrap();
|
|
std::fs::write(tmp.join("node_modules/pkg/index.js"), b"x").unwrap();
|
|
std::fs::write(tmp.join("mnt/remote.txt"), b"x").unwrap();
|
|
|
|
let mut got = Vec::new();
|
|
let n = scan(std::slice::from_ref(&tmp), &[tmp.join("mnt")], |batch| {
|
|
got.extend(batch)
|
|
});
|
|
|
|
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
|
assert_eq!(n, got.len());
|
|
assert!(names.contains(&"report.pdf"));
|
|
assert!(names.contains(&"docs"));
|
|
// Junk dir pruned, mountpoint-style exclude pruned.
|
|
assert!(!names.contains(&"index.js"));
|
|
assert!(!names.contains(&"node_modules"));
|
|
assert!(!names.contains(&"remote.txt"));
|
|
|
|
let report = got.iter().find(|e| e.name == "report.pdf").unwrap();
|
|
assert!(!report.is_dir);
|
|
assert_eq!(report.size, 1);
|
|
assert!(report.path.ends_with("docs/report.pdf"));
|
|
|
|
std::fs::remove_dir_all(&tmp).unwrap();
|
|
}
|
|
|
|
/// The Go module cache is the single largest source of index noise on a
|
|
/// developer's machine (58% of the entries on the one this was measured
|
|
/// on), and it cannot be pruned by directory name: `mod` is far too
|
|
/// common a name to blacklist outright.
|
|
#[test]
|
|
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
|
|
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
|
|
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
|
|
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
|
|
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
|
|
|
|
let mut got = Vec::new();
|
|
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
|
|
|
|
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
|
assert!(!names.contains(&"vendored.go"), "{names:?}");
|
|
assert!(names.contains(&"mine.conf"), "{names:?}");
|
|
|
|
std::fs::remove_dir_all(&tmp).unwrap();
|
|
}
|
|
}
|