keyhog_core/source.rs
1//! Source trait and chunk types: the abstraction for pluggable input backends.
2
3// Debt bucket: 9 items predating the crate floor raising `missing_docs` to
4// `warn`. Remove this allow once every Source-trait item is documented.
5#![allow(missing_docs)]
6
7use crate::SensitiveString;
8use serde::{Deserialize, Serialize};
9use std::sync::{Arc, LazyLock};
10use thiserror::Error;
11/// Canonical default window size (1 MiB) for streaming source chunks.
12pub const DEFAULT_WINDOW_SIZE_BYTES: usize = 1024 * 1024;
13
14/// Canonical default window overlap (128 KiB) between adjacent streaming source windows.
15///
16/// Single canonical owner shared across scanner seam reassembly, filesystem chunking,
17/// stdin streaming, archive member extraction, and benchmark harnesses.
18///
19/// 128 KiB covers PEM-encoded RSA-8192 keys, large JWTs, and multi-line concatenated
20/// secrets with generous margin while bounding per-window re-scan overhead.
21pub const DEFAULT_WINDOW_OVERLAP_BYTES: usize = 128 * 1024;
22
23/// Machine-readable reason a requested source surface was not fully scanned.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SourceCoverageGapKind {
26 /// The source denied access, did not exist, or returned an unreadable response.
27 Inaccessible,
28 /// A configured request, item, or byte limit stopped the scan early.
29 Truncated,
30}
31
32impl std::fmt::Display for SourceCoverageGapKind {
33 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 formatter.write_str(match self {
35 Self::Inaccessible => "inaccessible",
36 Self::Truncated => "truncated",
37 })
38 }
39}
40
41/// A scannable chunk of text with metadata about where it came from.
42///
43/// # Examples
44///
45/// ```rust
46/// use keyhog_core::{Chunk, ChunkMetadata};
47///
48/// let chunk = Chunk {
49/// data: "API_KEY=sk_live_example".into(),
50/// metadata: ChunkMetadata {
51/// source_type: "filesystem".into(),
52/// path: Some("app.env".into()),
53/// ..Default::default()
54/// },
55/// };
56///
57/// assert_eq!(chunk.metadata.path.as_deref(), Some("app.env"));
58/// ```
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Chunk {
61 /// UTF-8 text content to scan.
62 pub data: SensitiveString,
63 /// Provenance details used in findings and reporters.
64 pub metadata: ChunkMetadata,
65}
66
67impl From<String> for Chunk {
68 fn from(data: String) -> Self {
69 Self {
70 data: data.into(),
71 metadata: ChunkMetadata::default(),
72 }
73 }
74}
75
76impl From<&str> for Chunk {
77 fn from(data: &str) -> Self {
78 Self::from(data.to_string())
79 }
80}
81
82/// Metadata that tracks the source location for a scanned chunk.
83///
84/// # Examples
85///
86/// ```rust
87/// use keyhog_core::ChunkMetadata;
88///
89/// let metadata = ChunkMetadata {
90/// source_type: "git-diff".into(),
91/// path: Some("src/lib.rs".into()),
92/// commit: Some("abc123".into()),
93/// author: Some("Dev".into()),
94/// date: Some("2026-03-26T00:00:00Z".into()),
95/// ..Default::default()
96/// };
97///
98/// assert_eq!(&*metadata.source_type, "git-diff");
99/// ```
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct ChunkMetadata {
102 /// `Arc<str>` (not `String`) so cloning a chunk's metadata, done per decode
103 /// sub-chunk, where every sub-chunk of a file shares the same `source_type`
104 /// and `path`: is a refcount bump, not a fresh heap allocation + copy of
105 /// each string. Mirrors the `Arc<str>` convention already used by
106 /// `MatchLocation` in `finding.rs`; serialized through the same
107 /// `serde_arc_str` helpers so no `serde` `rc` feature is needed.
108 #[serde(with = "crate::finding::serde_arc_str")]
109 pub source_type: Arc<str>,
110 #[serde(with = "crate::finding::serde_arc_str_opt")]
111 pub path: Option<Arc<str>>,
112 #[serde(with = "crate::finding::serde_arc_str_opt")]
113 pub commit: Option<Arc<str>>,
114 #[serde(with = "crate::finding::serde_arc_str_opt")]
115 pub author: Option<Arc<str>>,
116 #[serde(with = "crate::finding::serde_arc_str_opt")]
117 pub date: Option<Arc<str>>,
118 pub base_offset: usize,
119 /// Number of lines that precede `base_offset` in the original file -
120 /// the line-number analog of `base_offset`. Zero for whole-file chunks
121 /// (single-pass mmap, stdin, http, git diffs). Non-zero only when a
122 /// source slices one file into multiple chunks (the filesystem
123 /// `>window_size` windowed path), where each window after the first
124 /// starts partway through the file. The scanner computes a match's
125 /// line number *within the chunk text* and adds this base so the
126 /// reported line is the absolute file line, not the per-window one -
127 /// exactly mirroring how `base_offset` makes the byte offset absolute.
128 /// Without it, a secret on line 584307 of a 70 MiB file was reported
129 /// at the window-local line (e.g. line 2), making findings impossible
130 /// to locate.
131 #[serde(default)]
132 pub base_line: usize,
133 /// File mtime in nanoseconds since UNIX epoch, when the source can
134 /// surface it cheaply (filesystem walks). Optional because non-fs
135 /// sources (stdin, http, git diffs) don't have a meaningful mtime.
136 /// Populated to drive the merkle-index metadata fast-path.
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub mtime_ns: Option<u64>,
139 /// File size in bytes, when known cheaply at chunk-production time.
140 /// Same shape and rationale as `mtime_ns`.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub size_bytes: Option<u64>,
143 /// Inode change time in nanoseconds since UNIX epoch, when the platform
144 /// exposes one (unix `st_ctime`). Unlike `mtime_ns` this cannot be forged
145 /// by userspace (`utimensat` / `set_times`), so the incremental index
146 /// requires it to agree before trusting its read-free fast-path skip.
147 /// `None` on platforms without a change time, which disables that skip.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub ctime_ns: Option<u64>,
150 /// For DECODE sub-chunks only: the `[start, end)` byte range of the freshly
151 /// decoded text within `data`. A decode sub-chunk is a small window of
152 /// already-scanned parent context with the decoded blob spliced in at this
153 /// span; everything OUTSIDE the span was scanned (and any finding deduped)
154 /// when the parent chunk was scanned, so the self-contained passes only need
155 /// to rescan a focus window around this span instead of the whole splice.
156 /// `None` for all non-decode chunks (whole-file, windowed, git-diff, …).
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub decoded_span: Option<(usize, usize)>,
159}
160
161macro_rules! define_intern_table {
162 (
163 $(#[$intern_meta:meta])*
164 pub fn $fn_intern:ident,
165 $(#[$list_meta:meta])*
166 pub fn $fn_list:ident,
167 $(
168 $(#[$item_meta:meta])*
169 ($name:ident, $str_val:literal)
170 ),* $(,)?
171 ) => {
172 $(
173 $(#[$item_meta])*
174 #[doc = concat!("Canonical `source_type` for `", $str_val, "` chunks.")]
175 pub static $name: LazyLock<Arc<str>> = LazyLock::new(|| Arc::from($str_val));
176 )*
177
178 $(#[$list_meta])*
179 pub fn $fn_list() -> &'static [&'static str] {
180 &[
181 $( $str_val ),*
182 ]
183 }
184
185 $(#[$intern_meta])*
186 pub fn $fn_intern(val: &str) -> Arc<str> {
187 match val {
188 $(
189 $str_val => Arc::clone(&$name),
190 )*
191 other => Arc::from(other),
192 }
193 }
194 };
195}
196
197define_intern_table! {
198 /// Intern a source type string reference into an `Arc<str>`.
199 ///
200 /// Returns a clone of a pre-allocated static `Arc<str>` for canonical source types,
201 /// avoiding heap allocation and string duplication.
202 pub fn intern_source_type,
203 /// Pre-interned common source type names.
204 pub fn common_source_types,
205 (SOURCE_TYPE_AZURE_BLOB, "azure_blob"),
206 (SOURCE_TYPE_BINARY, "binary"),
207 (SOURCE_TYPE_BINARY_GHIDRA_DECOMPILED, "binary:ghidra:decompiled"),
208 (SOURCE_TYPE_BINARY_GHIDRA_STRINGS, "binary:ghidra:strings"),
209 (SOURCE_TYPE_BINARY_STRINGS, "binary:strings"),
210 (SOURCE_TYPE_DOCKER, "docker"),
211 (SOURCE_TYPE_FILESYSTEM, "filesystem"),
212 (SOURCE_TYPE_FILESYSTEM_ARCHIVE, "filesystem/archive"),
213 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_BINARY, "filesystem/archive-binary"),
214 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_BINARY_TEX_ORPHANED, "filesystem/archive-binary/tex-orphaned"),
215 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_BINARY_TEX_REFERENCED, "filesystem/archive-binary/tex-referenced"),
216 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_BINARY_TEX_ROOT, "filesystem/archive-binary/tex-root"),
217 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_ANDROID, "filesystem/archive/android"),
218 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_ANDROID_RESOURCE, "filesystem/archive/android-resource"),
219 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_ANDROID_XML, "filesystem/archive/android-xml"),
220 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_TEX_COMMENT_ORPHANED, "filesystem/archive/tex-comment/orphaned"),
221 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_TEX_COMMENT_REFERENCED, "filesystem/archive/tex-comment/referenced"),
222 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_TEX_COMMENT_ROOT, "filesystem/archive/tex-comment/root"),
223 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_TEX_ORPHANED, "filesystem/archive/tex-orphaned"),
224 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_TEX_REFERENCED, "filesystem/archive/tex-referenced"),
225 (SOURCE_TYPE_FILESYSTEM_ARCHIVE_TEX_ROOT, "filesystem/archive/tex-root"),
226 (SOURCE_TYPE_FILESYSTEM_COMPRESSED, "filesystem/compressed"),
227 (SOURCE_TYPE_FILESYSTEM_COMPRESSED_BINARY, "filesystem/compressed-binary"),
228 (SOURCE_TYPE_FILESYSTEM_PDF, "filesystem/pdf"),
229 (SOURCE_TYPE_FILESYSTEM_WINDOWED, "filesystem/windowed"),
230 (SOURCE_TYPE_FILESYSTEM_BINARY_STRINGS, "filesystem:binary-strings"),
231 (SOURCE_TYPE_GCS, "gcs"),
232 (SOURCE_TYPE_GIT, "git"),
233 (SOURCE_TYPE_GIT_DIFF, "git-diff"),
234 (SOURCE_TYPE_GIT_HISTORY, "git-history"),
235 (SOURCE_TYPE_GIT_STAGED, "git-staged"),
236 (SOURCE_TYPE_GIT_DIFF_SLASH, "git/diff"),
237 (SOURCE_TYPE_GIT_HEAD, "git/head"),
238 (SOURCE_TYPE_GIT_HISTORY_SLASH, "git/history"),
239 (SOURCE_TYPE_GIT_STAGED_SLASH, "git/staged"),
240 (SOURCE_TYPE_GIT_TAG, "git/tag"),
241 (SOURCE_TYPE_GIT_UNREACHABLE, "git/unreachable"),
242 (SOURCE_TYPE_GITHUB, "github"),
243 (SOURCE_TYPE_S3, "s3"),
244 (SOURCE_TYPE_SLACK, "slack"),
245 (SOURCE_TYPE_STDIN, "stdin"),
246 (SOURCE_TYPE_WEB, "web"),
247 (SOURCE_TYPE_WEB_JS, "web:js"),
248 (SOURCE_TYPE_WEB_SOURCEMAP, "web:sourcemap"),
249 (SOURCE_TYPE_WEB_SOURCEMAP_RAW, "web:sourcemap:raw"),
250 (SOURCE_TYPE_WEB_WASM, "web:wasm"),
251 (SOURCE_TYPE_WIRE_HAR_REQUEST, "wire:har:request"),
252 (SOURCE_TYPE_WIRE_HAR_RESPONSE, "wire:har:response"),
253}
254
255/// Produces chunks of text for the scanner to process.
256/// Each implementation handles a different input source.
257///
258/// # Examples
259///
260/// ```rust
261/// use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
262///
263/// struct StaticSource;
264///
265/// impl Source for StaticSource {
266/// fn name(&self) -> &str {
267/// "static"
268/// }
269///
270/// fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
271/// Box::new(std::iter::once(Ok(Chunk {
272/// data: "TOKEN=value".into(),
273/// metadata: ChunkMetadata {
274/// source_type: "static".into(),
275/// ..Default::default()
276/// },
277/// })))
278/// }
279///
280/// fn as_any(&self) -> &dyn std::any::Any {
281/// self
282/// }
283/// }
284///
285/// let source = StaticSource;
286/// assert_eq!(source.name(), "static");
287/// ```
288pub trait Source: Send + Sync {
289 /// Human-readable source name used in warnings and telemetry.
290 fn name(&self) -> &str;
291 /// Yield all readable chunks from this source.
292 fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_>;
293 /// Support downcasting to concrete types.
294 fn as_any(&self) -> &dyn std::any::Any;
295
296 /// Whether all chunks for one exact `(source_type, path)` identity are
297 /// emitted contiguously. Dispatch may use this to split unrelated routing
298 /// classes without cutting a future cross-chunk dependency.
299 fn chunk_identities_are_contiguous(&self) -> bool {
300 false
301 }
302}
303
304/// Errors returned by input sources while enumerating or reading content.
305///
306/// # Examples
307///
308/// ```rust
309/// use keyhog_core::SourceError;
310///
311/// let error = SourceError::Other("pass a readable file or directory".into());
312/// assert!(error.to_string().contains("Fix"));
313/// ```
314#[derive(Debug, Error)]
315pub enum SourceError {
316 #[error(
317 "failed to read source: {0}. Fix: check the path exists, is readable, and is not a broken symlink"
318 )]
319 Io(#[from] std::io::Error),
320 #[error(
321 "failed to access git source: {0}. Fix: run inside a valid git repository and verify the requested refs exist"
322 )]
323 Git(String),
324 #[error(
325 "source coverage gap ({kind}) in {adapter} surface {surface} at {target}: {detail}. Fix: grant read access or raise the relevant source limit, then rerun the affected surface"
326 )]
327 Coverage {
328 /// Stable source adapter name.
329 adapter: String,
330 /// Independently selected surface that was incomplete.
331 surface: String,
332 /// Credential-free target identity.
333 target: String,
334 /// Typed coverage classification.
335 kind: SourceCoverageGapKind,
336 /// Response-free operator guidance.
337 detail: String,
338 },
339 #[error("unknown source '{name}'. Fix: use a source name listed by `keyhog scan --help`")]
340 UnknownSource {
341 /// Unrecognized source identifier supplied by the caller.
342 name: String,
343 },
344 #[error(
345 "source '{source_name}' is unavailable because this KeyHog artifact was built without the '{feature}' feature. Fix: install an artifact that includes '{feature}' or choose an enabled source"
346 )]
347 FeatureUnavailable {
348 /// Canonical source identifier.
349 source_name: String,
350 /// Cargo feature required to construct the source.
351 feature: String,
352 },
353 #[error(
354 "invalid configuration for source '{source_name}': {detail}. Fix: use the parameter format documented by `keyhog scan --help`"
355 )]
356 InvalidConfiguration {
357 /// Canonical source identifier.
358 source_name: String,
359 /// Credential-free explanation of the invalid input shape.
360 detail: String,
361 },
362 #[error(
363 "source name '{name}' is no longer accepted; use '{replacement}'. Fix: update the source identifier and rerun the scan"
364 )]
365 DeprecatedSourceName {
366 /// Retired source identifier.
367 name: String,
368 /// Canonical replacement identifier.
369 replacement: String,
370 },
371 #[error(
372 "failed to read source: {0}. Fix: adjust the source settings or input so KeyHog can read plain text safely"
373 )]
374 Other(String),
375}