Skip to main content

Exhume Grep as a library

exhume_grep exposes the same read-only content search used by the CLI. It accepts a SearchRequest, streams serializable SearchEvent values through a bounded Tokio channel, and returns a SearchSummary.

The API below describes version 0.1.0. The CLI documentation covers indexing prerequisites, supported sources, matching rules, and coverage limits. Source is available in the module repository.

Dependencies​

Requires Rust 1.92 or newer. Add these dependencies to Cargo.toml:

[dependencies]
exhume_grep = "0.1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
anyhow = "1"

This complete example searches evidence 1 in case.sqlite. Create the index first and keep its evidence source accessible. Run the consumer concurrently with the search: waiting for search completion before draining the bounded channel can block the producer.

use std::sync::{Arc, atomic::AtomicBool};

use exhume_grep::{
search, MatcherOptions, Pattern, SearchEvent, SearchRequest, Status,
};
use tokio::sync::mpsc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let matcher = MatcherOptions {
patterns: vec![Pattern {
id: "credential".to_string(),
expression: "password".to_string(),
}],
..Default::default()
};
let request = SearchRequest::new("case.sqlite", 1, matcher);
let cancel = Arc::new(AtomicBool::new(false));
let (tx, mut rx) = mpsc::channel::<SearchEvent>(128);

let consumer = async move {
while let Some(event) = rx.recv().await {
match event {
SearchEvent::Match { files, hit } => {
for file in std::iter::once(&files.file).chain(&files.aliases) {
println!(
"{:?}:{}:{} pattern={}",
file.path, hit.offset, hit.length, hit.pattern_id
);
}
}
SearchEvent::FileError { file, message } => {
eprintln!("file {}: {message}", file.id);
}
SearchEvent::SourceError { message, .. } => {
eprintln!("source: {message}");
}
SearchEvent::SkippedRange { files, range } => {
eprintln!(
"file {}: skipped {} bytes at {} ({})",
files.file.id, range.length, range.offset, range.reason
);
}
_ => {}
}
}
};

let (result, ()) = tokio::join!(search(request, tx, cancel.clone()), consumer);
let summary = result?;
println!(
"status={:?} scanned={} matched={} hits={}",
summary.status, summary.scanned_files, summary.matched_files, summary.hits
);
anyhow::ensure!(
summary.status == Status::Complete,
"search did not complete with full coverage: {:?}",
summary.status
);
Ok(())
}

The library returns Result<SearchSummary>. An Ok result can still contain an Incomplete, Limited, or Cancelled status. Handle both errors and the summary status; receiving some matches is not proof of complete coverage.

Configure matching and scope​

MatcherOptions::default() selects case-sensitive literals, UTF-8, a 1 MiB read chunk, an 8 MiB regex line limit, no byte previews, and no matching-line events. Supply at least one Pattern with a unique, nonempty ID and a nonempty expression.

For case-insensitive regex matching across supported encodings:

use exhume_grep::{Encoding, MatcherOptions, Mode, Pattern};

let matcher = MatcherOptions {
mode: Mode::Regex,
patterns: vec![Pattern {
id: "credentials".into(),
expression: "(?i)password|secret".into(),
}],
encodings: vec![Encoding::Utf8, Encoding::Utf16le, Encoding::Utf16be],
show_line: true,
line_bytes: 4096,
..Default::default()
};

Set scope and limits on SearchRequest before passing it to search:

FieldPurpose
database, evidence_idExisting index and the evidence row to search.
sourceOptional relocated evidence path; identity checks still apply.
partition_idsRestrict to these indexed partition IDs; empty selects all for the evidence.
directoryIndexed directory and its descendants.
include_paths, exclude_pathsRegex filters over normalized indexed paths.
min_size, max_sizeInclusive bounds on indexed file sizes in bytes.
matcherPatterns, encodings, matching mode, and context limits.
workersIndependent search workers, 1–64; default: 1.
files_onlyEmit FileMatched instead of individual hits; stop at the first hit per file.
max_hits, max_hits_per_fileOptional positive hit limits.

files_only cannot be combined with matcher.show_line. Each worker opens its own source, so seek cursors are not shared with other workers or viewers. Applications should not depend on a fixed result order when using multiple workers.

Events and results​

Events serialize as JSON objects with a snake-case event tag. Indexed native file identifiers serialize as decimal strings to preserve large APFS IDs when consumed by JavaScript.

Rust variant / JSON eventContent
Started / startedSchema version, evidence ID, resolved source, matcher options, and worker count.
FileStarted / file_startedPrimary indexed file, aliases, and source view.
Match / matchFile group and a Hit containing pattern ID, encoding, original byte offset, length, and optional hex preview.
MatchingLine / matching_lineOptional escaped display text, line coordinates, highlight spans, and truncation/partial flags.
FileMatched / file_matchedA matching file group when files_only is enabled.
SkippedRange / skipped_rangeUnsearched byte range, encoding, alignment, and reason.
FileError / file_errorIndexed file and a diagnostic message.
SourceError / source_errorEvidence ID and a source-level diagnostic.
FileFinished / file_finishedFile group, status, bytes read, and hit count.
Progress / progressAggregate bytes read, scanned files, and hits.
Finished / finishedFinal SearchSummary.

The Started event uses schema version 1 by default and 2 when matching-line events are requested. MatchingLine supplements individual Match events; count occurrences from Match, not from the number of display lines.

FileGroup contains a primary IndexedFile and any selected aliases for the same filesystem identifier within a partition. A single content scan can therefore represent multiple indexed paths. This is why selected_paths may differ from scanned_files.

All hit offsets and lengths refer to the original logical file bytes, including UTF-16 hits. In MatchingLine, line_offset is the start of the logical line; offset and length locate the displayed excerpt. LineSpan.start and .end are UTF-8 byte indices into the escaped text string. They are not character indices or JavaScript UTF-16 indices. Each span also carries its original byte offset and length.

Display text contains no ANSI codes and is not a lossless extraction of the source. Check truncated_start, truncated_end, and partial before presenting an excerpt as a complete line.

SearchSummary reports status, selected paths, scanned/matched/failed files, hits, bytes read, skipped ranges, limited files, elapsed milliseconds, and time to the first hit. An invalid request or fatal error can return Err without a Finished event, so always inspect the return value of search as well as the event stream.

Cancellation and backpressure​

Retain a clone of the cancellation token in your UI or job controller. To request cancellation from another task:

use std::sync::atomic::Ordering;

cancel.store(true, Ordering::Relaxed);

Cancellation is cooperative. Continue draining the channel while awaiting completion if the application needs remaining events and the final summary. Dropping the receiver also stops the scan. The bounded channel applies backpressure when the consumer cannot keep up, avoiding an unbounded queue of matches.