Ship floating launcher, automatic discovery and bounded media search improvements

This commit is contained in:
karl committed 2026-10-14 11:44:15 +02:00
1 parent 01279a6b25
commit 521966a2ac
43 files changed
+3165 -267

No files matched your search

+142 -5
View File
@@ -645,6 +645,7 @@ impl SearchIndex {
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => break,
Err(error) => return Err(error).context("Inspect targeted path ancestor"),
}
}
@@ -684,6 +685,11 @@ impl SearchIndex {
check_index_cancelled(cancelled)?;
match fs::symlink_metadata(scope) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
preserve_unreadable_scope(&transaction, &root_key, scope)?;
report.skipped += 1;
continue;
}
Err(error) => return Err(error).context("Inspect reconciliation scope"),
Ok(metadata) if metadata.file_type().is_symlink() => {
bail!("Reconciliation scope became a symlink")
@@ -731,8 +737,26 @@ impl SearchIndex {
})
{
check_index_cancelled(cancelled)?;
let entry =
entry.context("directory traversal failed; reconciliation rolled back")?;
let entry = match entry {
Ok(entry) => entry,
Err(error)
if error.io_error().is_some_and(|error| {
error.kind() == std::io::ErrorKind::PermissionDenied
}) && error.path().is_some() =>
{
preserve_unreadable_scope(
&transaction,
&root_key,
error.path().expect("Checked traversal error path"),
)?;
report.skipped += 1;
continue;
}
Err(error) => {
return Err(error)
.context("directory traversal failed; reconciliation rolled back")
}
};
if !entry.file_type().is_file() {
continue;
}
@@ -751,7 +775,18 @@ impl SearchIndex {
let filename = entry.file_name().to_string_lossy().into_owned();
transaction.execute("INSERT INTO seen_paths(path) VALUES (?1)", [&path])?;
report.discovered += 1;
let metadata = entry.metadata()?;
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(error)
if error.io_error().is_some_and(|error| {
error.kind() == std::io::ErrorKind::PermissionDenied
}) =>
{
report.skipped += 1;
continue;
}
Err(error) => return Err(error.into()),
};
let size = i64::try_from(metadata.len()).context("file exceeds supported size")?;
let mtime = searchi_media::unix_time_ns(metadata.modified()?)?;
let extension = entry
@@ -771,6 +806,10 @@ impl SearchIndex {
// metadata is unchanged. This detects same-stat overwrites while
// unsupported media/giant files retain the metadata-only fast path.
let input = extraction::read_source(entry.path());
if input.as_ref().is_err_and(permission_denied) {
report.skipped += 1;
continue;
}
// A parser rejection may retain a filename-only record, but a
// filesystem read failure cannot certify this reconciliation.
if input
@@ -781,7 +820,14 @@ impl SearchIndex {
.with_context(|| format!("read {path}; reconciliation rolled back"));
}
if matches!(&input, Ok(Some(_))) {
let after = fs::metadata(entry.path())?;
let after = match fs::metadata(entry.path()) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
report.skipped += 1;
continue;
}
Err(error) => return Err(error.into()),
};
if after.len() != metadata.len() || after.modified()? != metadata.modified()? {
bail!("Source changed during extraction read; retry indexing: {path}; reconciliation rolled back");
}
@@ -840,7 +886,15 @@ impl SearchIndex {
check_index_cancelled(cancelled)?;
if matches!(extension.as_str(), "pdf" | "docx") {
if let Some(hash) = &source_hash {
extraction::verify_document_snapshot(entry.path(), hash, &metadata)?;
if let Err(error) =
extraction::verify_document_snapshot(entry.path(), hash, &metadata)
{
if permission_denied(&error) {
report.skipped += 1;
continue;
}
return Err(error);
}
}
}
if extracted.is_none() {
@@ -1536,6 +1590,32 @@ fn substring_anchor(characters: &[char], frequencies: &[(i64, String)]) -> Strin
characters[start..end].iter().collect()
}
fn permission_denied(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::PermissionDenied)
})
}
/// A denied walk is not proof of deletion. Keep prior records in exactly this
/// subtree without updating their fingerprints or certifying their freshness.
fn preserve_unreadable_scope(
transaction: &rusqlite::Transaction<'_>,
root: &str,
scope: &Path,
) -> Result<()> {
let key = scope.to_string_lossy();
let prefix = format!("{}{}", key, std::path::MAIN_SEPARATOR);
transaction.execute(
"INSERT OR IGNORE INTO seen_paths(path)
SELECT path FROM files WHERE root=?1
AND (?4 OR path=?2 OR substr(path,1,length(?3))=?3)",
params![root, key, prefix, key == root],
)?;
Ok(())
}
fn check_index_cancelled(cancelled: Option<&AtomicBool>) -> Result<()> {
if cancelled.is_some_and(|flag| flag.load(Ordering::Acquire)) {
bail!("Index reconciliation cancelled; transaction rolled back");
@@ -1560,6 +1640,63 @@ fn validate_normalized_absolute(path: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
#[test]
fn denied_scope_preserves_only_existing_records_with_component_boundary() -> anyhow::Result<()>
{
use super::*;
let directory = tempfile::tempdir()?;
let root = directory.path().canonicalize()?;
let denied = root.join("private");
let sibling = root.join("private-other");
fs::create_dir(&denied)?;
fs::create_dir(&sibling)?;
fs::write(denied.join("kept.txt"), "original exact source")?;
fs::write(sibling.join("removed.txt"), "sibling")?;
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(&root)?;
let root_key = root.to_string_lossy().into_owned();
let transaction = index.connection.transaction()?;
transaction.execute_batch("CREATE TEMP TABLE IF NOT EXISTS seen_paths(path TEXT PRIMARY KEY); DELETE FROM seen_paths;")?;
preserve_unreadable_scope(&transaction, &root_key, &denied)?;
let preserved: Vec<String> = transaction
.prepare("SELECT path FROM seen_paths ORDER BY path")?
.query_map([], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?;
assert_eq!(
preserved,
vec![denied.join("kept.txt").to_string_lossy().into_owned()]
);
assert_eq!(transaction.execute("DELETE FROM files WHERE root=?1 AND NOT EXISTS (SELECT 1 FROM seen_paths WHERE seen_paths.path=files.path)", [&root_key])?, 1);
let content: String =
transaction.query_row("SELECT content FROM files", [], |row| row.get(0))?;
assert_eq!(content, "original exact source");
transaction.execute("DELETE FROM seen_paths", [])?;
preserve_unreadable_scope(&transaction, &root_key, &root)?;
assert_eq!(
transaction.query_row("SELECT count(*) FROM seen_paths", [], |row| row
.get::<_, i64>(0))?,
1
);
transaction.commit()?;
Ok(())
}
#[test]
fn permission_skip_does_not_swallow_other_io_errors() {
let denied = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.context("source read");
assert!(super::permission_denied(&denied));
for kind in [
std::io::ErrorKind::NotFound,
std::io::ErrorKind::UnexpectedEof,
std::io::ErrorKind::Interrupted,
] {
assert!(!super::permission_denied(&anyhow::Error::new(
std::io::Error::from(kind)
)));
}
}
#[test]
fn legacy_metadata_migration_preserves_keyword_search_without_inventing_fields(
) -> anyhow::Result<()> {