Skip to main content

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}