use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::Read;
use std::sync::LazyLock;
use regex::Regex;
use crate::{hex_encode, sha256_hash, CredentialHash, VerifiedFinding};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum AccessTargetKind {
Account,
Tenant,
Endpoint,
Database,
Resource,
}
impl AccessTargetKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Account => "account",
Self::Tenant => "tenant",
Self::Endpoint => "endpoint",
Self::Database => "database",
Self::Resource => "resource",
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum TargetRelation {
Decoded,
SameLine,
SameFile,
}
impl TargetRelation {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Decoded => "decoded",
Self::SameLine => "same_line",
Self::SameFile => "same_file",
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum Redaction {
None,
Tail,
Hash,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum CoverageGapReason {
NoFilePath,
SourceNotReadable,
HistoricalContent,
TransientReadFailed,
PermanentReadFailed,
NotUtf8,
DerivedViewAnchorless,
FileTruncated,
ByteBudgetExhausted,
}
impl CoverageGapReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NoFilePath => "no_file_path",
Self::SourceNotReadable => "source_not_readable",
Self::HistoricalContent => "historical_content",
Self::TransientReadFailed => "transient_read_failed",
Self::PermanentReadFailed => "permanent_read_failed",
Self::NotUtf8 => "not_utf8",
Self::DerivedViewAnchorless => "derived_view_anchorless",
Self::FileTruncated => "file_truncated",
Self::ByteBudgetExhausted => "byte_budget_exhausted",
}
}
#[must_use]
pub fn explain(self) -> &'static str {
match self {
Self::NoFilePath => "the finding carries no file path, so there is nothing to index",
Self::SourceNotReadable => {
"this source backend does not expose a re-readable local file; \
rescan the extracted content from disk to get access targets"
}
Self::HistoricalContent => {
"the finding is historical content at a commit; the working-tree \
file was not indexed because its neighbours may postdate the credential"
}
Self::TransientReadFailed => {
"the file could not be read, for a reason that may not hold a moment \
later; it was removed, replaced, or locked between the scan and this \
pass, so rerunning may cover it"
}
Self::PermanentReadFailed => {
"the file could not be read and rerunning will not change that; check \
permissions and whether the path is a regular file"
}
Self::NotUtf8 => "the indexed prefix is not valid UTF-8",
Self::DerivedViewAnchorless => {
"the credential came from a derived view of this file (a decode view, \
or a windowed read of a large file), so its line number does not \
index the file; the file was still indexed and its targets are still \
reported, but at maximum distance decay rather than by proximity"
}
Self::FileTruncated => {
"the file is larger than the configured max_file_bytes, so only \
its prefix was indexed"
}
Self::ByteBudgetExhausted => {
"the pass reached max_total_bytes before reaching this file"
}
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TargetEvidence {
pub relation: TargetRelation,
pub rule_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_distance: Option<usize>,
pub provenance: ConfidenceProvenance,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ConfidenceProvenance {
pub source: String,
pub base: f64,
pub decay_steps: u32,
pub decay_factor: f64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AccessTarget {
pub kind: AccessTargetKind,
pub value: String,
pub redaction: Redaction,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
pub confidence: f64,
pub evidence: TargetEvidence,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct TargetedLocation {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CredentialAccessTargets {
pub credential_hash: String,
pub detector_id: String,
pub service: String,
pub location: TargetedLocation,
pub targets: Vec<AccessTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct CoverageGap {
pub reason: CoverageGapReason,
pub explanation: String,
pub findings: usize,
pub examples: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AccessTargetCoverage {
pub findings_total: usize,
pub findings_with_file_context: usize,
pub files_indexed: usize,
pub bytes_indexed: u64,
pub complete: bool,
pub gaps: Vec<CoverageGap>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AccessTargetReport {
pub targets: Vec<CredentialAccessTargets>,
pub coverage: AccessTargetCoverage,
}
impl AccessTargetReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.targets.is_empty() && self.coverage.gaps.is_empty()
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Settings {
max_file_bytes: u64,
max_total_bytes: u64,
max_targets_per_finding: usize,
max_matches_per_rule: usize,
min_confidence: f64,
same_file_decay: f64,
decay_line_step: usize,
decay_max_steps: u32,
decoded_confidence: f64,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct MetadataRule {
key: String,
kind: AccessTargetKind,
#[serde(default)]
service: Option<String>,
label: String,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RuleSpec {
id: String,
kind: AccessTargetKind,
label: String,
#[serde(default)]
service: Option<String>,
pattern: String,
group: usize,
confidence: f64,
redact: Redaction,
#[serde(default)]
redact_keep: Option<usize>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicyFile {
settings: Settings,
#[serde(default)]
metadata: Vec<MetadataRule>,
#[serde(default)]
rule: Vec<RuleSpec>,
}
struct CompiledRule {
spec: RuleSpec,
regex: Regex,
}
struct Policy {
settings: Settings,
metadata: Vec<MetadataRule>,
rules: Vec<CompiledRule>,
}
#[allow(clippy::panic)]
static POLICY: LazyLock<Policy> = LazyLock::new(|| {
match compile_policy(
include_str!("../data/access-targets.toml"),
"<embedded data/access-targets.toml>",
) {
Ok(policy) => policy,
Err(error) => panic!(
"keyhog: access-target policy '<embedded data/access-targets.toml>' \
is invalid: {error}. Fix: correct crates/core/data/access-targets.toml and rebuild"
),
}
});
fn compile_policy(raw: &str, origin: &str) -> Result<Policy, String> {
let file = toml::from_str::<PolicyFile>(raw)
.map_err(|error| format!("failed to parse {origin}: {error}"))?;
validate_settings(&file.settings, origin)?;
let mut seen = BTreeSet::new();
let mut rules = Vec::with_capacity(file.rule.len());
for spec in file.rule {
let id = spec.id.trim().to_string();
if id.is_empty() {
return Err(format!("{origin} [[rule]] has an empty id"));
}
if !seen.insert(id.clone()) {
return Err(format!("{origin} [[rule]] duplicate id {id:?}"));
}
if spec.label.trim().is_empty() {
return Err(format!("{origin} [[rule]] {id:?} has an empty label"));
}
if !(spec.confidence > 0.0 && spec.confidence <= 1.0) {
return Err(format!(
"{origin} [[rule]] {id:?} confidence must be in (0.0, 1.0], got {}",
spec.confidence
));
}
if spec.group == 0 {
return Err(format!(
"{origin} [[rule]] {id:?} group must be at least 1; group 0 is the \
whole match, which would emit surrounding text"
));
}
if matches!(spec.redact, Redaction::Tail) && spec.redact_keep.unwrap_or(0) == 0 {
return Err(format!(
"{origin} [[rule]] {id:?} uses redact = \"tail\" and must set a \
positive redact_keep"
));
}
let regex = Regex::new(&spec.pattern)
.map_err(|error| format!("{origin} [[rule]] {id:?} pattern is invalid: {error}"))?;
let groups = regex.captures_len();
if spec.group >= groups {
return Err(format!(
"{origin} [[rule]] {id:?} wants capture group {} but the pattern has {}",
spec.group,
groups.saturating_sub(1)
));
}
rules.push(CompiledRule { spec, regex });
}
let mut seen_keys = BTreeSet::new();
for entry in &file.metadata {
if entry.key.trim().is_empty() {
return Err(format!("{origin} [[metadata]] has an empty key"));
}
if !seen_keys.insert(entry.key.clone()) {
return Err(format!(
"{origin} [[metadata]] duplicate key {:?}",
entry.key
));
}
if entry.label.trim().is_empty() {
return Err(format!(
"{origin} [[metadata]] {:?} has an empty label",
entry.key
));
}
}
Ok(Policy {
settings: file.settings,
metadata: file.metadata,
rules,
})
}
fn validate_settings(settings: &Settings, origin: &str) -> Result<(), String> {
if settings.max_file_bytes == 0 {
return Err(format!(
"{origin} [settings] max_file_bytes must be positive"
));
}
if settings.max_total_bytes < settings.max_file_bytes {
return Err(format!(
"{origin} [settings] max_total_bytes ({}) must be at least max_file_bytes ({})",
settings.max_total_bytes, settings.max_file_bytes
));
}
if settings.max_targets_per_finding == 0 {
return Err(format!(
"{origin} [settings] max_targets_per_finding must be positive"
));
}
if settings.max_matches_per_rule == 0 {
return Err(format!(
"{origin} [settings] max_matches_per_rule must be positive"
));
}
if !(settings.min_confidence >= 0.0 && settings.min_confidence < 1.0) {
return Err(format!(
"{origin} [settings] min_confidence must be in [0.0, 1.0), got {}",
settings.min_confidence
));
}
if !(settings.same_file_decay > 0.0 && settings.same_file_decay <= 1.0) {
return Err(format!(
"{origin} [settings] same_file_decay must be in (0.0, 1.0], got {}",
settings.same_file_decay
));
}
if settings.decay_line_step == 0 {
return Err(format!(
"{origin} [settings] decay_line_step must be positive"
));
}
if settings.decay_max_steps == 0 {
return Err(format!(
"{origin} [settings] decay_max_steps must be at least 1; zero would make \
a match on the far end of a file score exactly as a match on the \
credential's own line"
));
}
if !(settings.decoded_confidence > 0.0 && settings.decoded_confidence <= 1.0) {
return Err(format!(
"{origin} [settings] decoded_confidence must be in (0.0, 1.0], got {}",
settings.decoded_confidence
));
}
Ok(())
}
pub fn validate_access_target_policy(raw: &str, origin: &str) -> Result<(), String> {
compile_policy(raw, origin).map(|_| ())
}
#[must_use]
pub fn access_target_rule_ids() -> Vec<&'static str> {
POLICY
.rules
.iter()
.map(|rule| rule.spec.id.as_str())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentError {
TransientRead,
PermanentRead,
NotUtf8,
}
impl ContentError {
#[must_use]
pub fn classify(error: &std::io::Error) -> Self {
use std::io::ErrorKind;
match error.kind() {
ErrorKind::PermissionDenied
| ErrorKind::InvalidInput
| ErrorKind::InvalidData
| ErrorKind::Unsupported => Self::PermanentRead,
_ => Self::TransientRead,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileContent {
pub text: String,
pub truncated: bool,
}
pub trait FileContentSource {
fn read_prefix(&self, path: &str, max_bytes: u64) -> Result<FileContent, ContentError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FilesystemContent;
impl FileContentSource for FilesystemContent {
fn read_prefix(&self, path: &str, max_bytes: u64) -> Result<FileContent, ContentError> {
let file = std::fs::File::open(path).map_err(|error| ContentError::classify(&error))?;
let mut buffer = Vec::new();
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut buffer)
.map_err(|error| ContentError::classify(&error))?;
let truncated = buffer.len() as u64 > max_bytes;
if truncated {
buffer.truncate(usize::try_from(max_bytes).unwrap_or(usize::MAX)); }
let text = String::from_utf8(buffer).map_err(|_| ContentError::NotUtf8)?;
Ok(FileContent { text, truncated })
}
}
const READABLE_SOURCES: &[&str] = &["filesystem", "fs"];
struct IndexedTarget {
rule: usize,
line: usize,
column: usize,
span_bytes: usize,
value: String,
}
struct FileIndex {
targets: Vec<IndexedTarget>,
truncated: bool,
}
fn index_content(text: &str, deny: &HashSet<CredentialHash>, truncated: bool) -> FileIndex {
let policy = &*POLICY;
let line_starts = line_start_offsets(text);
let mut targets = Vec::new();
for (index, rule) in policy.rules.iter().enumerate() {
let mut emitted = 0usize;
for captures in rule.regex.captures_iter(text) {
if emitted >= policy.settings.max_matches_per_rule {
break;
}
let Some(group) = captures.get(rule.spec.group) else {
continue;
};
let raw = group.as_str();
if raw.is_empty() {
continue;
}
if deny.contains(&sha256_hash(raw)) {
continue;
}
let (line, column) = position_of(&line_starts, group.start());
targets.push(IndexedTarget {
rule: index,
line,
column,
span_bytes: raw.len(),
value: apply_redaction(raw, &rule.spec),
});
emitted += 1;
}
}
targets.sort_by(|a, b| {
a.line
.cmp(&b.line)
.then_with(|| a.column.cmp(&b.column))
.then_with(|| a.rule.cmp(&b.rule))
});
FileIndex { targets, truncated }
}
fn apply_redaction(raw: &str, spec: &RuleSpec) -> String {
match spec.redact {
Redaction::None => raw.to_string(),
Redaction::Tail => {
let keep = spec.redact_keep.unwrap_or(4); let start = raw
.char_indices()
.rev()
.take(keep)
.last()
.map_or(raw.len(), |(offset, _)| offset);
let mut out = String::with_capacity(3 + raw.len() - start);
out.push_str("...");
out.push_str(&raw[start..]);
out
}
Redaction::Hash => {
let digest = hex_encode(sha256_hash(raw));
let mut out = String::with_capacity(23);
out.push_str("sha256:");
out.push_str(&digest[..16]);
out
}
}
}
fn line_start_offsets(text: &str) -> Vec<usize> {
let mut starts = Vec::with_capacity(text.len() / 40 + 1);
starts.push(0);
for (offset, byte) in text.bytes().enumerate() {
if byte == b'\n' {
starts.push(offset + 1);
}
}
starts
}
fn position_of(line_starts: &[usize], offset: usize) -> (usize, usize) {
let line_index = match line_starts.binary_search(&offset) {
Ok(index) => index,
Err(index) => index.saturating_sub(1),
};
let start = line_starts.get(line_index).copied().unwrap_or(0); (line_index + 1, offset - start + 1)
}
fn round3(value: f64) -> f64 {
(value * 1000.0).round() / 1000.0
}
#[derive(Default)]
struct GapTally {
counts: BTreeMap<CoverageGapReason, (usize, Vec<String>)>,
}
const MAX_GAP_EXAMPLES: usize = 5;
impl GapTally {
fn record(&mut self, reason: CoverageGapReason, example: &str) {
let entry = self.counts.entry(reason).or_insert((0, Vec::new()));
entry.0 += 1;
if entry.1.len() < MAX_GAP_EXAMPLES && !entry.1.iter().any(|seen| seen == example) {
entry.1.push(example.to_string());
}
}
fn finish(self) -> Vec<CoverageGap> {
self.counts
.into_iter()
.map(|(reason, (findings, examples))| CoverageGap {
reason,
explanation: reason.explain().to_string(),
findings,
examples,
})
.collect()
}
}
#[must_use]
pub fn associate_access_targets(findings: &[VerifiedFinding]) -> AccessTargetReport {
let content = crate::retry::RetryingContentSource::new(&FilesystemContent);
associate_access_targets_with(findings, &content)
}
#[must_use]
pub fn associate_access_targets_with(
findings: &[VerifiedFinding],
content: &dyn FileContentSource,
) -> AccessTargetReport {
let policy = &*POLICY;
let settings = &policy.settings;
let deny: HashSet<CredentialHash> = findings
.iter()
.map(|finding| finding.credential_hash)
.collect();
let mut indexes: BTreeMap<String, Result<FileIndex, CoverageGapReason>> = BTreeMap::new();
let mut bytes_indexed: u64 = 0;
let mut budget_exhausted = false;
let mut gaps = GapTally::default();
let mut with_context = 0usize;
let mut rows: Vec<CredentialAccessTargets> = Vec::new();
for finding in findings {
let mut targets = decoded_targets(finding, policy);
let index = match indexable(finding) {
Ok(target) => {
let path = target.path();
if !indexes.contains_key(path) {
let entry = if budget_exhausted || bytes_indexed >= settings.max_total_bytes {
budget_exhausted = true;
Err(CoverageGapReason::ByteBudgetExhausted)
} else {
let remaining = settings.max_total_bytes - bytes_indexed;
let cap = settings.max_file_bytes.min(remaining);
match content.read_prefix(path, cap) {
Ok(file) => {
bytes_indexed =
bytes_indexed.saturating_add(file.text.len() as u64);
let truncated = file.truncated || cap < settings.max_file_bytes;
Ok(index_content(&file.text, &deny, truncated))
}
Err(ContentError::TransientRead) => {
Err(CoverageGapReason::TransientReadFailed)
}
Err(ContentError::PermanentRead) => {
Err(CoverageGapReason::PermanentReadFailed)
}
Err(ContentError::NotUtf8) => Err(CoverageGapReason::NotUtf8),
}
};
indexes.insert(path.to_string(), entry);
}
match indexes.get(path) {
Some(Ok(index)) => {
with_context += 1;
if index.truncated {
gaps.record(CoverageGapReason::FileTruncated, path);
}
if target.anchor().is_none() {
gaps.record(CoverageGapReason::DerivedViewAnchorless, path);
}
Some((path, index, target.anchor()))
}
Some(Err(reason)) => {
gaps.record(*reason, path);
None
}
None => None,
}
}
Err(reason) => {
let example = finding
.location
.file_path
.as_deref()
.unwrap_or(finding.location.source.as_ref()); gaps.record(reason, example);
None
}
};
if let Some((path, index, anchor)) = index {
for candidate in &index.targets {
if let Some(target) = score(candidate, anchor, path, policy) {
targets.push(target);
}
}
}
if targets.is_empty() {
continue;
}
targets.sort_by(|a, b| {
let a_distance = a.evidence.line_distance.unwrap_or(0); let b_distance = b.evidence.line_distance.unwrap_or(0); a.evidence
.relation
.cmp(&b.evidence.relation)
.then_with(|| a_distance.cmp(&b_distance))
.then_with(|| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal) })
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.value.cmp(&b.value))
});
targets.dedup_by(|a, b| a.kind == b.kind && a.value == b.value);
targets.truncate(settings.max_targets_per_finding);
rows.push(CredentialAccessTargets {
credential_hash: hex_encode(finding.credential_hash),
detector_id: finding.detector_id.to_string(),
service: finding.service.to_string(),
location: TargetedLocation {
source: finding.location.source.to_string(),
file_path: finding.location.file_path.as_deref().map(str::to_string),
line: finding.location.line,
},
targets,
});
}
rows.sort_by(|a, b| {
a.location
.file_path
.cmp(&b.location.file_path)
.then_with(|| a.location.line.cmp(&b.location.line))
.then_with(|| a.detector_id.cmp(&b.detector_id))
.then_with(|| a.credential_hash.cmp(&b.credential_hash))
});
let gaps = gaps.finish();
AccessTargetReport {
targets: rows,
coverage: AccessTargetCoverage {
findings_total: findings.len(),
findings_with_file_context: with_context,
files_indexed: indexes.values().filter(|entry| entry.is_ok()).count(),
bytes_indexed,
complete: gaps.is_empty(),
gaps,
},
}
}
fn decoded_targets(finding: &VerifiedFinding, policy: &Policy) -> Vec<AccessTarget> {
let mut out = Vec::new();
for entry in &policy.metadata {
let Some(value) = finding.metadata.get(&entry.key) else {
continue;
};
if value.is_empty() {
continue;
}
out.push(AccessTarget {
kind: entry.kind,
value: value.clone(),
redaction: Redaction::None,
label: entry.label.clone(),
service: entry.service.clone(),
confidence: round3(policy.settings.decoded_confidence),
evidence: TargetEvidence {
relation: TargetRelation::Decoded,
rule_id: format!("metadata:{}", entry.key),
file_path: None,
line: None,
column: None,
span_bytes: None,
line_distance: None,
provenance: ConfidenceProvenance {
source: "credential_metadata".to_string(),
base: round3(policy.settings.decoded_confidence),
decay_steps: 0,
decay_factor: 1.0,
},
},
});
}
out
}
fn score(
candidate: &IndexedTarget,
anchor: Option<usize>,
path: &str,
policy: &Policy,
) -> Option<AccessTarget> {
let settings = &policy.settings;
let rule = policy.rules.get(candidate.rule)?;
let (relation, steps, distance) = match anchor {
Some(anchor) => {
let distance = candidate.line.abs_diff(anchor);
if distance == 0 {
(TargetRelation::SameLine, 0u32, Some(0usize))
} else {
let steps = u32::try_from(distance / settings.decay_line_step)
.unwrap_or(settings.decay_max_steps) .clamp(1, settings.decay_max_steps);
(TargetRelation::SameFile, steps, Some(distance))
}
}
None => (TargetRelation::SameFile, settings.decay_max_steps, None),
};
let confidence = rule.spec.confidence * settings.same_file_decay.powi(steps as i32);
if confidence < settings.min_confidence {
return None;
}
Some(AccessTarget {
kind: rule.spec.kind,
value: candidate.value.clone(),
redaction: rule.spec.redact,
label: rule.spec.label.clone(),
service: rule.spec.service.clone(),
confidence: round3(confidence),
evidence: TargetEvidence {
relation,
rule_id: rule.spec.id.clone(),
file_path: Some(path.to_string()),
line: Some(candidate.line),
column: Some(candidate.column),
span_bytes: Some(candidate.span_bytes),
line_distance: distance,
provenance: ConfidenceProvenance {
source: "tier_b_rule".to_string(),
base: round3(rule.spec.confidence),
decay_steps: steps,
decay_factor: settings.same_file_decay,
},
},
})
}
enum Indexable<'a> {
Anchored(&'a str, usize),
Anchorless(&'a str),
}
impl<'a> Indexable<'a> {
fn path(&self) -> &'a str {
match *self {
Self::Anchored(path, _) | Self::Anchorless(path) => path,
}
}
fn anchor(&self) -> Option<usize> {
match *self {
Self::Anchored(_, line) => Some(line),
Self::Anchorless(_) => None,
}
}
}
fn indexable(finding: &VerifiedFinding) -> Result<Indexable<'_>, CoverageGapReason> {
if finding.location.commit.is_some() {
return Err(CoverageGapReason::HistoricalContent);
}
let source = finding.location.source.as_ref();
let anchored = READABLE_SOURCES.contains(&source);
let derived = !anchored
&& READABLE_SOURCES.iter().any(|readable| {
source.starts_with(readable) && source.as_bytes().get(readable.len()) == Some(&b'/')
});
if !anchored && !derived {
return Err(CoverageGapReason::SourceNotReadable);
}
let path = match finding.location.file_path.as_deref() {
Some(path) if !path.is_empty() => path,
_ => return Err(CoverageGapReason::NoFilePath),
};
if anchored {
let line = finding.location.line.unwrap_or(1); Ok(Indexable::Anchored(path, line))
} else {
Ok(Indexable::Anchorless(path))
}
}