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::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}