Ship floating launcher, automatic discovery and bounded media search improvements
This commit is contained in:
1 parent
01279a6b25
commit
521966a2ac
43 files changed
+3165
-267
No files matched your search
@@ -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<()> {
|
||||
|
||||
Reference in new issue
Block a user