pub(super) mod probe;
pub mod restore;
pub(crate) mod scan;
pub mod stages;
#[allow(deprecated)]
pub use probe::leaked_probe_thread_count; pub use probe::probe_thread_failures;
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 use stages::{derive_warm_boot_stages, WarmBootInputs};
pub fn canonicalize_best_effort(path: &std::path::Path) -> PathBuf {
match std::fs::canonicalize(path) {
Ok(canonical) => canonical,
Err(e) => {
tracing::debug!(
"warm-boot: could not canonicalize root_path {}: {} (using stored path)",
path.display(),
e,
);
path.to_path_buf()
}
}
}
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 probe_warmboot_volumes_from_paths(paths: &[PathBuf]) -> HashSet<PathBuf> {
if paths.is_empty() {
return HashSet::new();
}
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>,
known_root_paths: &HashSet<PathBuf>,
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 \
(id already in registry)",
colocated.id,
colocated.root_path.display()
);
continue;
}
let canonical_colocated = canonicalize_best_effort(&colocated.root_path);
if known_root_paths.contains(&canonical_colocated) {
tracing::debug!(
"dual-discovery: colocated index '{}' at {} skipped \
(root_path already owned by a legacy entry, issue #860)",
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)]
#[path = "warm_boot_tests.rs"]
mod tests;