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::Serialize;
9use std::sync::Arc;
10use thiserror::Error;
11
12/// Machine-readable reason a requested source surface was not fully scanned.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SourceCoverageGapKind {
15 /// The source denied access, did not exist, or returned an unreadable response.
16 Inaccessible,
17 /// A configured request, item, or byte limit stopped the scan early.
18 Truncated,
19}
20
21impl std::fmt::Display for SourceCoverageGapKind {
22 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 formatter.write_str(match self {
24 Self::Inaccessible => "inaccessible",
25 Self::Truncated => "truncated",
26 })
27 }
28}
29
30/// A scannable chunk of text with metadata about where it came from.
31///
32/// # Examples
33///
34/// ```rust
35/// use keyhog_core::{Chunk, ChunkMetadata};
36///
37/// let chunk = Chunk {
38/// data: "API_KEY=sk_live_example".into(),
39/// metadata: ChunkMetadata {
40/// source_type: "filesystem".into(),
41/// path: Some("app.env".into()),
42/// ..Default::default()
43/// },
44/// };
45///
46/// assert_eq!(chunk.metadata.path.as_deref(), Some("app.env"));
47/// ```
48#[derive(Debug, Clone, Serialize)]
49pub struct Chunk {
50 /// UTF-8 text content to scan.
51 pub data: SensitiveString,
52 /// Provenance details used in findings and reporters.
53 pub metadata: ChunkMetadata,
54}
55
56impl From<String> for Chunk {
57 fn from(data: String) -> Self {
58 Self {
59 data: data.into(),
60 metadata: ChunkMetadata::default(),
61 }
62 }
63}
64
65impl From<&str> for Chunk {
66 fn from(data: &str) -> Self {
67 Self::from(data.to_string())
68 }
69}
70
71/// Metadata that tracks the source location for a scanned chunk.
72///
73/// # Examples
74///
75/// ```rust
76/// use keyhog_core::ChunkMetadata;
77///
78/// let metadata = ChunkMetadata {
79/// source_type: "git-diff".into(),
80/// path: Some("src/lib.rs".into()),
81/// commit: Some("abc123".into()),
82/// author: Some("Dev".into()),
83/// date: Some("2026-03-26T00:00:00Z".into()),
84/// ..Default::default()
85/// };
86///
87/// assert_eq!(&*metadata.source_type, "git-diff");
88/// ```
89#[derive(Debug, Clone, Serialize, Default)]
90pub struct ChunkMetadata {
91 /// `Arc<str>` (not `String`) so cloning a chunk's metadata, done per decode
92 /// sub-chunk, where every sub-chunk of a file shares the same `source_type`
93 /// and `path`: is a refcount bump, not a fresh heap allocation + copy of
94 /// each string. Mirrors the `Arc<str>` convention already used by
95 /// `MatchLocation` in `finding.rs`; serialized through the same
96 /// `serde_arc_str` helpers so no `serde` `rc` feature is needed.
97 #[serde(with = "crate::finding::serde_arc_str")]
98 pub source_type: Arc<str>,
99 #[serde(with = "crate::finding::serde_arc_str_opt")]
100 pub path: Option<Arc<str>>,
101 #[serde(with = "crate::finding::serde_arc_str_opt")]
102 pub commit: Option<Arc<str>>,
103 #[serde(with = "crate::finding::serde_arc_str_opt")]
104 pub author: Option<Arc<str>>,
105 #[serde(with = "crate::finding::serde_arc_str_opt")]
106 pub date: Option<Arc<str>>,
107 pub base_offset: usize,
108 /// Number of lines that precede `base_offset` in the original file -
109 /// the line-number analog of `base_offset`. Zero for whole-file chunks
110 /// (single-pass mmap, stdin, http, git diffs). Non-zero only when a
111 /// source slices one file into multiple chunks (the filesystem
112 /// `>window_size` windowed path), where each window after the first
113 /// starts partway through the file. The scanner computes a match's
114 /// line number *within the chunk text* and adds this base so the
115 /// reported line is the absolute file line, not the per-window one -
116 /// exactly mirroring how `base_offset` makes the byte offset absolute.
117 /// Without it, a secret on line 584307 of a 70 MiB file was reported
118 /// at the window-local line (e.g. line 2), making findings impossible
119 /// to locate.
120 #[serde(default)]
121 pub base_line: usize,
122 /// File mtime in nanoseconds since UNIX epoch, when the source can
123 /// surface it cheaply (filesystem walks). Optional because non-fs
124 /// sources (stdin, http, git diffs) don't have a meaningful mtime.
125 /// Populated to drive the merkle-index metadata fast-path.
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub mtime_ns: Option<u64>,
128 /// File size in bytes, when known cheaply at chunk-production time.
129 /// Same shape and rationale as `mtime_ns`.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub size_bytes: Option<u64>,
132 /// For DECODE sub-chunks only: the `[start, end)` byte range of the freshly
133 /// decoded text within `data`. A decode sub-chunk is a small window of
134 /// already-scanned parent context with the decoded blob spliced in at this
135 /// span; everything OUTSIDE the span was scanned (and any finding deduped)
136 /// when the parent chunk was scanned, so the self-contained passes only need
137 /// to rescan a focus window around this span instead of the whole splice.
138 /// `None` for all non-decode chunks (whole-file, windowed, git-diff, …).
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub decoded_span: Option<(usize, usize)>,
141}
142
143/// Produces chunks of text for the scanner to process.
144/// Each implementation handles a different input source.
145///
146/// # Examples
147///
148/// ```rust
149/// use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
150///
151/// struct StaticSource;
152///
153/// impl Source for StaticSource {
154/// fn name(&self) -> &str {
155/// "static"
156/// }
157///
158/// fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
159/// Box::new(std::iter::once(Ok(Chunk {
160/// data: "TOKEN=value".into(),
161/// metadata: ChunkMetadata {
162/// source_type: "static".into(),
163/// ..Default::default()
164/// },
165/// })))
166/// }
167///
168/// fn as_any(&self) -> &dyn std::any::Any {
169/// self
170/// }
171/// }
172///
173/// let source = StaticSource;
174/// assert_eq!(source.name(), "static");
175/// ```
176pub trait Source: Send + Sync {
177 /// Human-readable source name used in warnings and telemetry.
178 fn name(&self) -> &str;
179 /// Yield all readable chunks from this source.
180 fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_>;
181 /// Support downcasting to concrete types.
182 fn as_any(&self) -> &dyn std::any::Any;
183
184 /// Whether all chunks for one exact `(source_type, path)` identity are
185 /// emitted contiguously. Dispatch may use this to split unrelated routing
186 /// classes without cutting a future cross-chunk dependency.
187 fn chunk_identities_are_contiguous(&self) -> bool {
188 false
189 }
190}
191
192/// Errors returned by input sources while enumerating or reading content.
193///
194/// # Examples
195///
196/// ```rust
197/// use keyhog_core::SourceError;
198///
199/// let error = SourceError::Other("pass a readable file or directory".into());
200/// assert!(error.to_string().contains("Fix"));
201/// ```
202#[derive(Debug, Error)]
203pub enum SourceError {
204 #[error(
205 "failed to read source: {0}. Fix: check the path exists, is readable, and is not a broken symlink"
206 )]
207 Io(#[from] std::io::Error),
208 #[error(
209 "failed to access git source: {0}. Fix: run inside a valid git repository and verify the requested refs exist"
210 )]
211 Git(String),
212 #[error(
213 "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"
214 )]
215 Coverage {
216 /// Stable source adapter name.
217 adapter: String,
218 /// Independently selected surface that was incomplete.
219 surface: String,
220 /// Credential-free target identity.
221 target: String,
222 /// Typed coverage classification.
223 kind: SourceCoverageGapKind,
224 /// Response-free operator guidance.
225 detail: String,
226 },
227 #[error("unknown source '{name}'. Fix: use a source name listed by `keyhog scan --help`")]
228 UnknownSource {
229 /// Unrecognized source identifier supplied by the caller.
230 name: String,
231 },
232 #[error(
233 "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"
234 )]
235 FeatureUnavailable {
236 /// Canonical source identifier.
237 source_name: String,
238 /// Cargo feature required to construct the source.
239 feature: String,
240 },
241 #[error(
242 "invalid configuration for source '{source_name}': {detail}. Fix: use the parameter format documented by `keyhog scan --help`"
243 )]
244 InvalidConfiguration {
245 /// Canonical source identifier.
246 source_name: String,
247 /// Credential-free explanation of the invalid input shape.
248 detail: String,
249 },
250 #[error(
251 "source name '{name}' is no longer accepted; use '{replacement}'. Fix: update the source identifier and rerun the scan"
252 )]
253 DeprecatedSourceName {
254 /// Retired source identifier.
255 name: String,
256 /// Canonical replacement identifier.
257 replacement: String,
258 },
259 #[error(
260 "failed to read source: {0}. Fix: adjust the source settings or input so KeyHog can read plain text safely"
261 )]
262 Other(String),
263}