2026-08-02 19:04:30 -04:00
|
|
|
//! The one place `#[cfg]` lives.
|
|
|
|
|
//!
|
2026-08-09 16:25:43 -04:00
|
|
|
//! Two rules: every function is defined for every target, so callers never
|
|
|
|
|
//! wrap a call site in `#[cfg]`; and anything decidable from a string rather
|
|
|
|
|
//! than a syscall is split out and made testable everywhere
|
|
|
|
|
//! ([`is_unc_string`], [`PATH_COLLATION`]), because the test suite runs on
|
|
|
|
|
//! Linux.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
use std::ffi::OsString;
|
|
|
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
/// The user's home directory.
|
|
|
|
|
///
|
|
|
|
|
/// On Windows `%USERPROFILE%` is checked **first**. Git Bash and MSYS2 export
|
|
|
|
|
/// `HOME` as a POSIX path (`/c/Users/me`) that no Win32 API can open, and
|
|
|
|
|
/// preferring it would point the config file, the index, and the default
|
|
|
|
|
/// indexing root at a directory that does not exist.
|
|
|
|
|
pub fn home_dir() -> Option<OsString> {
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
|
|
|
|
return Some(profile);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `FILE_ATTRIBUTE_HIDDEN`, spelled out so [`attributes_are_hidden`] compiles
|
|
|
|
|
/// and is tested on Linux; checked against the real header below.
|
2026-08-05 18:05:04 -04:00
|
|
|
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
const _: () = assert!(
|
|
|
|
|
FILE_ATTRIBUTE_HIDDEN == windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_HIDDEN
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Whether a Windows attribute word marks an entry hidden.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `FILE_ATTRIBUTE_HIDDEN` and nothing else. `FILE_ATTRIBUTE_SYSTEM` must not
|
|
|
|
|
/// be part of this test: Windows honours a folder's `desktop.ini` only if the
|
|
|
|
|
/// folder carries Read-only or System, so cloud-sync clients set System on
|
|
|
|
|
/// their plainly visible sync roots purely to get a branded icon — including
|
|
|
|
|
/// it pruned whole cloud folders from the index. Everything System was meant
|
|
|
|
|
/// to catch (`$RECYCLE.BIN`, `pagefile.sys`, the legacy per-user junctions)
|
|
|
|
|
/// is Hidden **and** System, so Hidden alone catches every one.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg_attr(not(windows), allow(dead_code))]
|
|
|
|
|
pub(crate) fn attributes_are_hidden(attributes: u32) -> bool {
|
|
|
|
|
attributes & FILE_ATTRIBUTE_HIDDEN != 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Why an entry counted as hidden. Only the attribute case is worth the
|
|
|
|
|
/// walk's log line: an attribute the user cannot see has no discoverability.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum HiddenReason {
|
|
|
|
|
DotPrefix,
|
|
|
|
|
Attribute,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether a directory entry counts as hidden, and why.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Unix: a leading dot. Windows: a leading dot **or**
|
|
|
|
|
/// `FILE_ATTRIBUTE_HIDDEN`, without which `include_hidden = false` hides
|
|
|
|
|
/// nothing there. `FILE_ATTRIBUTE_SYSTEM` is not part of it; see
|
2026-08-05 18:05:04 -04:00
|
|
|
/// [`attributes_are_hidden`].
|
|
|
|
|
///
|
|
|
|
|
/// `meta` must report the attributes of the entry **itself**, never of a link
|
|
|
|
|
/// target — callers pass `symlink_metadata` or an already-cached directory
|
2026-08-09 16:25:43 -04:00
|
|
|
/// entry. If call sites disagree on that, a full run indexes a file the
|
|
|
|
|
/// watcher then refuses to update and the index churns on every cycle.
|
2026-08-02 19:04:30 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `meta` is a closure because on Unix it is never called — `metadata()`
|
|
|
|
|
/// would cost an extra `lstat` per entry — while on Windows the data is
|
|
|
|
|
/// already cached from `FindNextFileW`.
|
2026-08-05 18:05:04 -04:00
|
|
|
pub fn entry_hidden_reason<F>(name: &str, meta: F) -> Option<HiddenReason>
|
2026-08-02 19:04:30 -04:00
|
|
|
where
|
|
|
|
|
F: FnOnce() -> Option<std::fs::Metadata>,
|
|
|
|
|
{
|
|
|
|
|
if name.starts_with('.') {
|
2026-08-05 18:05:04 -04:00
|
|
|
return Some(HiddenReason::DotPrefix);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::windows::fs::MetadataExt;
|
|
|
|
|
if let Some(m) = meta() {
|
2026-08-05 18:05:04 -04:00
|
|
|
if attributes_are_hidden(m.file_attributes()) {
|
|
|
|
|
return Some(HiddenReason::Attribute);
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
{
|
|
|
|
|
let _ = meta;
|
|
|
|
|
}
|
2026-08-05 18:05:04 -04:00
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [`entry_hidden_reason`] for the callers that only need the verdict.
|
|
|
|
|
pub fn entry_is_hidden<F>(name: &str, meta: F) -> bool
|
|
|
|
|
where
|
|
|
|
|
F: FnOnce() -> Option<std::fs::Metadata>,
|
|
|
|
|
{
|
|
|
|
|
entry_hidden_reason(name, meta).is_some()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `FILE_ATTRIBUTE_REPARSE_POINT`, spelled out for
|
|
|
|
|
/// [`FILE_ATTRIBUTE_HIDDEN`]'s reason and checked the same way.
|
2026-08-05 18:05:04 -04:00
|
|
|
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
const _: () = assert!(
|
|
|
|
|
FILE_ATTRIBUTE_REPARSE_POINT
|
|
|
|
|
== windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// The attributes a dehydrated cloud file carries.
|
|
|
|
|
///
|
|
|
|
|
/// `FILE_ATTRIBUTE_OFFLINE` (0x1000) is the old tape-archive bit that OneDrive
|
|
|
|
|
/// reused; `RECALL_ON_OPEN` (0x40000) marks a file whose *metadata* is local but
|
|
|
|
|
/// whose data is not; `RECALL_ON_DATA_ACCESS` (0x400000) is the modern
|
|
|
|
|
/// Files-On-Demand placeholder. Any one of them means opening the file for read
|
|
|
|
|
/// pulls it over the network.
|
|
|
|
|
const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000;
|
|
|
|
|
const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x4_0000;
|
|
|
|
|
const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x40_0000;
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
const _: () = {
|
|
|
|
|
use windows_sys::Win32::Storage::FileSystem as fs_attrs;
|
|
|
|
|
assert!(FILE_ATTRIBUTE_OFFLINE == fs_attrs::FILE_ATTRIBUTE_OFFLINE);
|
|
|
|
|
assert!(FILE_ATTRIBUTE_RECALL_ON_OPEN == fs_attrs::FILE_ATTRIBUTE_RECALL_ON_OPEN);
|
|
|
|
|
assert!(FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS == fs_attrs::FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Whether reading this file's contents would pull it down from the cloud.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// A Files-On-Demand placeholder has real metadata and no data; opening one
|
|
|
|
|
/// for read blocks on a network download of the entire file. The default
|
|
|
|
|
/// indexing root is `%USERPROFILE%` and the walk hashes the head of every new
|
|
|
|
|
/// or changed file, so without this test a first index quietly downloads the
|
|
|
|
|
/// user's whole cloud drive. Always `false` off Windows.
|
2026-08-05 18:05:04 -04:00
|
|
|
pub fn is_cloud_placeholder(meta: &std::fs::Metadata) -> bool {
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::windows::fs::MetadataExt;
|
|
|
|
|
return attributes_are_dehydrated(meta.file_attributes());
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
{
|
|
|
|
|
let _ = meta;
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The bit test behind [`is_cloud_placeholder`], split out so the Linux suite
|
2026-08-09 16:25:43 -04:00
|
|
|
/// exercises it.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg_attr(not(windows), allow(dead_code))]
|
|
|
|
|
pub(crate) fn attributes_are_dehydrated(attributes: u32) -> bool {
|
|
|
|
|
attributes
|
|
|
|
|
& (FILE_ATTRIBUTE_OFFLINE
|
|
|
|
|
| FILE_ATTRIBUTE_RECALL_ON_OPEN
|
|
|
|
|
| FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS)
|
|
|
|
|
!= 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether a Windows attribute word marks an entry as a reparse point.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// *Not* the same question as `FileType::is_symlink`, which additionally
|
|
|
|
|
/// requires the name-surrogate bit: see [`entry_cached_metadata`].
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg_attr(not(windows), allow(dead_code))]
|
|
|
|
|
pub(crate) fn attributes_are_reparse_point(attributes: u32) -> bool {
|
|
|
|
|
attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Metadata a directory read already handed back, on the platforms that hand
|
|
|
|
|
/// any back at all.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `std::fs::Metadata` on Windows: `FindFirstFileW`/`FindNextFileW` return
|
|
|
|
|
/// size, timestamps and attributes with every name, and
|
|
|
|
|
/// `std::fs::DirEntry::metadata` is a copy of that buffer rather than a
|
|
|
|
|
/// syscall. A `fs::metadata(path)` on the same entry is a whole extra
|
|
|
|
|
/// `CreateFileW` + `GetFileInformationByHandle` + `CloseHandle` — through
|
|
|
|
|
/// every antivirus minifilter on the machine — for data already in hand.
|
2026-08-05 18:05:04 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Uninhabited everywhere else (`getdents64` returns only `d_type`), so
|
|
|
|
|
/// `Option<CachedMetadata>` is zero-sized off Windows.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg(windows)]
|
|
|
|
|
pub(crate) type CachedMetadata = std::fs::Metadata;
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
pub(crate) type CachedMetadata = std::convert::Infallible;
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Enforces the zero-size claim above.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
const _: () = assert!(std::mem::size_of::<Option<CachedMetadata>>() == 0);
|
|
|
|
|
|
|
|
|
|
/// The metadata a directory read already produced for one entry — but only
|
2026-08-09 16:25:43 -04:00
|
|
|
/// where trusting it is indistinguishable from a fresh `stat`.
|
|
|
|
|
///
|
|
|
|
|
/// `meta` is a closure for [`entry_hidden_reason`]'s reason: on Unix it is
|
|
|
|
|
/// never called.
|
|
|
|
|
///
|
|
|
|
|
/// `None` for a reparse point even on Windows, and that is the whole
|
|
|
|
|
/// subtlety: `fs::metadata` *follows* a reparse point, while the cached
|
|
|
|
|
/// buffer describes the link itself. The tags std does not classify as
|
|
|
|
|
/// symlinks — `IO_REPARSE_TAG_APPEXECLINK`, the OneDrive
|
|
|
|
|
/// `IO_REPARSE_TAG_CLOUD_*` family — reach the walk's ordinary file arm, and
|
|
|
|
|
/// for those the two answers differ: an AppExecLink fails `CreateFileW` with
|
|
|
|
|
/// `ERROR_CANT_ACCESS_FILE` and is skipped, while its directory entry looks
|
|
|
|
|
/// like an ordinary empty file. Tested against the raw
|
|
|
|
|
/// `FILE_ATTRIBUTE_REPARSE_POINT` bit, not `is_symlink`, which additionally
|
|
|
|
|
/// requires the name-surrogate bit `0x20000000` — precisely the test that
|
|
|
|
|
/// lets AppExecLink and the cloud tags through.
|
2026-08-05 18:05:04 -04:00
|
|
|
pub(crate) fn entry_cached_metadata<F>(meta: F) -> Option<CachedMetadata>
|
|
|
|
|
where
|
|
|
|
|
F: FnOnce() -> Option<std::fs::Metadata>,
|
|
|
|
|
{
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::windows::fs::MetadataExt;
|
|
|
|
|
if let Some(m) = meta().filter(|m| !attributes_are_reparse_point(m.file_attributes())) {
|
|
|
|
|
return Some(m);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
{
|
|
|
|
|
let _ = meta;
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A file's `std::fs::Metadata`, from the directory read where that read
|
2026-08-09 16:25:43 -04:00
|
|
|
/// supplied it and from a `stat` where it did not. Off Windows `cached` is
|
|
|
|
|
/// uninhabited and therefore provably `None`.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[cfg(windows)]
|
|
|
|
|
pub(crate) fn metadata_or_stat(
|
|
|
|
|
path: &Path,
|
|
|
|
|
cached: Option<CachedMetadata>,
|
|
|
|
|
) -> std::io::Result<std::fs::Metadata> {
|
|
|
|
|
match cached {
|
|
|
|
|
Some(m) => Ok(m),
|
|
|
|
|
None => std::fs::metadata(path),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
pub(crate) fn metadata_or_stat(
|
|
|
|
|
path: &Path,
|
|
|
|
|
_cached: Option<CachedMetadata>,
|
|
|
|
|
) -> std::io::Result<std::fs::Metadata> {
|
|
|
|
|
std::fs::metadata(path)
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether `path` has a hidden component *below* the root that contains it.
|
|
|
|
|
///
|
|
|
|
|
/// Components at or above a root are exempt, because the walkers exempt their
|
2026-08-09 16:25:43 -04:00
|
|
|
/// root too. The two must agree, or a full run indexes a file the watcher
|
|
|
|
|
/// then refuses to update and the index churns — a certainty on Windows,
|
|
|
|
|
/// where `AppData` carries `FILE_ATTRIBUTE_HIDDEN` and roots under it are
|
|
|
|
|
/// routine.
|
2026-08-02 19:04:30 -04:00
|
|
|
///
|
|
|
|
|
/// `roots` are matched by whole path components, so `/a/bc` is not treated as
|
|
|
|
|
/// living under `/a/b`. A path under no known root is checked in full.
|
|
|
|
|
pub fn path_has_hidden_component_under(path: &Path, roots: &[PathBuf]) -> bool {
|
|
|
|
|
// Innermost containing root wins: with both `/data` and `/data/.cache`
|
|
|
|
|
// configured, a file under the latter is only judged below `.cache`.
|
|
|
|
|
let base = roots
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|r| path.starts_with(r))
|
|
|
|
|
.max_by_key(|r| r.components().count());
|
|
|
|
|
|
|
|
|
|
let (mut current, tail) = match base {
|
|
|
|
|
Some(root) => match path.strip_prefix(root) {
|
|
|
|
|
Ok(tail) => (root.clone(), tail),
|
|
|
|
|
Err(_) => (PathBuf::new(), path),
|
|
|
|
|
},
|
|
|
|
|
None => (PathBuf::new(), path),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Rebuild the absolute path as we descend: a bare tail component cannot
|
|
|
|
|
// be stat'd on its own, and the attribute check needs a real path.
|
|
|
|
|
for component in tail.components() {
|
|
|
|
|
current.push(component);
|
|
|
|
|
if let Component::Normal(name) = component {
|
|
|
|
|
let name = name.to_string_lossy();
|
2026-08-05 18:05:04 -04:00
|
|
|
// `symlink_metadata`, not `metadata`: each component is judged as
|
|
|
|
|
// itself, which is what the walkers do. Only the final component
|
|
|
|
|
// stops being followed, so intermediate ones still resolve
|
|
|
|
|
// normally. See `entry_hidden_reason`.
|
|
|
|
|
if entry_is_hidden(&name, || std::fs::symlink_metadata(¤t).ok()) {
|
2026-08-02 19:04:30 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether `s` names a UNC path, in either spelling.
|
|
|
|
|
///
|
|
|
|
|
/// Split out from [`is_network_path`] so the string half is testable on every
|
2026-08-09 16:25:43 -04:00
|
|
|
/// platform; only *called* on Windows.
|
2026-08-02 19:04:30 -04:00
|
|
|
#[cfg_attr(not(windows), allow(dead_code))]
|
|
|
|
|
pub(crate) fn is_unc_string(s: &str) -> bool {
|
|
|
|
|
s.starts_with(r"\\?\UNC\") || (s.starts_with(r"\\") && !s.starts_with(r"\\?\"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Filesystem types whose operations are network round trips.
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
const NETWORK_FS_TYPES: [&str; 8] = [
|
2026-08-04 03:27:05 -04:00
|
|
|
"cifs",
|
|
|
|
|
"smb3",
|
|
|
|
|
"smbfs",
|
|
|
|
|
"nfs",
|
|
|
|
|
"nfs4",
|
|
|
|
|
"afs",
|
|
|
|
|
"fuse.sshfs",
|
|
|
|
|
"9p",
|
2026-08-02 19:04:30 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// Whether `path` lives on a network filesystem.
|
|
|
|
|
///
|
|
|
|
|
/// Reads `/proc/mounts` and takes the longest mount point that is a prefix of
|
|
|
|
|
/// `path` — the innermost mount is the one that actually serves it.
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
pub(crate) fn is_network_path(path: &Path) -> bool {
|
|
|
|
|
let Ok(mounts) = std::fs::read_to_string("/proc/mounts") else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
|
|
|
|
|
|
|
|
|
let mut best: Option<(usize, bool)> = None;
|
|
|
|
|
for line in mounts.lines() {
|
|
|
|
|
let mut fields = line.split_whitespace();
|
|
|
|
|
let (Some(_dev), Some(point), Some(fstype)) = (fields.next(), fields.next(), fields.next())
|
|
|
|
|
else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
// `/proc/mounts` octal-escapes spaces and a few other characters.
|
|
|
|
|
let point = point.replace("\\040", " ");
|
|
|
|
|
let point = Path::new(&point);
|
|
|
|
|
if !target.starts_with(point) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let depth = point.components().count();
|
|
|
|
|
let is_network = NETWORK_FS_TYPES.contains(&fstype);
|
|
|
|
|
if best.is_none_or(|(d, _)| depth > d) {
|
|
|
|
|
best = Some((depth, is_network));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
best.is_some_and(|(_, is_network)| is_network)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether `path` is served by a network redirector.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// UNC needs no syscall. A *mapped drive letter* is indistinguishable from a
|
|
|
|
|
/// local disk by string inspection — `GetDriveTypeW` is the only way to tell.
|
|
|
|
|
/// Left undetected it walks with `LOCAL_THREADS` instead of
|
|
|
|
|
/// `NETWORK_THREADS`.
|
2026-08-02 19:04:30 -04:00
|
|
|
#[cfg(windows)]
|
|
|
|
|
pub(crate) fn is_network_path(path: &Path) -> bool {
|
|
|
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
|
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
|
|
|
|
|
use windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE;
|
|
|
|
|
|
|
|
|
|
let s = path.to_string_lossy();
|
|
|
|
|
if is_unc_string(&s) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetDriveTypeW wants a root ("Z:\"), not an arbitrary path.
|
|
|
|
|
let Some(root) = path.components().next() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let Component::Prefix(prefix) = root else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let mut wide: Vec<u16> = prefix.as_os_str().encode_wide().collect();
|
|
|
|
|
wide.push(b'\\' as u16);
|
|
|
|
|
wide.push(0);
|
|
|
|
|
unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_REMOTE }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(any(target_os = "linux", windows)))]
|
|
|
|
|
pub(crate) fn is_network_path(_path: &Path) -> bool {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether the filesystem-notification backend covers a whole tree from one
|
|
|
|
|
/// watch on its root.
|
|
|
|
|
///
|
|
|
|
|
/// `false` (inotify): one watch descriptor covers exactly one directory's
|
|
|
|
|
/// entries, so the caller must walk the tree and register every directory
|
2026-08-09 16:25:43 -04:00
|
|
|
/// itself — which is what lets it skip ignored and hidden subtrees.
|
2026-08-02 19:04:30 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `true` (`ReadDirectoryChangesW`): one handle covers the subtree,
|
|
|
|
|
/// directories created later included. notify allocates a 16 KiB buffer
|
|
|
|
|
/// *inline per watch* plus a directory handle, so per-directory registration
|
|
|
|
|
/// here would ask for gigabytes on a large tree; pruning moves to the event
|
|
|
|
|
/// path instead.
|
2026-08-02 19:04:30 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// macOS FSEvents is also natively recursive, but stays on the per-directory
|
|
|
|
|
/// path, which works there and is the one under test.
|
2026-08-02 19:04:30 -04:00
|
|
|
pub const WATCH_ROOTS_RECURSIVELY: bool = cfg!(windows);
|
|
|
|
|
|
|
|
|
|
/// SQLite collation for comparing stored path strings.
|
|
|
|
|
///
|
|
|
|
|
/// Windows filesystems are case-insensitive, and SQLite's `LIKE` already folds
|
|
|
|
|
/// ASCII case by default. A path filter that compares one half with `=` and the
|
|
|
|
|
/// other with `LIKE` would otherwise disagree with itself. `NOCASE` folds ASCII
|
|
|
|
|
/// only, which matches what `LIKE` does — non-ASCII paths stay case-sensitive
|
|
|
|
|
/// on both sides, consistently.
|
2026-08-20 02:34:08 -04:00
|
|
|
///
|
|
|
|
|
/// **No query interpolates this any more.** The folder filter was its last
|
|
|
|
|
/// caller, and it now needs one `LIKE` where it used to need `= COLLATE … OR
|
|
|
|
|
/// LIKE`. What is left is a *specification*: this constant and
|
|
|
|
|
/// `tests::collation_matches_like_case_folding` are where the rule is written
|
|
|
|
|
/// down, and the places that fold paths by hand —
|
|
|
|
|
/// [`PATHS_ARE_CASE_INSENSITIVE`], [`crate::config::IgnoreSet`],
|
|
|
|
|
/// [`crate::config::Config::is_index_file`] — are required to agree with it.
|
|
|
|
|
/// Deleting it would leave that agreement asserted nowhere.
|
2026-08-02 19:04:30 -04:00
|
|
|
pub const PATH_COLLATION: &str = if cfg!(windows) { "NOCASE" } else { "BINARY" };
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Whether this platform's filesystem matches names without regard to case —
|
|
|
|
|
/// what ignore patterns compile against: on Windows and macOS `node_modules`
|
|
|
|
|
/// has to exclude `Node_Modules`, and on Linux it must not.
|
2026-08-05 18:05:04 -04:00
|
|
|
pub const PATHS_ARE_CASE_INSENSITIVE: bool = cfg!(any(windows, target_os = "macos"));
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
/// Drop the **calling thread** to background scheduling priority.
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Per-thread, not per-process: the GUI shares this process. Best-effort and
|
|
|
|
|
/// idempotent.
|
2026-08-05 18:05:04 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// **CPU only, on every platform.** Windows' `THREAD_MODE_BACKGROUND_BEGIN`
|
|
|
|
|
/// is not used: it drops the thread to `IoPriorityVeryLow`, a tier the kernel
|
|
|
|
|
/// actively rate-limits — the same one SuperFetch and defrag run in — while
|
|
|
|
|
/// Linux's `nice` has no I/O half at all, so the two platforms would not be
|
|
|
|
|
/// doing remotely the same thing.
|
2026-08-03 03:06:19 -04:00
|
|
|
pub fn set_background_priority() {
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
{
|
|
|
|
|
// Linux schedules per task, so `nice` moves this thread alone.
|
|
|
|
|
unsafe { libc::nice(10) };
|
|
|
|
|
}
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
use windows_sys::Win32::System::Threading::{
|
2026-08-05 18:05:04 -04:00
|
|
|
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_BELOW_NORMAL,
|
2026-08-03 03:06:19 -04:00
|
|
|
};
|
2026-08-09 16:25:43 -04:00
|
|
|
// CPU only; see the note above for why this is not
|
|
|
|
|
// `THREAD_MODE_BACKGROUND_BEGIN`.
|
2026-08-05 18:05:04 -04:00
|
|
|
unsafe { SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL) };
|
2026-08-03 03:06:19 -04:00
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Elsewhere (macOS, BSD): nothing — `nice` there applies to the whole
|
|
|
|
|
// process and would hit the GUI.
|
2026-08-03 03:06:19 -04:00
|
|
|
}
|
2026-08-05 19:17:11 -04:00
|
|
|
|
|
|
|
|
/// Stack size for the per-run worker threads spawned by [`spawn_worker`].
|
|
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// glibc **caches freed thread stacks** rather than unmapping them
|
|
|
|
|
/// (`stack_cache_maxsize`, 40 MiB by default), and a cached stack keeps its
|
|
|
|
|
/// dirty pages — so pages a short-lived worker touched outlive it for the
|
|
|
|
|
/// life of the process. 512 KiB is ample for these threads' bounded loops.
|
|
|
|
|
/// Document parsing recurses on untrusted input; if a malformed document ever
|
|
|
|
|
/// overflows this, raise it and give the parser a depth limit.
|
2026-08-05 19:17:11 -04:00
|
|
|
const WORKER_STACK_SIZE: usize = 512 * 1024;
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Spawn one of a run's short-lived worker threads, named and with
|
|
|
|
|
/// [`WORKER_STACK_SIZE`].
|
2026-08-05 19:17:11 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Panics if the thread cannot be spawned, exactly as `thread::spawn` does.
|
2026-08-05 19:17:11 -04:00
|
|
|
pub fn spawn_worker<F, T>(name: &str, f: F) -> std::thread::JoinHandle<T>
|
|
|
|
|
where
|
|
|
|
|
F: FnOnce() -> T + Send + 'static,
|
|
|
|
|
T: Send + 'static,
|
|
|
|
|
{
|
|
|
|
|
std::thread::Builder::new()
|
|
|
|
|
.name(name.to_string())
|
|
|
|
|
.stack_size(WORKER_STACK_SIZE)
|
|
|
|
|
.spawn(f)
|
|
|
|
|
.expect("spawn worker thread")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return free heap pages to the kernel.
|
|
|
|
|
///
|
|
|
|
|
/// glibc's `free` returns a chunk to its arena's free list, not to the OS —
|
2026-08-09 16:25:43 -04:00
|
|
|
/// only the top of an arena is ever trimmed, and only past
|
|
|
|
|
/// `M_TRIM_THRESHOLD` — so a transient peak stays in RSS for the life of the
|
|
|
|
|
/// process.
|
2026-08-05 19:17:11 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// `malloc_trim(0)` walks *every* arena, so one call from the coordinator
|
|
|
|
|
/// also reclaims what other threads left behind. It costs milliseconds on a
|
|
|
|
|
/// large heap, so it must not go anywhere hot. Best-effort and idempotent.
|
2026-08-05 19:17:11 -04:00
|
|
|
pub fn release_free_heap() {
|
|
|
|
|
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
|
|
|
|
{
|
|
|
|
|
// SAFETY: no arguments, no pointers, and safe to call from any thread
|
|
|
|
|
// at any time — glibc takes the arena locks itself.
|
|
|
|
|
unsafe { libc::malloc_trim(0) };
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Elsewhere: nothing. `malloc_trim` is a glibc extension; musl returns
|
|
|
|
|
// spans to the kernel on free.
|
2026-08-05 19:17:11 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-20 00:13:10 -04:00
|
|
|
/// Create `dir` and its parents, readable only by their owner.
|
|
|
|
|
///
|
|
|
|
|
/// `create_dir_all` leaves the mode to the umask, which is 022 nearly
|
|
|
|
|
/// everywhere and so grants the world `+rx`. The two directories this is
|
|
|
|
|
/// called for hold the index and the config, i.e. the names and text of
|
|
|
|
|
/// everything under the configured roots, plus the salt.
|
|
|
|
|
///
|
|
|
|
|
/// Only directories *created here* are narrowed: an existing one keeps its
|
|
|
|
|
/// mode, because a user who chose `~/Documents` as their data directory did
|
|
|
|
|
/// not ask for it to be locked down.
|
|
|
|
|
pub fn create_dir_private(dir: &Path) -> std::io::Result<()> {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::DirBuilderExt;
|
|
|
|
|
std::fs::DirBuilder::new()
|
|
|
|
|
.recursive(true)
|
|
|
|
|
.mode(0o700)
|
|
|
|
|
.create(dir)
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
{
|
|
|
|
|
// Windows: a file created under the user's profile inherits an ACL
|
|
|
|
|
// that already excludes other users.
|
|
|
|
|
std::fs::create_dir_all(dir)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Narrow `path` to owner-only access, best effort.
|
|
|
|
|
///
|
|
|
|
|
/// SQLite creates its database 0644 (`SQLITE_DEFAULT_FILE_PERMISSIONS`, which
|
|
|
|
|
/// the bundled build does not override) and then copies that mode to the
|
|
|
|
|
/// `-wal` and `-shm` it derives from it, so narrowing the main file before
|
|
|
|
|
/// anything else is opened covers all three. The index is a strictly larger
|
|
|
|
|
/// secret than any single file it was read from: it holds the full text of
|
|
|
|
|
/// documents whose own permissions may be far tighter.
|
|
|
|
|
///
|
|
|
|
|
/// Failure is ignored: on a filesystem with no Unix permissions (a FAT stick,
|
|
|
|
|
/// a network share) there is nothing to set and nothing to report.
|
|
|
|
|
pub fn restrict_to_owner(path: &Path) {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
{
|
|
|
|
|
let _ = path;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Open `path` for reading, refusing anything that is not a regular file.
|
|
|
|
|
///
|
|
|
|
|
/// Every caller has already decided the path *was* a regular file, from a
|
|
|
|
|
/// `stat` taken earlier — during the walk, or when a result row was put on
|
|
|
|
|
/// screen. A rename can put a FIFO, a tty or a character device at that name
|
|
|
|
|
/// in between, and `open` on one of those blocks in the kernel until a writer
|
|
|
|
|
/// or a carrier appears: uninterruptibly, past any stop flag, and for as long
|
|
|
|
|
/// as the process lives. One such open strands a walk worker while it still
|
|
|
|
|
/// holds its job slot, which parks the whole pool behind it.
|
|
|
|
|
///
|
|
|
|
|
/// `O_NONBLOCK` makes the open itself return, and the handle is then asked
|
|
|
|
|
/// what it actually is — `fstat` on the descriptor, so nothing can swap the
|
|
|
|
|
/// name again underneath the answer. Both are free on a regular file, which
|
|
|
|
|
/// is the only case that matters for speed: the flag is ignored for ordinary
|
|
|
|
|
/// files and the `fstat` hits the inode already in hand.
|
|
|
|
|
pub fn open_regular_file(path: &Path) -> std::io::Result<std::fs::File> {
|
|
|
|
|
let mut opts = std::fs::OpenOptions::new();
|
|
|
|
|
opts.read(true);
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
|
opts.custom_flags(libc::O_NONBLOCK);
|
|
|
|
|
}
|
|
|
|
|
let file = opts.open(path)?;
|
|
|
|
|
if !file.metadata()?.file_type().is_file() {
|
|
|
|
|
return Err(std::io::Error::new(
|
|
|
|
|
std::io::ErrorKind::InvalidInput,
|
|
|
|
|
"not a regular file",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 02:34:08 -04:00
|
|
|
/// Why [`IndexLock::acquire`] did not hand back a lock.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum LockError {
|
|
|
|
|
/// Another process holds it. `pid` is whatever the holder recorded in the
|
|
|
|
|
/// file and is for the message only — it is never what decides.
|
|
|
|
|
Held { pid: Option<u32> },
|
|
|
|
|
/// The filesystem does not do locks. The caller must carry on regardless;
|
|
|
|
|
/// see [`IndexLock::acquire`].
|
|
|
|
|
Unsupported(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Proof that this process, and no other, owns the index at a given path.
|
|
|
|
|
///
|
|
|
|
|
/// Held for the life of the process: dropping it, or exiting, releases the
|
|
|
|
|
/// lock.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct IndexLock {
|
|
|
|
|
/// The lock lives on this open file description. Keeping the handle alive
|
|
|
|
|
/// is the whole mechanism, which is why the field is never read.
|
|
|
|
|
_file: std::fs::File,
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The lock this process is holding, if any.
|
|
|
|
|
///
|
|
|
|
|
/// A `static` because that is what the thing models: `flock` and `LockFileEx`
|
|
|
|
|
/// are owned by an open file description, one index is locked per process, and
|
|
|
|
|
/// what releases it is the process ending. Threading an `Arc<Mutex<_>>` from
|
|
|
|
|
/// `main` down through the unlock gate and into the settings handler would say
|
|
|
|
|
/// the same thing in three more places.
|
|
|
|
|
///
|
|
|
|
|
/// Never dropped — statics are not — and that costs nothing here: the kernel
|
|
|
|
|
/// releases the lock however the process goes away, which is the whole design.
|
|
|
|
|
static HELD_LOCK: std::sync::Mutex<Option<IndexLock>> = std::sync::Mutex::new(None);
|
|
|
|
|
|
|
|
|
|
impl IndexLock {
|
|
|
|
|
/// The lock file for the index at `db_path`.
|
|
|
|
|
///
|
|
|
|
|
/// Its own name, never the database or one of SQLite's sidecars: a
|
|
|
|
|
/// `flock` of ours on an inode SQLite also locks would be a second
|
|
|
|
|
/// locking protocol on one file, and on Unix our `close` of it would
|
|
|
|
|
/// cancel SQLite's locks — the very failure this whole change exists to
|
|
|
|
|
/// prevent.
|
|
|
|
|
pub fn path_for(db_path: &Path) -> PathBuf {
|
|
|
|
|
let name = db_path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.unwrap_or("index.sqlite");
|
|
|
|
|
db_path.with_file_name(format!("{}.lock", name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Take the index lock, or report who has it.
|
|
|
|
|
///
|
|
|
|
|
/// **The guard is the kernel's lock, never the file's existence.** `flock`
|
|
|
|
|
/// and `LockFileEx` are held by the open file description, so the kernel
|
|
|
|
|
/// drops them when the process goes away for *any* reason — a clean exit,
|
|
|
|
|
/// a panic, SIGKILL, the OOM killer, a SIGBUS, or the power going out. A
|
|
|
|
|
/// `.lock` file left behind by an unclean shutdown is therefore inert, and
|
|
|
|
|
/// the next start re-acquires it normally. Nothing here may ever branch on
|
|
|
|
|
/// the file being present; a stale-PID-file scheme would strand the user
|
|
|
|
|
/// behind a crash exactly when they most want to reopen the app.
|
|
|
|
|
///
|
|
|
|
|
/// [`LockError::Unsupported`] means the filesystem could not answer, not
|
|
|
|
|
/// that the lock is taken — NFS without `lockd`, some FUSE mounts, a few
|
|
|
|
|
/// network shares. Callers **must** start anyway: a convenience guard is
|
|
|
|
|
/// never a good enough reason to refuse to open.
|
|
|
|
|
pub fn acquire(db_path: &Path) -> Result<IndexLock, LockError> {
|
|
|
|
|
let path = IndexLock::path_for(db_path);
|
|
|
|
|
if let Some(dir) = path.parent() {
|
|
|
|
|
if !dir.as_os_str().is_empty() {
|
|
|
|
|
let _ = create_dir_private(dir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let file = match std::fs::OpenOptions::new()
|
|
|
|
|
.read(true)
|
|
|
|
|
.write(true)
|
|
|
|
|
.create(true)
|
|
|
|
|
.truncate(false)
|
|
|
|
|
.open(&path)
|
|
|
|
|
{
|
|
|
|
|
Ok(f) => f,
|
|
|
|
|
// A read-only or unwritable index directory is not a second
|
|
|
|
|
// instance. Same rule as an unsupported filesystem: carry on.
|
|
|
|
|
Err(e) => return Err(LockError::Unsupported(format!("{}: {}", path.display(), e))),
|
|
|
|
|
};
|
|
|
|
|
lock_exclusive_nonblocking(&file).map_err(|e| match e {
|
|
|
|
|
LockAttempt::Held => LockError::Held {
|
|
|
|
|
pid: read_recorded_pid(&path),
|
|
|
|
|
},
|
|
|
|
|
LockAttempt::Unsupported(msg) => LockError::Unsupported(msg),
|
|
|
|
|
})?;
|
|
|
|
|
let lock = IndexLock {
|
|
|
|
|
_file: file,
|
|
|
|
|
path: path.clone(),
|
|
|
|
|
};
|
|
|
|
|
// Only after the lock is ours, and only so a second instance can name
|
|
|
|
|
// us in its message. Nothing reads this to make a decision.
|
|
|
|
|
lock.record_holder();
|
|
|
|
|
Ok(lock)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [`IndexLock::acquire`], keeping the lock in [`HELD_LOCK`] for the life
|
|
|
|
|
/// of the process. What a frontend calls at startup.
|
|
|
|
|
pub fn hold(db_path: &Path) -> Result<(), LockError> {
|
|
|
|
|
let lock = IndexLock::acquire(db_path)?;
|
|
|
|
|
*crate::lock_ok(&HELD_LOCK) = Some(lock);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Move the held lock onto the index at `db_path`, for a `database_path`
|
|
|
|
|
/// that changed while the app was running.
|
|
|
|
|
///
|
|
|
|
|
/// **The new lock is taken before the old one is let go.** A refusal then
|
|
|
|
|
/// leaves this process holding exactly what it held before, so the caller
|
|
|
|
|
/// can reject the change and stay in a state that is still true. Releasing
|
|
|
|
|
/// first would leave a window in which another instance could claim the
|
|
|
|
|
/// index we are about to keep using.
|
|
|
|
|
///
|
|
|
|
|
/// Naming the index already locked is [`LockError::Held`] and changes
|
|
|
|
|
/// nothing. [`LockError::Unsupported`] means the move *happened* — the old
|
|
|
|
|
/// lock is gone — and the new path simply cannot be locked; carry on, as at
|
|
|
|
|
/// startup. Holding the old path's lock in that case would be worse than
|
|
|
|
|
/// holding none: it guards a file this process no longer uses while barring
|
|
|
|
|
/// a second instance from it.
|
|
|
|
|
pub fn move_to(db_path: &Path) -> Result<(), LockError> {
|
|
|
|
|
let mut slot = crate::lock_ok(&HELD_LOCK);
|
|
|
|
|
// Short-circuit before acquiring: `flock` conflicts with itself across
|
|
|
|
|
// two open file descriptions even inside one process, so re-taking the
|
|
|
|
|
// path we already hold would report itself as `Held`.
|
|
|
|
|
if slot
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|held| held.path == IndexLock::path_for(db_path))
|
|
|
|
|
{
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
match IndexLock::acquire(db_path) {
|
|
|
|
|
Ok(lock) => {
|
|
|
|
|
*slot = Some(lock);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Err(LockError::Unsupported(why)) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
Err(LockError::Unsupported(why))
|
|
|
|
|
}
|
|
|
|
|
Err(e) => Err(e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn record_holder(&self) {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
// A fresh handle: writing through `_file` would move the shared file
|
|
|
|
|
// offset the lock has nothing to do with, and the failure here is
|
|
|
|
|
// cosmetic either way. That a *second* handle can write here at all is
|
|
|
|
|
// why the Windows lock byte sits at [`LOCK_BYTE_OFFSET`] rather than at
|
|
|
|
|
// offset 0 — its byte-range locks are mandatory and per-handle, so a
|
|
|
|
|
// lock over this byte would block our own write.
|
|
|
|
|
let written = std::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.truncate(true)
|
|
|
|
|
.open(&self.path)
|
|
|
|
|
.and_then(|mut f| write!(f, "{}", std::process::id()));
|
|
|
|
|
if let Err(e) = written {
|
|
|
|
|
crate::log_warn!(
|
|
|
|
|
"could not record the lock holder in {}: {}",
|
|
|
|
|
self.path.display(),
|
|
|
|
|
e
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The PID a holder recorded, for a message. `None` whenever the file is
|
|
|
|
|
/// absent, empty or unparseable — all of which are ordinary.
|
|
|
|
|
fn read_recorded_pid(path: &Path) -> Option<u32> {
|
|
|
|
|
std::fs::read_to_string(path).ok()?.trim().parse().ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What one attempt at the lock found.
|
|
|
|
|
enum LockAttempt {
|
|
|
|
|
Held,
|
|
|
|
|
Unsupported(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Where the Windows lock byte lives, chosen to be nowhere near the PID the
|
|
|
|
|
/// lock file holds at offset 0. See [`lock_exclusive_nonblocking`]'s Windows
|
|
|
|
|
/// arm; the Unix arm has no equivalent, because `flock` locks the open file
|
|
|
|
|
/// description rather than a byte range.
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
const LOCK_BYTE_OFFSET: u64 = 1 << 63;
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
fn lock_exclusive_nonblocking(file: &std::fs::File) -> Result<(), LockAttempt> {
|
|
|
|
|
use std::os::unix::io::AsRawFd;
|
|
|
|
|
|
|
|
|
|
// `flock`, not `fcntl`: a POSIX record lock would be cancelled by any
|
|
|
|
|
// `close` this process makes on the same inode, which is the hazard
|
|
|
|
|
// documented in `file_handling::index_file_set`. A `flock` belongs to the
|
|
|
|
|
// open file description and is immune to it.
|
|
|
|
|
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
|
|
|
if rc == 0 {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let err = std::io::Error::last_os_error();
|
|
|
|
|
match err.raw_os_error() {
|
|
|
|
|
Some(libc::EWOULDBLOCK) => Err(LockAttempt::Held),
|
|
|
|
|
_ => Err(LockAttempt::Unsupported(err.to_string())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
fn lock_exclusive_nonblocking(file: &std::fs::File) -> Result<(), LockAttempt> {
|
|
|
|
|
use std::os::windows::io::AsRawHandle;
|
|
|
|
|
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
|
|
|
|
|
use windows_sys::Win32::Storage::FileSystem::{
|
|
|
|
|
LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
|
|
|
|
|
};
|
|
|
|
|
use windows_sys::Win32::System::IO::{OVERLAPPED, OVERLAPPED_0, OVERLAPPED_0_0};
|
|
|
|
|
|
|
|
|
|
// The locked byte sits at [`LOCK_BYTE_OFFSET`], far past anything this
|
|
|
|
|
// file will ever hold — **not** at offset 0, where the PID is written.
|
|
|
|
|
// Windows byte-range locks are *mandatory* and belong to the file object,
|
|
|
|
|
// not the process: a lock covering offset 0 makes our own
|
|
|
|
|
// `record_holder`, which writes through a second handle, fail with
|
|
|
|
|
// `ERROR_LOCK_VIOLATION`, and `read_recorded_pid` fail the same way — so
|
|
|
|
|
// the "already running" message would lose the PID on the one platform
|
|
|
|
|
// that has no terminal to print it to. Locking beyond end-of-file is
|
|
|
|
|
// explicitly legal and is the conventional way to use a file as a
|
|
|
|
|
// semaphore.
|
|
|
|
|
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
|
|
|
|
|
overlapped.Anonymous = OVERLAPPED_0 {
|
|
|
|
|
Anonymous: OVERLAPPED_0_0 {
|
|
|
|
|
Offset: LOCK_BYTE_OFFSET as u32,
|
|
|
|
|
OffsetHigh: (LOCK_BYTE_OFFSET >> 32) as u32,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
let ok = unsafe {
|
|
|
|
|
LockFileEx(
|
|
|
|
|
file.as_raw_handle() as _,
|
|
|
|
|
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
|
|
|
|
|
0,
|
|
|
|
|
1,
|
|
|
|
|
0,
|
|
|
|
|
&mut overlapped,
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
if ok != 0 {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let err = std::io::Error::last_os_error();
|
|
|
|
|
match err.raw_os_error() {
|
|
|
|
|
Some(code) if code == ERROR_LOCK_VIOLATION as i32 => Err(LockAttempt::Held),
|
|
|
|
|
_ => Err(LockAttempt::Unsupported(err.to_string())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bytes free to this user on the filesystem holding `path`, or `None` where
|
|
|
|
|
/// the platform will not say.
|
|
|
|
|
///
|
|
|
|
|
/// `None` is "unknown", never "zero": every caller treats it as no reason to
|
|
|
|
|
/// hold anything back.
|
|
|
|
|
///
|
|
|
|
|
/// The path need not exist — its nearest existing ancestor is what gets
|
|
|
|
|
/// asked, so this answers for a database that has not been created yet.
|
|
|
|
|
pub fn available_space(path: &Path) -> Option<u64> {
|
|
|
|
|
let mut probe = path;
|
|
|
|
|
loop {
|
|
|
|
|
if probe.exists() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
probe = probe.parent()?;
|
|
|
|
|
}
|
|
|
|
|
available_space_of_existing(probe)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
fn available_space_of_existing(path: &Path) -> Option<u64> {
|
|
|
|
|
use std::ffi::CString;
|
|
|
|
|
use std::os::unix::ffi::OsStrExt;
|
|
|
|
|
|
|
|
|
|
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
|
|
|
|
|
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
|
|
|
|
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
// `f_bavail`, not `f_bfree`: the reserved blocks in the difference are
|
|
|
|
|
// root's, and this process is not root. `f_frsize` is the fragment size
|
|
|
|
|
// the block counts are in — `f_bsize` is the preferred I/O size and is
|
|
|
|
|
// the wrong multiplier.
|
|
|
|
|
let frsize = if stat.f_frsize > 0 {
|
|
|
|
|
stat.f_frsize
|
|
|
|
|
} else {
|
|
|
|
|
stat.f_bsize
|
|
|
|
|
};
|
|
|
|
|
(stat.f_bavail as u64).checked_mul(frsize as u64)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
fn available_space_of_existing(path: &Path) -> Option<u64> {
|
|
|
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
|
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
|
|
|
|
|
|
|
|
|
let wide: Vec<u16> = path
|
|
|
|
|
.as_os_str()
|
|
|
|
|
.encode_wide()
|
|
|
|
|
.chain(std::iter::once(0))
|
|
|
|
|
.collect();
|
|
|
|
|
let mut free_to_caller: u64 = 0;
|
|
|
|
|
let ok = unsafe {
|
|
|
|
|
GetDiskFreeSpaceExW(
|
|
|
|
|
wide.as_ptr(),
|
|
|
|
|
&mut free_to_caller,
|
|
|
|
|
std::ptr::null_mut(),
|
|
|
|
|
std::ptr::null_mut(),
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
(ok != 0).then_some(free_to_caller)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
/// How long to keep retrying a delete that fails because something else holds
|
|
|
|
|
/// the file open.
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
const REMOVE_RETRY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
|
|
|
|
|
|
|
|
|
|
/// `fs::remove_file`, retried briefly on Windows.
|
|
|
|
|
///
|
|
|
|
|
/// Unix `unlink` succeeds even with the file open, so this is a single call
|
|
|
|
|
/// there. Windows returns a sharing violation while *any* handle is open —
|
|
|
|
|
/// most often an antivirus scanner reading the file microseconds after we
|
|
|
|
|
/// closed it. The retry turns a spurious hard failure into a short pause.
|
|
|
|
|
pub fn remove_file_retrying(path: &Path) -> std::io::Result<()> {
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
{
|
|
|
|
|
std::fs::remove_file(path)
|
|
|
|
|
}
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
let deadline = std::time::Instant::now() + REMOVE_RETRY_BUDGET;
|
|
|
|
|
loop {
|
|
|
|
|
match std::fs::remove_file(path) {
|
|
|
|
|
Ok(()) => return Ok(()),
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(e),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if std::time::Instant::now() >= deadline {
|
|
|
|
|
return Err(e);
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Deny read access to `dir`, for tests that exercise the unreadable-directory
|
2026-08-09 16:25:43 -04:00
|
|
|
/// guards. Exposed because `tests/full_index.rs` is a separate crate.
|
2026-08-02 19:04:30 -04:00
|
|
|
///
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Windows uses `icacls`: a deny ACE binds even the owner until the paired
|
2026-08-02 19:04:30 -04:00
|
|
|
/// [`restore_read`] rewrites it, and neither call needs elevation.
|
|
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn deny_read(dir: &Path) -> std::io::Result<()> {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o000))
|
|
|
|
|
}
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
2026-08-04 03:27:05 -04:00
|
|
|
icacls(
|
|
|
|
|
dir,
|
|
|
|
|
&["/deny", &format!("{}:(OI)(CI)(RD)", current_user()?)],
|
|
|
|
|
)
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
|
|
|
{
|
|
|
|
|
let _ = dir;
|
|
|
|
|
Err(std::io::Error::new(
|
|
|
|
|
std::io::ErrorKind::Unsupported,
|
|
|
|
|
"deny_read is not supported on this target",
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Undo [`deny_read`] so the directory can be cleaned up.
|
|
|
|
|
#[doc(hidden)]
|
|
|
|
|
pub fn restore_read(dir: &Path) -> std::io::Result<()> {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755))
|
|
|
|
|
}
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
{
|
|
|
|
|
icacls(dir, &["/remove:d", ¤t_user()?])
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
|
|
|
{
|
|
|
|
|
let _ = dir;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
fn current_user() -> std::io::Result<String> {
|
|
|
|
|
match (std::env::var("USERDOMAIN"), std::env::var("USERNAME")) {
|
|
|
|
|
(Ok(domain), Ok(user)) => Ok(format!("{}\\{}", domain, user)),
|
|
|
|
|
(_, Ok(user)) => Ok(user),
|
|
|
|
|
_ => Err(std::io::Error::other("USERNAME is not set")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
fn icacls(dir: &Path, args: &[&str]) -> std::io::Result<()> {
|
|
|
|
|
let out = std::process::Command::new("icacls")
|
|
|
|
|
.arg(dir)
|
|
|
|
|
.args(args)
|
|
|
|
|
.output()?;
|
|
|
|
|
if out.status.success() {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(std::io::Error::other(format!(
|
|
|
|
|
"icacls {}: {}",
|
|
|
|
|
dir.display(),
|
|
|
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
2026-08-09 16:25:43 -04:00
|
|
|
#[path = "platform_tests.rs"]
|
|
|
|
|
mod tests;
|