use keyhog_core::RawMatch;
use keyhog_scanner::telemetry::DogfoodEvent;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const WIRE_VERSION: u32 = 6;
pub const MAX_FRAME_BYTES: u32 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
Hello,
ScanText {
path: Option<String>,
text: String,
dogfood: bool,
},
ScanPath {
path: String,
working_dir: Option<String>,
dogfood: bool,
},
Health,
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Response {
Hello {
wire_version: u32,
keyhog_version: String,
git_hash: String,
detector_rules_digest: String,
backend_policy: String,
detector_count: usize,
uptime_secs: u64,
},
ScanResults {
path: Option<String>,
matches: Vec<RawMatch>,
engine_example_suppressions: u64,
dogfood_events: Vec<DogfoodEvent>,
static_recovery_rejections: BTreeMap<String, u64>,
dogfood_detail_events_dropped: u64,
source_coverage_gaps: SourceCoverageGaps,
backend_recovery: RequiredOption<BackendRecoveryStatus>,
},
Health {
uptime_secs: u64,
scans_served: u64,
active_scans: u32,
detector_count: usize,
backend_recoveries: u64,
last_backend_fault: Option<BackendRecoveryStatus>,
},
Error { message: String },
Shutdown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendRecoveryStatus {
pub failed_backend: String,
pub recovery_backend: String,
pub recovered_ranges: Vec<RecoveredInputRangeStatus>,
pub recovered_chunks: usize,
pub recovered_bytes: u64,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequiredOption<T> {
None,
Some(T),
}
impl<T> RequiredOption<T> {
pub fn is_none(&self) -> bool {
matches!(self, RequiredOption::None)
}
pub fn is_some(&self) -> bool {
matches!(self, RequiredOption::Some(_))
}
pub fn expect(self, msg: &str) -> T {
match self {
RequiredOption::Some(v) => v,
RequiredOption::None => panic!("{msg}"),
}
}
}
impl<T> From<Option<T>> for RequiredOption<T> {
fn from(opt: Option<T>) -> Self {
opt.map_or(RequiredOption::None, RequiredOption::Some)
}
}
impl<T> From<RequiredOption<T>> for Option<T> {
fn from(req: RequiredOption<T>) -> Self {
match req {
RequiredOption::None => None,
RequiredOption::Some(v) => Some(v),
}
}
}
impl<T: Serialize> Serialize for RequiredOption<T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
RequiredOption::None => serializer.serialize_none(),
RequiredOption::Some(v) => v.serialize(serializer),
}
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for RequiredOption<T> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct RequiredOptionVisitor<T> {
marker: std::marker::PhantomData<T>,
}
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for RequiredOptionVisitor<T> {
type Value = RequiredOption<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a required optional value")
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(RequiredOption::None)
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok(RequiredOption::None)
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let de = serde::de::value::MapAccessDeserializer::new(map);
T::deserialize(de).map(RequiredOption::Some)
}
fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
where
S: serde::de::SeqAccess<'de>,
{
let de = serde::de::value::SeqAccessDeserializer::new(seq);
T::deserialize(de).map(RequiredOption::Some)
}
}
deserializer.deserialize_any(RequiredOptionVisitor {
marker: std::marker::PhantomData,
})
}
}
impl<T> Default for RequiredOption<T> {
fn default() -> Self {
RequiredOption::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecoveredInputRangeStatus {
pub chunk_index: usize,
pub byte_start: usize,
pub byte_end: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceCoverageGaps {
pub over_max_size: usize,
pub binary: usize,
pub unreadable: usize,
pub git_object_unreadable: usize,
pub archive_truncated: usize,
pub binary_section_name_unresolved: usize,
pub source_truncated: usize,
pub structured_source_parse_failures: usize,
pub archive_duplicate_scan_unavailable: usize,
pub git_lfs_pointer: usize,
}
impl SourceCoverageGaps {
pub fn total(self) -> usize {
self.over_max_size
+ self.binary
+ self.unreadable
+ self.git_object_unreadable
+ self.archive_truncated
+ self.binary_section_name_unresolved
+ self.source_truncated
+ self.structured_source_parse_failures
+ self.archive_duplicate_scan_unavailable
+ self.git_lfs_pointer
}
pub fn fail_class_total(self) -> usize {
self.unreadable
+ self.git_object_unreadable
+ self.archive_truncated
+ self.binary_section_name_unresolved
+ self.source_truncated
+ self.structured_source_parse_failures
+ self.archive_duplicate_scan_unavailable
+ self.git_lfs_pointer
}
pub fn is_empty(self) -> bool {
self.total() == 0
}
pub fn fail_class_empty(self) -> bool {
self.fail_class_total() == 0
}
}
pub(crate) fn response_kind(response: &Response) -> &'static str {
match response {
Response::Hello { .. } => "Hello",
Response::Health { .. } => "Health",
Response::ScanResults { .. } => "ScanResults",
Response::Shutdown => "Shutdown",
Response::Error { .. } => "Error",
}
}