pub(super) mod probe;
pub mod restore;
mod scan;
pub use probe::leaked_probe_thread_count;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use crate::service::persistence::PersistedIndex;
pub use restore::restore_one_index_bounded;
pub const ROOT_SCAN_TIMEOUT: Duration = Duration::from_secs(10);
pub fn warmboot_index_timeout() -> Duration {
let secs = std::env::var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(ROOT_SCAN_TIMEOUT.as_secs());
Duration::from_secs(secs)
}
pub fn probe_warmboot_volumes(entries: &[PersistedIndex]) -> HashSet<PathBuf> {
if entries.is_empty() {
return HashSet::new();
}
let paths: Vec<PathBuf> = entries.iter().map(|e| e.root_path.clone()).collect();
let deadline = probe::volume_probe_timeout();
probe::probe_all_volumes(&paths, deadline)
}
pub fn is_on_inaccessible_volume(
root_path: &std::path::Path,
inaccessible_volumes: &HashSet<PathBuf>,
) -> bool {
let key = probe::volume_key(root_path);
inaccessible_volumes.contains(&key)
}
pub fn collect_legacy_entries() -> Vec<PersistedIndex> {
use crate::service::persistence::{data_dir, indexes_toml_path, load_index_registry};
match data_dir() {
Ok(ref d) => tracing::info!("warm-boot: data directory: {}", d.display()),
Err(ref e) => tracing::error!(
"warm-boot: FATAL — cannot resolve data directory; \
set TRUSTY_DATA_DIR in the launchd plist (issue #718). Error: {e}"
),
}
let path_hint = indexes_toml_path()
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<path unresolvable>".to_string());
match load_index_registry() {
Ok(entries) if entries.is_empty() => {
tracing::debug!("warm-boot: indexes.toml at {path_hint} — empty (first run)");
Vec::new()
}
Ok(entries) => {
tracing::info!(
"warm-boot: loaded {} legacy index(es) from {path_hint}",
entries.len()
);
entries
}
Err(e) => {
tracing::error!(
"warm-boot: FAILED reading indexes.toml at {path_hint}: {e}. \
Indexes MISSING on this boot. \
Set TRUSTY_DATA_DIR in the launchd/systemd unit (issue #718)."
);
Vec::new()
}
}
}
pub async fn collect_colocated_entries(
known_ids: &HashSet<String>,
inaccessible_volumes: &HashSet<PathBuf>,
) -> Vec<PersistedIndex> {
use crate::service::roots_registry::load_roots;
let tracked_roots: Vec<PathBuf> = match load_roots() {
Ok(r) => r.into_iter().map(|r| r.path).collect(),
Err(e) => {
tracing::error!(
"warm-boot: FAILED reading roots.toml: {e}. \
Colocated indexes not discovered on this boot (issue #718)."
);
return Vec::new();
}
};
if tracked_roots.is_empty() {
return Vec::new();
}
let (accessible_roots, pre_skipped): (Vec<PathBuf>, Vec<PathBuf>) = tracked_roots
.into_iter()
.partition(|r| !is_on_inaccessible_volume(r, inaccessible_volumes));
if !pre_skipped.is_empty() {
tracing::warn!(
"warm-boot: skipping {} colocated root(s) on inaccessible volumes (issue #723): {}",
pre_skipped.len(),
pre_skipped
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
if accessible_roots.is_empty() {
return Vec::new();
}
tracing::info!(
"warm-boot: scanning {} tracked root(s) for colocated indexes",
accessible_roots.len()
);
let timeout = warmboot_index_timeout();
let mut results: Vec<PersistedIndex> = Vec::new();
let mut seen_ids = known_ids.clone();
for root in accessible_roots {
let root_for_log = root.clone();
let root_for_task = root.clone();
let scan_future = tokio::task::spawn_blocking(move || scan::scan_one_root(&root_for_task));
match tokio::time::timeout(timeout, scan_future).await {
Ok(Ok(entries)) => {
for colocated in entries {
if seen_ids.contains(&colocated.id) {
tracing::debug!(
"dual-discovery: colocated index '{}' at {} skipped (already in registry)",
colocated.id,
colocated.root_path.display()
);
continue;
}
seen_ids.insert(colocated.id.clone());
results.push(PersistedIndex {
id: colocated.id,
root_path: colocated.root_path,
colocated: true,
..Default::default()
});
}
}
Ok(Err(join_err)) => {
tracing::warn!(
"warm-boot: colocated scan task panicked for root {}: {join_err}",
root_for_log.display()
);
}
Err(_elapsed) => {
let is_external = scan::is_likely_external_volume(&root_for_log);
if is_external {
tracing::warn!(
"warm-boot: colocated scan TIMED OUT for external-volume root {} \
(>{:.0}s, likely TCC/permission denial under launchd). \
HINT: grant Full Disk Access to the launchd agent in \
System Settings → Privacy & Security → Full Disk Access, \
or move the index off the external volume. \
Skipping this root — other roots still restored. (issue #718)",
root_for_log.display(),
timeout.as_secs_f32(),
);
} else {
tracing::warn!(
"warm-boot: colocated scan TIMED OUT for root {} \
(>{:.0}s). The root may be on a network or slow filesystem. \
Skipping this root — other roots still restored. (issue #718)",
root_for_log.display(),
timeout.as_secs_f32(),
);
}
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[serial_test::serial]
fn warmboot_index_timeout_parses_env_var() {
unsafe { std::env::set_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS", "42") };
assert_eq!(
warmboot_index_timeout(),
Duration::from_secs(42),
"must parse 42 from env var"
);
unsafe { std::env::remove_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS") };
assert_eq!(
warmboot_index_timeout(),
ROOT_SCAN_TIMEOUT,
"must fall back to ROOT_SCAN_TIMEOUT when env var is absent"
);
}
#[tokio::test]
#[serial_test::serial]
async fn colocated_scan_partial_failure_still_returns_accessible() {
let data_tmp = tempfile::tempdir().unwrap();
let real_root = tempfile::tempdir().unwrap();
let ts_dir = real_root.path().join(".trusty-search");
std::fs::create_dir_all(&ts_dir).unwrap();
unsafe {
std::env::set_var("TRUSTY_DATA_DIR", data_tmp.path());
}
let nonexistent = std::path::PathBuf::from("/tmp/trusty-718-no-root-xyz9999");
crate::service::roots_registry::upsert_root(real_root.path().to_path_buf()).unwrap();
crate::service::roots_registry::upsert_root(nonexistent).unwrap();
let known_ids: HashSet<String> = HashSet::new();
let inaccessible: HashSet<PathBuf> = HashSet::new();
let results = collect_colocated_entries(&known_ids, &inaccessible).await;
unsafe {
std::env::remove_var("TRUSTY_DATA_DIR");
}
assert_eq!(
results.len(),
1,
"accessible root must be discovered even when another root is inaccessible; \
got: {results:?}"
);
let canonical_root = real_root.path().canonicalize().unwrap();
assert_eq!(
results[0].root_path, canonical_root,
"discovered root_path must match the real tempdir"
);
}
#[tokio::test]
#[serial_test::serial]
async fn colocated_scan_deduplicates_against_known_ids() {
use crate::service::fs_discovery::id_from_path;
let data_tmp = tempfile::tempdir().unwrap();
let real_root = tempfile::tempdir().unwrap();
let ts_dir = real_root.path().join(".trusty-search");
std::fs::create_dir_all(&ts_dir).unwrap();
let canonical_root = real_root.path().canonicalize().unwrap();
let expected_id = id_from_path(&canonical_root);
unsafe {
std::env::set_var("TRUSTY_DATA_DIR", data_tmp.path());
}
crate::service::roots_registry::upsert_root(real_root.path().to_path_buf()).unwrap();
let mut known_ids: HashSet<String> = HashSet::new();
known_ids.insert(expected_id.clone());
let inaccessible: HashSet<PathBuf> = HashSet::new();
let results = collect_colocated_entries(&known_ids, &inaccessible).await;
unsafe {
std::env::remove_var("TRUSTY_DATA_DIR");
}
assert!(
results.is_empty(),
"index already in known_ids must not be returned again; got: {results:?}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn colocated_scan_skips_inaccessible_volume_roots() {
use crate::service::fs_discovery::id_from_path;
let data_tmp = tempfile::tempdir().unwrap();
let real_root = tempfile::tempdir().unwrap();
let ts_dir = real_root.path().join(".trusty-search");
std::fs::create_dir_all(&ts_dir).unwrap();
let canonical_root = real_root.path().canonicalize().unwrap();
let real_id = id_from_path(&canonical_root);
let fake_blocked = PathBuf::from("/Volumes/BLOCKED/some-project");
unsafe {
std::env::set_var("TRUSTY_DATA_DIR", data_tmp.path());
}
crate::service::roots_registry::upsert_root(real_root.path().to_path_buf()).unwrap();
crate::service::roots_registry::upsert_root(fake_blocked.clone()).unwrap();
let known_ids: HashSet<String> = HashSet::new();
let mut inaccessible: HashSet<PathBuf> = HashSet::new();
inaccessible.insert(PathBuf::from("/Volumes/BLOCKED"));
let results = collect_colocated_entries(&known_ids, &inaccessible).await;
unsafe {
std::env::remove_var("TRUSTY_DATA_DIR");
}
assert_eq!(
results.len(),
1,
"only the accessible root must be returned; got: {results:?}"
);
assert_eq!(
results[0].id, real_id,
"the returned entry must be the real root, not the blocked one"
);
}
}