use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::{DateTime, Utc};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Instant;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SessionEventType {
Navigation,
NetworkRequest,
NetworkResponse,
NetworkError,
Click,
Input,
FormSubmit,
Scroll,
ConsoleMessage,
JsError,
Screenshot,
CookieChange,
StorageChange,
WebSocketMessage,
Marker,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ConsoleSeverity {
Log,
Info,
Warning,
Error,
Debug,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
Head,
Options,
Connect,
Trace,
#[serde(other)]
Unknown,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::Get => write!(f, "GET"),
HttpMethod::Post => write!(f, "POST"),
HttpMethod::Put => write!(f, "PUT"),
HttpMethod::Patch => write!(f, "PATCH"),
HttpMethod::Delete => write!(f, "DELETE"),
HttpMethod::Head => write!(f, "HEAD"),
HttpMethod::Options => write!(f, "OPTIONS"),
HttpMethod::Connect => write!(f, "CONNECT"),
HttpMethod::Trace => write!(f, "TRACE"),
HttpMethod::Unknown => write!(f, "UNKNOWN"),
}
}
}
impl From<&str> for HttpMethod {
fn from(s: &str) -> Self {
match s.to_uppercase().as_str() {
"GET" => HttpMethod::Get,
"POST" => HttpMethod::Post,
"PUT" => HttpMethod::Put,
"PATCH" => HttpMethod::Patch,
"DELETE" => HttpMethod::Delete,
"HEAD" => HttpMethod::Head,
"OPTIONS" => HttpMethod::Options,
"CONNECT" => HttpMethod::Connect,
"TRACE" => HttpMethod::Trace,
other => {
debug!("Unknown HTTP method encountered: {}", other);
HttpMethod::Unknown
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkRequest {
pub request_id: String,
pub url: String,
pub method: HttpMethod,
pub headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
pub size: usize,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initiator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_trace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkResponse {
pub request_id: String,
pub status_code: u16,
pub status_text: String,
pub headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub body_truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
pub size: usize,
pub ttfb_ms: u64,
pub duration_ms: u64,
pub timestamp: DateTime<Utc>,
pub from_cache: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkError {
pub request_id: String,
pub error_type: String,
pub message: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomInteraction {
pub interaction_type: DomInteractionType,
pub selector: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub xpath: Option<String>,
pub tag_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub element_id: Option<String>,
#[serde(default)]
pub classes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scroll_position: Option<ScrollPosition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coordinates: Option<ClickCoordinates>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DomInteractionType {
Click,
DoubleClick,
RightClick,
Input,
Focus,
Blur,
Submit,
Scroll,
Hover,
KeyPress,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrollPosition {
pub x: i32,
pub y: i32,
pub max_x: i32,
pub max_y: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClickCoordinates {
pub x: i32,
pub y: i32,
pub viewport_x: i32,
pub viewport_y: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsoleMessage {
pub severity: ConsoleSeverity,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_number: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_number: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_trace: Option<String>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Screenshot {
pub id: String,
pub data: String,
pub format: String,
pub width: u32,
pub height: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub url: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NavigationEvent {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_url: Option<String>,
pub navigation_type: NavigationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub load_time_ms: Option<u64>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum NavigationType {
Link,
Typed,
FormSubmit,
BackForward,
Reload,
Redirect,
Script,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieChange {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub secure: bool,
pub http_only: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<DateTime<Utc>>,
pub deleted: bool,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageChange {
pub storage_type: StorageType,
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_value: Option<String>,
pub url: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum StorageType {
LocalStorage,
SessionStorage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketEvent {
pub url: String,
pub direction: WebSocketDirection,
pub data: String,
pub truncated: bool,
pub is_binary: bool,
pub size: usize,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum WebSocketDirection {
Sent,
Received,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionEvent {
pub id: String,
pub event_type: SessionEventType,
pub timestamp: DateTime<Utc>,
pub offset_ms: u64,
pub current_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub navigation: Option<NavigationEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_request: Option<NetworkRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_response: Option<NetworkResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_error: Option<NetworkError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dom_interaction: Option<DomInteraction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub console_message: Option<ConsoleMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<Screenshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cookie_change: Option<CookieChange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_change: Option<StorageChange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub websocket_event: Option<WebSocketEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub marker_label: Option<String>,
}
impl SessionEvent {
pub fn navigation(
id: String,
offset_ms: u64,
current_url: String,
navigation: NavigationEvent,
) -> Self {
Self {
id,
event_type: SessionEventType::Navigation,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: Some(navigation),
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
}
}
pub fn network_request(
id: String,
offset_ms: u64,
current_url: String,
request: NetworkRequest,
) -> Self {
Self {
id,
event_type: SessionEventType::NetworkRequest,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: None,
network_request: Some(request),
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
}
}
pub fn network_response(
id: String,
offset_ms: u64,
current_url: String,
response: NetworkResponse,
) -> Self {
Self {
id,
event_type: SessionEventType::NetworkResponse,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: None,
network_request: None,
network_response: Some(response),
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
}
}
pub fn dom_interaction(
id: String,
offset_ms: u64,
current_url: String,
interaction: DomInteraction,
) -> Self {
let event_type = match interaction.interaction_type {
DomInteractionType::Click
| DomInteractionType::DoubleClick
| DomInteractionType::RightClick => SessionEventType::Click,
DomInteractionType::Input | DomInteractionType::KeyPress => SessionEventType::Input,
DomInteractionType::Submit => SessionEventType::FormSubmit,
DomInteractionType::Scroll => SessionEventType::Scroll,
_ => SessionEventType::Click,
};
Self {
id,
event_type,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: Some(interaction),
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
}
}
pub fn console_message(
id: String,
offset_ms: u64,
current_url: String,
message: ConsoleMessage,
) -> Self {
let event_type = if message.severity == ConsoleSeverity::Error {
SessionEventType::JsError
} else {
SessionEventType::ConsoleMessage
};
Self {
id,
event_type,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: Some(message),
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
}
}
pub fn screenshot(
id: String,
offset_ms: u64,
current_url: String,
screenshot: Screenshot,
) -> Self {
Self {
id,
event_type: SessionEventType::Screenshot,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: Some(screenshot),
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
}
}
pub fn marker(id: String, offset_ms: u64, current_url: String, label: String) -> Self {
Self {
id,
event_type: SessionEventType::Marker,
timestamp: Utc::now(),
offset_ms,
current_url,
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: Some(label),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionRecording {
pub id: String,
pub name: String,
pub start_url: String,
pub started_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
pub duration_ms: u64,
pub events: Vec<SessionEvent>,
pub event_count: usize,
pub metadata: RecordingMetadata,
pub stats: RecordingStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingMetadata {
pub scanner_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
pub viewport_width: u32,
pub viewport_height: u32,
pub settings: RecordingSettings,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingSettings {
pub capture_bodies: bool,
pub max_body_size: usize,
pub capture_screenshots: bool,
pub screenshot_mode: ScreenshotMode,
pub capture_console: bool,
pub redact_sensitive: bool,
pub capture_dom_interactions: bool,
pub capture_storage: bool,
}
impl Default for RecordingSettings {
fn default() -> Self {
Self {
capture_bodies: true,
max_body_size: 1024 * 1024, capture_screenshots: true,
screenshot_mode: ScreenshotMode::KeyEvents,
capture_console: true,
redact_sensitive: true,
capture_dom_interactions: true,
capture_storage: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotMode {
KeyEvents,
Interval(u64),
AllInteractions,
None,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingStats {
pub navigations: usize,
pub network_requests: usize,
pub network_errors: usize,
pub dom_interactions: usize,
pub console_messages: usize,
pub errors: usize,
pub screenshots: usize,
pub bytes_transferred: usize,
pub unique_urls: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RecorderState {
Idle,
Recording,
Paused,
Stopped,
}
pub struct SessionRecorder {
state: Arc<RwLock<RecorderState>>,
settings: RecordingSettings,
start_time: Arc<RwLock<Option<Instant>>>,
start_url: Arc<RwLock<String>>,
current_url: Arc<RwLock<String>>,
events: Arc<Mutex<Vec<SessionEvent>>>,
event_counter: Arc<std::sync::atomic::AtomicU64>,
pending_requests: Arc<Mutex<HashMap<String, Instant>>>,
stats: Arc<Mutex<RecordingStats>>,
unique_urls: Arc<Mutex<std::collections::HashSet<String>>>,
}
impl SessionRecorder {
fn with_events<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut Vec<SessionEvent>) -> R,
{
match self.events.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
warn!("[SessionRecorder] Events mutex poisoned, recovering");
f(&mut poisoned.into_inner())
}
}
}
fn with_stats<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut RecordingStats) -> R,
{
match self.stats.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
warn!("[SessionRecorder] Stats mutex poisoned, recovering");
f(&mut poisoned.into_inner())
}
}
}
fn with_pending_requests<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut HashMap<String, Instant>) -> R,
{
match self.pending_requests.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
warn!("[SessionRecorder] Pending requests mutex poisoned, recovering");
f(&mut poisoned.into_inner())
}
}
}
fn with_unique_urls<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut std::collections::HashSet<String>) -> R,
{
match self.unique_urls.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
warn!("[SessionRecorder] Unique URLs mutex poisoned, recovering");
f(&mut poisoned.into_inner())
}
}
}
fn read_state(&self) -> RecorderState {
match self.state.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => {
warn!("[SessionRecorder] State RwLock poisoned, recovering");
poisoned.into_inner().clone()
}
}
}
fn with_state_write<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut RecorderState) -> R,
{
match self.state.write() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
warn!("[SessionRecorder] State RwLock poisoned, recovering");
f(&mut poisoned.into_inner())
}
}
}
fn read_start_time(&self) -> Option<Instant> {
match self.start_time.read() {
Ok(guard) => *guard,
Err(poisoned) => {
warn!("[SessionRecorder] Start time RwLock poisoned, recovering");
*poisoned.into_inner()
}
}
}
fn write_start_time(&self, value: Option<Instant>) {
match self.start_time.write() {
Ok(mut guard) => *guard = value,
Err(poisoned) => {
warn!("[SessionRecorder] Start time RwLock poisoned, recovering");
*poisoned.into_inner() = value;
}
}
}
fn read_start_url(&self) -> String {
match self.start_url.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => {
warn!("[SessionRecorder] Start URL RwLock poisoned, recovering");
poisoned.into_inner().clone()
}
}
}
fn write_start_url(&self, value: String) {
match self.start_url.write() {
Ok(mut guard) => *guard = value,
Err(poisoned) => {
warn!("[SessionRecorder] Start URL RwLock poisoned, recovering");
*poisoned.into_inner() = value;
}
}
}
fn read_current_url(&self) -> String {
match self.current_url.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => {
warn!("[SessionRecorder] Current URL RwLock poisoned, recovering");
poisoned.into_inner().clone()
}
}
}
fn write_current_url(&self, value: String) {
match self.current_url.write() {
Ok(mut guard) => *guard = value,
Err(poisoned) => {
warn!("[SessionRecorder] Current URL RwLock poisoned, recovering");
*poisoned.into_inner() = value;
}
}
}
pub fn new() -> Self {
Self::with_settings(RecordingSettings::default())
}
pub fn with_settings(settings: RecordingSettings) -> Self {
Self {
state: Arc::new(RwLock::new(RecorderState::Idle)),
settings,
start_time: Arc::new(RwLock::new(None)),
start_url: Arc::new(RwLock::new(String::new())),
current_url: Arc::new(RwLock::new(String::new())),
events: Arc::new(Mutex::new(Vec::new())),
event_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
pending_requests: Arc::new(Mutex::new(HashMap::new())),
stats: Arc::new(Mutex::new(RecordingStats::default())),
unique_urls: Arc::new(Mutex::new(std::collections::HashSet::new())),
}
}
pub fn state(&self) -> RecorderState {
self.read_state()
}
pub fn is_recording(&self) -> bool {
matches!(self.read_state(), RecorderState::Recording)
}
pub fn start(&self, start_url: &str) {
let current_state = self.read_state();
if current_state != RecorderState::Idle && current_state != RecorderState::Stopped {
warn!(
"[SessionRecorder] Cannot start recording, current state: {:?}",
current_state
);
return;
}
self.with_state_write(|state| *state = RecorderState::Recording);
self.write_start_time(Some(Instant::now()));
self.write_start_url(start_url.to_string());
self.write_current_url(start_url.to_string());
self.with_events(|events| events.clear());
self.with_stats(|stats| *stats = RecordingStats::default());
self.with_unique_urls(|urls| {
urls.clear();
urls.insert(start_url.to_string());
});
self.event_counter
.store(0, std::sync::atomic::Ordering::SeqCst);
info!("[SessionRecorder] Started recording: {}", start_url);
}
pub fn pause(&self) {
self.with_state_write(|state| {
if *state == RecorderState::Recording {
*state = RecorderState::Paused;
debug!("[SessionRecorder] Recording paused");
}
});
}
pub fn resume(&self) {
self.with_state_write(|state| {
if *state == RecorderState::Paused {
*state = RecorderState::Recording;
debug!("[SessionRecorder] Recording resumed");
}
});
}
pub fn stop(&self) -> SessionRecording {
self.with_state_write(|state| *state = RecorderState::Stopped);
let start_time = self.read_start_time();
let duration_ms = start_time
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0);
let events = self.with_events(|events| events.clone());
let stats = self.with_stats(|stats| stats.clone());
let unique_urls_count = self.with_unique_urls(|urls| urls.len());
let mut final_stats = stats;
final_stats.unique_urls = unique_urls_count;
let recording = SessionRecording {
id: uuid::Uuid::new_v4().to_string(),
name: format!(
"Recording {}",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
),
start_url: self.read_start_url(),
started_at: chrono::Utc::now() - chrono::Duration::milliseconds(duration_ms as i64),
ended_at: Some(chrono::Utc::now()),
duration_ms,
event_count: events.len(),
events,
metadata: RecordingMetadata {
scanner_version: env!("CARGO_PKG_VERSION").to_string(),
user_agent: None,
viewport_width: 1920,
viewport_height: 1080,
settings: self.settings.clone(),
tags: Vec::new(),
},
stats: final_stats,
};
info!(
"[SessionRecorder] Recording stopped: {} events, {} ms",
recording.event_count, recording.duration_ms
);
recording
}
fn offset_ms(&self) -> u64 {
self.read_start_time()
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0)
}
fn next_event_id(&self) -> String {
let counter = self
.event_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
format!("evt_{}", counter)
}
fn get_current_url(&self) -> String {
self.read_current_url()
}
fn set_current_url(&self, url: &str) {
self.write_current_url(url.to_string());
self.with_unique_urls(|urls| {
urls.insert(url.to_string());
});
}
pub fn record_navigation(&self, nav: NavigationEvent) {
if !self.is_recording() {
return;
}
self.set_current_url(&nav.url);
let event =
SessionEvent::navigation(self.next_event_id(), self.offset_ms(), nav.url.clone(), nav);
self.with_events(|events| events.push(event));
self.with_stats(|stats| stats.navigations += 1);
debug!("[SessionRecorder] Recorded navigation");
}
pub fn record_network_request(&self, request: NetworkRequest) {
if !self.is_recording() {
return;
}
self.with_pending_requests(|pending| {
pending.insert(request.request_id.clone(), Instant::now());
});
let current_url = self.get_current_url();
let event = SessionEvent::network_request(
self.next_event_id(),
self.offset_ms(),
current_url,
request,
);
self.with_events(|events| events.push(event));
self.with_stats(|stats| stats.network_requests += 1);
}
pub fn record_network_response(&self, mut response: NetworkResponse) {
if !self.is_recording() {
return;
}
if let Some(start) = self.with_pending_requests(|pending| pending.remove(&response.request_id)) {
response.duration_ms = start.elapsed().as_millis() as u64;
}
self.with_stats(|stats| stats.bytes_transferred += response.size);
let current_url = self.get_current_url();
let event = SessionEvent::network_response(
self.next_event_id(),
self.offset_ms(),
current_url,
response,
);
self.with_events(|events| events.push(event));
}
pub fn record_network_error(&self, error: NetworkError) {
if !self.is_recording() {
return;
}
self.with_pending_requests(|pending| pending.remove(&error.request_id));
let current_url = self.get_current_url();
let event = SessionEvent {
id: self.next_event_id(),
event_type: SessionEventType::NetworkError,
timestamp: Utc::now(),
offset_ms: self.offset_ms(),
current_url,
network_error: Some(error),
navigation: None,
network_request: None,
network_response: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
websocket_event: None,
marker_label: None,
};
self.with_events(|events| events.push(event));
self.with_stats(|stats| stats.network_errors += 1);
}
pub fn record_dom_interaction(&self, interaction: DomInteraction) {
if !self.is_recording() || !self.settings.capture_dom_interactions {
return;
}
let current_url = self.get_current_url();
let event = SessionEvent::dom_interaction(
self.next_event_id(),
self.offset_ms(),
current_url,
interaction,
);
self.with_events(|events| events.push(event));
self.with_stats(|stats| stats.dom_interactions += 1);
}
pub fn record_console_message(&self, message: ConsoleMessage) {
if !self.is_recording() || !self.settings.capture_console {
return;
}
if message.severity == ConsoleSeverity::Error
|| message.severity == ConsoleSeverity::Warning
{
self.with_stats(|stats| stats.errors += 1);
}
let current_url = self.get_current_url();
let event = SessionEvent::console_message(
self.next_event_id(),
self.offset_ms(),
current_url,
message,
);
self.with_events(|events| events.push(event));
self.with_stats(|stats| stats.console_messages += 1);
}
pub fn record_screenshot(&self, data: Vec<u8>, description: Option<String>) {
if !self.is_recording() || !self.settings.capture_screenshots {
return;
}
let current_url = self.get_current_url();
let screenshot = Screenshot {
id: uuid::Uuid::new_v4().to_string(),
data: BASE64.encode(&data),
format: "png".to_string(),
width: 0, height: 0,
description,
url: current_url.clone(),
timestamp: Utc::now(),
};
let event = SessionEvent::screenshot(
self.next_event_id(),
self.offset_ms(),
current_url,
screenshot,
);
self.with_events(|events| events.push(event));
self.with_stats(|stats| stats.screenshots += 1);
}
pub fn record_cookie_change(&self, cookie: CookieChange) {
if !self.is_recording() {
return;
}
let current_url = self.get_current_url();
let event = SessionEvent {
id: self.next_event_id(),
event_type: SessionEventType::CookieChange,
timestamp: Utc::now(),
offset_ms: self.offset_ms(),
current_url,
cookie_change: Some(cookie),
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
storage_change: None,
websocket_event: None,
marker_label: None,
};
self.with_events(|events| events.push(event));
}
pub fn record_storage_change(&self, change: StorageChange) {
if !self.is_recording() || !self.settings.capture_storage {
return;
}
let current_url = self.get_current_url();
let event = SessionEvent {
id: self.next_event_id(),
event_type: SessionEventType::StorageChange,
timestamp: Utc::now(),
offset_ms: self.offset_ms(),
current_url,
storage_change: Some(change),
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
websocket_event: None,
marker_label: None,
};
self.with_events(|events| events.push(event));
}
pub fn record_websocket_message(&self, ws_event: WebSocketEvent) {
if !self.is_recording() {
return;
}
let current_url = self.get_current_url();
let event = SessionEvent {
id: self.next_event_id(),
event_type: SessionEventType::WebSocketMessage,
timestamp: Utc::now(),
offset_ms: self.offset_ms(),
current_url,
websocket_event: Some(ws_event),
navigation: None,
network_request: None,
network_response: None,
network_error: None,
dom_interaction: None,
console_message: None,
screenshot: None,
cookie_change: None,
storage_change: None,
marker_label: None,
};
self.with_events(|events| events.push(event));
}
pub fn add_marker(&self, label: &str) {
if !self.is_recording() {
return;
}
let current_url = self.get_current_url();
let event = SessionEvent::marker(
self.next_event_id(),
self.offset_ms(),
current_url,
label.to_string(),
);
self.with_events(|events| events.push(event));
debug!("[SessionRecorder] Marker added: {}", label);
}
pub fn event_count(&self) -> usize {
self.with_events(|events| events.len())
}
pub fn stats(&self) -> RecordingStats {
self.with_stats(|stats| stats.clone())
}
}
impl Default for SessionRecorder {
fn default() -> Self {
Self::new()
}
}
impl Clone for SessionRecorder {
fn clone(&self) -> Self {
Self {
state: Arc::clone(&self.state),
settings: self.settings.clone(),
start_time: Arc::clone(&self.start_time),
start_url: Arc::clone(&self.start_url),
current_url: Arc::clone(&self.current_url),
events: Arc::clone(&self.events),
event_counter: Arc::clone(&self.event_counter),
pending_requests: Arc::clone(&self.pending_requests),
stats: Arc::clone(&self.stats),
unique_urls: Arc::clone(&self.unique_urls),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ExportFormat {
Har,
Json,
JsonCompressed,
Html,
}
pub struct SessionExporter<'a> {
recording: &'a SessionRecording,
}
impl<'a> SessionExporter<'a> {
pub fn new(recording: &'a SessionRecording) -> Self {
Self { recording }
}
fn render_missing_event_data(&self, event_type_class: &str, event_type_label: &str) -> String {
warn!(
"[SessionExporter] Event missing expected data for type: {}",
event_type_label
);
format!(
r#"
<div class="event" data-type="{}">
<div class="event-header">
<span class="event-type {}">{}</span>
<span class="event-time">?ms</span>
</div>
<div class="event-details"><em>Event data missing</em></div>
</div>
"#,
event_type_class, event_type_class, event_type_label
)
}
pub fn export(&self, format: ExportFormat) -> Result<Vec<u8>> {
match format {
ExportFormat::Har => self.export_har(),
ExportFormat::Json => self.export_json(),
ExportFormat::JsonCompressed => self.export_json_compressed(),
ExportFormat::Html => self.export_html(),
}
}
pub fn export_har(&self) -> Result<Vec<u8>> {
let har = self.build_har()?;
let json = serde_json::to_string_pretty(&har).context("Failed to serialize HAR")?;
Ok(json.into_bytes())
}
fn build_har(&self) -> Result<HarRoot> {
let mut entries: Vec<HarEntry> = Vec::new();
let mut requests: HashMap<String, &NetworkRequest> = HashMap::new();
for event in &self.recording.events {
if let Some(ref req) = event.network_request {
requests.insert(req.request_id.clone(), req);
}
if let Some(ref resp) = event.network_response {
if let Some(req) = requests.remove(&resp.request_id) {
entries.push(self.create_har_entry(req, resp));
}
}
}
Ok(HarRoot {
log: HarLog {
version: "1.2".to_string(),
creator: HarCreator {
name: "Lonkero Security Scanner".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
browser: Some(HarBrowser {
name: "Chrome/Chromium (Headless)".to_string(),
version: "latest".to_string(),
}),
pages: vec![HarPage {
started_date_time: self.recording.started_at.to_rfc3339(),
id: "page_0".to_string(),
title: self.recording.start_url.clone(),
page_timings: HarPageTimings {
on_content_load: None,
on_load: Some(self.recording.duration_ms as i64),
},
}],
entries,
},
})
}
fn create_har_entry(&self, req: &NetworkRequest, resp: &NetworkResponse) -> HarEntry {
HarEntry {
started_date_time: req.timestamp.to_rfc3339(),
time: resp.duration_ms as f64,
request: HarRequest {
method: req.method.to_string(),
url: req.url.clone(),
http_version: "HTTP/1.1".to_string(),
cookies: Vec::new(),
headers: req
.headers
.iter()
.map(|(k, v)| HarHeader {
name: k.clone(),
value: v.clone(),
})
.collect(),
query_string: Vec::new(),
post_data: req.body.as_ref().map(|body| HarPostData {
mime_type: req.content_type.clone().unwrap_or_default(),
text: Some(body.clone()),
params: Vec::new(),
}),
headers_size: -1,
body_size: req.size as i64,
},
response: HarResponse {
status: resp.status_code as i32,
status_text: resp.status_text.clone(),
http_version: "HTTP/1.1".to_string(),
cookies: Vec::new(),
headers: resp
.headers
.iter()
.map(|(k, v)| HarHeader {
name: k.clone(),
value: v.clone(),
})
.collect(),
content: HarContent {
size: resp.size as i64,
compression: None,
mime_type: resp.content_type.clone().unwrap_or_default(),
text: resp.body.clone(),
encoding: None,
},
redirect_url: String::new(),
headers_size: -1,
body_size: resp.size as i64,
},
cache: HarCache {},
timings: HarTimings {
blocked: -1.0,
dns: -1.0,
connect: -1.0,
send: 0.0,
wait: resp.ttfb_ms as f64,
receive: (resp.duration_ms - resp.ttfb_ms) as f64,
ssl: Some(-1.0),
},
server_ip_address: None,
connection: None,
pageref: Some("page_0".to_string()),
}
}
pub fn export_json(&self) -> Result<Vec<u8>> {
let json = serde_json::to_string_pretty(&self.recording)
.context("Failed to serialize recording to JSON")?;
Ok(json.into_bytes())
}
pub fn export_json_compressed(&self) -> Result<Vec<u8>> {
let json = serde_json::to_string(&self.recording)
.context("Failed to serialize recording to JSON")?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(json.as_bytes())
.context("Failed to compress JSON")?;
encoder.finish().context("Failed to finish compression")
}
pub fn export_html(&self) -> Result<Vec<u8>> {
let mut html = String::new();
html.push_str(&format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Recording - {}</title>
<style>
:root {{
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--text-primary: #e8e8e8;
--text-secondary: #a0a0a0;
--accent: #e94560;
--success: #00d4aa;
--warning: #ffc107;
--error: #dc3545;
--border: #2a2a4a;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}}
.container {{ max-width: 1400px; margin: 0 auto; padding: 20px; }}
header {{
background: var(--bg-secondary);
padding: 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}}
h1 {{ color: var(--accent); font-size: 1.5rem; margin-bottom: 10px; }}
.meta {{ display: flex; gap: 30px; flex-wrap: wrap; color: var(--text-secondary); font-size: 0.9rem; }}
.stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
}}
.stat {{
background: var(--bg-secondary);
padding: 15px;
border-radius: 8px;
border: 1px solid var(--border);
}}
.stat-value {{ font-size: 1.5rem; font-weight: bold; color: var(--accent); }}
.stat-label {{ font-size: 0.85rem; color: var(--text-secondary); }}
.timeline {{ position: relative; padding-left: 30px; }}
.timeline::before {{
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: var(--border);
}}
.event {{
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
position: relative;
}}
.event::before {{
content: '';
position: absolute;
left: -24px;
top: 20px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
}}
.event-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.event-type {{
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}}
.event-type.navigation {{ background: #3498db; }}
.event-type.network {{ background: #2ecc71; }}
.event-type.interaction {{ background: #9b59b6; }}
.event-type.console {{ background: #f39c12; }}
.event-type.error {{ background: var(--error); }}
.event-type.screenshot {{ background: #1abc9c; }}
.event-time {{ font-size: 0.8rem; color: var(--text-secondary); }}
.event-url {{ font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); word-break: break-all; }}
.event-details {{ font-size: 0.9rem; margin-top: 10px; }}
.screenshot-container {{ margin-top: 10px; }}
.screenshot-container img {{
max-width: 100%;
border: 1px solid var(--border);
border-radius: 4px;
}}
pre {{
background: var(--bg-tertiary);
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 0.85rem;
}}
.network-status {{ font-weight: bold; }}
.network-status.success {{ color: var(--success); }}
.network-status.redirect {{ color: var(--warning); }}
.network-status.error {{ color: var(--error); }}
.filter-bar {{
background: var(--bg-secondary);
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}}
.filter-btn {{
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
font-size: 0.85rem;
}}
.filter-btn:hover {{ background: var(--accent); }}
.filter-btn.active {{ background: var(--accent); border-color: var(--accent); }}
</style>
</head>
<body>
<header>
<h1>Session Recording Report</h1>
<div class="meta">
<span><strong>URL:</strong> {}</span>
<span><strong>Duration:</strong> {}ms</span>
<span><strong>Events:</strong> {}</span>
<span><strong>Started:</strong> {}</span>
</div>
</header>
<div class="container">
"#,
self.recording.id,
self.recording.start_url,
self.recording.duration_ms,
self.recording.event_count,
self.recording.started_at.format("%Y-%m-%d %H:%M:%S UTC")
));
html.push_str(&format!(
r#"
<div class="stats">
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">Navigations</div>
</div>
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">Network Requests</div>
</div>
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">DOM Interactions</div>
</div>
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">Console Messages</div>
</div>
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">Screenshots</div>
</div>
</div>
"#,
self.recording.stats.navigations,
self.recording.stats.network_requests,
self.recording.stats.dom_interactions,
self.recording.stats.console_messages,
self.recording.stats.errors,
self.recording.stats.screenshots,
));
html.push_str(
r#"
<div class="filter-bar">
<button class="filter-btn active" onclick="filterEvents('all')">All</button>
<button class="filter-btn" onclick="filterEvents('navigation')">Navigation</button>
<button class="filter-btn" onclick="filterEvents('network')">Network</button>
<button class="filter-btn" onclick="filterEvents('interaction')">Interactions</button>
<button class="filter-btn" onclick="filterEvents('console')">Console</button>
<button class="filter-btn" onclick="filterEvents('error')">Errors</button>
<button class="filter-btn" onclick="filterEvents('screenshot')">Screenshots</button>
</div>
<div class="timeline">
"#,
);
for event in &self.recording.events {
html.push_str(&self.render_event_html(event));
}
html.push_str(
r#"
</div>
</div>
<script>
function filterEvents(type) {
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('.event').forEach(el => {
if (type === 'all' || el.dataset.type === type) {
el.style.display = 'block';
} else {
el.style.display = 'none';
}
});
}
</script>
</body>
</html>
"#,
);
Ok(html.into_bytes())
}
fn render_event_html(&self, event: &SessionEvent) -> String {
let (event_type_class, event_type_label, details) = match event.event_type {
SessionEventType::Navigation => {
let Some(nav) = event.navigation.as_ref() else {
return self.render_missing_event_data("navigation", "Navigation");
};
(
"navigation",
"Navigation",
format!(
"<div class='event-url'>{}</div>",
html_escape::encode_text(&nav.url)
),
)
}
SessionEventType::NetworkRequest => {
let Some(req) = event.network_request.as_ref() else {
return self.render_missing_event_data("network", "Request");
};
(
"network",
"Request",
format!(
"<div><strong>{}</strong> <span class='event-url'>{}</span></div>",
req.method,
html_escape::encode_text(&req.url)
),
)
}
SessionEventType::NetworkResponse => {
let Some(resp) = event.network_response.as_ref() else {
return self.render_missing_event_data("network", "Response");
};
let status_class = if resp.status_code < 300 {
"success"
} else if resp.status_code < 400 {
"redirect"
} else {
"error"
};
(
"network",
"Response",
format!(
"<div><span class='network-status {}'>{} {}</span> - {} bytes in {}ms</div>",
status_class, resp.status_code, resp.status_text, resp.size, resp.duration_ms
),
)
}
SessionEventType::NetworkError => {
let Some(err) = event.network_error.as_ref() else {
return self.render_missing_event_data("error", "Network Error");
};
(
"error",
"Network Error",
format!("<div>{}</div>", html_escape::encode_text(&err.message)),
)
}
SessionEventType::Click
| SessionEventType::Input
| SessionEventType::FormSubmit
| SessionEventType::Scroll => {
let Some(dom) = event.dom_interaction.as_ref() else {
return self.render_missing_event_data("interaction", "Interaction");
};
(
"interaction",
match event.event_type {
SessionEventType::Click => "Click",
SessionEventType::Input => "Input",
SessionEventType::FormSubmit => "Form Submit",
SessionEventType::Scroll => "Scroll",
_ => "Interaction",
},
format!(
"<div><{}> {}</div>",
html_escape::encode_text(&dom.tag_name),
html_escape::encode_text(&dom.selector)
),
)
}
SessionEventType::ConsoleMessage => {
let Some(msg) = event.console_message.as_ref() else {
return self.render_missing_event_data("console", "Console");
};
(
"console",
match msg.severity {
ConsoleSeverity::Error => "Console Error",
ConsoleSeverity::Warning => "Console Warning",
_ => "Console",
},
format!("<pre>{}</pre>", html_escape::encode_text(&msg.text)),
)
}
SessionEventType::JsError => {
let Some(msg) = event.console_message.as_ref() else {
return self.render_missing_event_data("error", "JS Error");
};
(
"error",
"JS Error",
format!("<pre>{}</pre>", html_escape::encode_text(&msg.text)),
)
}
SessionEventType::Screenshot => {
let Some(ss) = event.screenshot.as_ref() else {
return self.render_missing_event_data("screenshot", "Screenshot");
};
(
"screenshot",
"Screenshot",
format!(
"<div class='screenshot-container'><img src='data:image/png;base64,{}' alt='Screenshot'/></div>",
ss.data
),
)
}
SessionEventType::CookieChange => ("network", "Cookie", String::new()),
SessionEventType::StorageChange => ("network", "Storage", String::new()),
SessionEventType::WebSocketMessage => ("network", "WebSocket", String::new()),
SessionEventType::Marker => {
let Some(label) = event.marker_label.as_ref() else {
return self.render_missing_event_data("navigation", "Marker");
};
(
"navigation",
"Marker",
format!("<div>{}</div>", html_escape::encode_text(label)),
)
}
};
format!(
r#"
<div class="event" data-type="{}">
<div class="event-header">
<span class="event-type {}">{}</span>
<span class="event-time">+{}ms</span>
</div>
<div class="event-details">{}</div>
</div>
"#,
event_type_class, event_type_class, event_type_label, event.offset_ms, details
)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarRoot {
pub log: HarLog,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarLog {
pub version: String,
pub creator: HarCreator,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser: Option<HarBrowser>,
pub pages: Vec<HarPage>,
pub entries: Vec<HarEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarCreator {
pub name: String,
pub version: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarBrowser {
pub name: String,
pub version: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarPage {
pub started_date_time: String,
pub id: String,
pub title: String,
pub page_timings: HarPageTimings,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarPageTimings {
#[serde(skip_serializing_if = "Option::is_none")]
pub on_content_load: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_load: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarEntry {
pub started_date_time: String,
pub time: f64,
pub request: HarRequest,
pub response: HarResponse,
pub cache: HarCache,
pub timings: HarTimings,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_ip_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pageref: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarRequest {
pub method: String,
pub url: String,
pub http_version: String,
pub cookies: Vec<HarCookie>,
pub headers: Vec<HarHeader>,
pub query_string: Vec<HarQueryParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_data: Option<HarPostData>,
pub headers_size: i64,
pub body_size: i64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarResponse {
pub status: i32,
pub status_text: String,
pub http_version: String,
pub cookies: Vec<HarCookie>,
pub headers: Vec<HarHeader>,
pub content: HarContent,
pub redirect_url: String,
pub headers_size: i64,
pub body_size: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarCookie {
pub name: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarHeader {
pub name: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarQueryParam {
pub name: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarPostData {
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
pub params: Vec<HarParam>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarParam {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HarContent {
pub size: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub compression: Option<i64>,
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarCache {}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarTimings {
pub blocked: f64,
pub dns: f64,
pub connect: f64,
pub send: f64,
pub wait: f64,
pub receive: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssl: Option<f64>,
}
pub struct RecordingCompression;
impl RecordingCompression {
pub fn compress(recording: &SessionRecording) -> Result<Vec<u8>> {
let json = serde_json::to_string(recording).context("Failed to serialize recording")?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(json.as_bytes())
.context("Failed to compress")?;
encoder.finish().context("Failed to finish compression")
}
pub fn decompress(data: &[u8]) -> Result<SessionRecording> {
let mut decoder = GzDecoder::new(data);
let mut json = String::new();
decoder
.read_to_string(&mut json)
.context("Failed to decompress")?;
serde_json::from_str(&json).context("Failed to parse recording")
}
pub fn compression_ratio(original: &SessionRecording, compressed: &[u8]) -> f64 {
let original_size = serde_json::to_string(original)
.map(|s| s.len())
.unwrap_or(0);
if original_size == 0 {
return 0.0;
}
1.0 - (compressed.len() as f64 / original_size as f64)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingAttachment {
pub recording_id: String,
pub start_offset_ms: u64,
pub end_offset_ms: u64,
pub relevant_event_ids: Vec<String>,
pub description: String,
}
impl SessionRecording {
pub fn create_attachment(
&self,
start_offset_ms: u64,
end_offset_ms: u64,
description: &str,
) -> RecordingAttachment {
let relevant_event_ids: Vec<String> = self
.events
.iter()
.filter(|e| e.offset_ms >= start_offset_ms && e.offset_ms <= end_offset_ms)
.map(|e| e.id.clone())
.collect();
RecordingAttachment {
recording_id: self.id.clone(),
start_offset_ms,
end_offset_ms,
relevant_event_ids,
description: description.to_string(),
}
}
pub fn extract_segment(&self, start_offset_ms: u64, end_offset_ms: u64) -> SessionRecording {
let events: Vec<SessionEvent> = self
.events
.iter()
.filter(|e| e.offset_ms >= start_offset_ms && e.offset_ms <= end_offset_ms)
.cloned()
.collect();
let mut stats = RecordingStats::default();
for event in &events {
match event.event_type {
SessionEventType::Navigation => stats.navigations += 1,
SessionEventType::NetworkRequest => stats.network_requests += 1,
SessionEventType::NetworkError => stats.network_errors += 1,
SessionEventType::Click
| SessionEventType::Input
| SessionEventType::FormSubmit
| SessionEventType::Scroll => stats.dom_interactions += 1,
SessionEventType::ConsoleMessage | SessionEventType::JsError => {
stats.console_messages += 1;
if event.event_type == SessionEventType::JsError {
stats.errors += 1;
}
}
SessionEventType::Screenshot => stats.screenshots += 1,
_ => {}
}
}
SessionRecording {
id: format!("{}-segment", self.id),
name: format!(
"{} ({}ms - {}ms)",
self.name, start_offset_ms, end_offset_ms
),
start_url: self.start_url.clone(),
started_at: self.started_at + chrono::Duration::milliseconds(start_offset_ms as i64),
ended_at: Some(self.started_at + chrono::Duration::milliseconds(end_offset_ms as i64)),
duration_ms: end_offset_ms - start_offset_ms,
event_count: events.len(),
events,
metadata: self.metadata.clone(),
stats,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_recorder_lifecycle() {
let recorder = SessionRecorder::new();
assert_eq!(recorder.state(), RecorderState::Idle);
recorder.start("https://example.com");
assert_eq!(recorder.state(), RecorderState::Recording);
assert!(recorder.is_recording());
recorder.pause();
assert_eq!(recorder.state(), RecorderState::Paused);
assert!(!recorder.is_recording());
recorder.resume();
assert_eq!(recorder.state(), RecorderState::Recording);
let recording = recorder.stop();
assert_eq!(recorder.state(), RecorderState::Stopped);
assert_eq!(recording.start_url, "https://example.com");
}
#[test]
fn test_record_navigation() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.record_navigation(NavigationEvent {
url: "https://example.com/page".to_string(),
from_url: Some("https://example.com".to_string()),
navigation_type: NavigationType::Link,
status_code: Some(200),
title: Some("Test Page".to_string()),
load_time_ms: Some(150),
timestamp: Utc::now(),
});
let recording = recorder.stop();
assert_eq!(recording.stats.navigations, 1);
assert_eq!(recording.events.len(), 1);
assert_eq!(recording.events[0].event_type, SessionEventType::Navigation);
}
#[test]
fn test_record_network_request_response() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
let request_id = "req_123".to_string();
recorder.record_network_request(NetworkRequest {
request_id: request_id.clone(),
url: "https://api.example.com/data".to_string(),
method: HttpMethod::Get,
headers: HashMap::new(),
body: None,
content_type: None,
size: 0,
timestamp: Utc::now(),
initiator: Some("fetch".to_string()),
stack_trace: None,
});
std::thread::sleep(std::time::Duration::from_millis(10));
recorder.record_network_response(NetworkResponse {
request_id: request_id.clone(),
status_code: 200,
status_text: "OK".to_string(),
headers: HashMap::new(),
body: Some("{\"data\": \"test\"}".to_string()),
body_truncated: false,
content_type: Some("application/json".to_string()),
size: 16,
ttfb_ms: 5,
duration_ms: 0, timestamp: Utc::now(),
from_cache: false,
});
let recording = recorder.stop();
assert_eq!(recording.stats.network_requests, 1);
assert_eq!(recording.events.len(), 2);
}
#[test]
fn test_record_dom_interaction() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.record_dom_interaction(DomInteraction {
interaction_type: DomInteractionType::Click,
selector: "button#submit".to_string(),
xpath: Some("/html/body/form/button".to_string()),
tag_name: "BUTTON".to_string(),
element_id: Some("submit".to_string()),
classes: vec!["btn".to_string(), "btn-primary".to_string()],
text_content: Some("Submit".to_string()),
input_value: None,
scroll_position: None,
coordinates: Some(ClickCoordinates {
x: 100,
y: 200,
viewport_x: 100,
viewport_y: 200,
}),
timestamp: Utc::now(),
});
let recording = recorder.stop();
assert_eq!(recording.stats.dom_interactions, 1);
assert_eq!(recording.events[0].event_type, SessionEventType::Click);
}
#[test]
fn test_record_console_message() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.record_console_message(ConsoleMessage {
severity: ConsoleSeverity::Error,
text: "Uncaught TypeError: Cannot read property 'foo' of undefined".to_string(),
source_url: Some("https://example.com/app.js".to_string()),
line_number: Some(42),
column_number: Some(10),
stack_trace: None,
timestamp: Utc::now(),
});
let recording = recorder.stop();
assert_eq!(recording.stats.console_messages, 1);
assert_eq!(recording.stats.errors, 1);
assert_eq!(recording.events[0].event_type, SessionEventType::JsError);
}
#[test]
fn test_add_marker() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.add_marker("vulnerability_detected");
let recording = recorder.stop();
assert_eq!(recording.events.len(), 1);
assert_eq!(recording.events[0].event_type, SessionEventType::Marker);
assert_eq!(
recording.events[0].marker_label.as_ref().unwrap(),
"vulnerability_detected"
);
}
#[test]
fn test_export_json() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.add_marker("test");
let recording = recorder.stop();
let exporter = SessionExporter::new(&recording);
let json = exporter.export(ExportFormat::Json).unwrap();
assert!(!json.is_empty());
let parsed: SessionRecording = serde_json::from_slice(&json).unwrap();
assert_eq!(parsed.start_url, "https://example.com");
}
#[test]
fn test_export_har() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.record_network_request(NetworkRequest {
request_id: "req_1".to_string(),
url: "https://example.com/api".to_string(),
method: HttpMethod::Get,
headers: HashMap::new(),
body: None,
content_type: None,
size: 0,
timestamp: Utc::now(),
initiator: None,
stack_trace: None,
});
recorder.record_network_response(NetworkResponse {
request_id: "req_1".to_string(),
status_code: 200,
status_text: "OK".to_string(),
headers: HashMap::new(),
body: Some("{}".to_string()),
body_truncated: false,
content_type: Some("application/json".to_string()),
size: 2,
ttfb_ms: 10,
duration_ms: 50,
timestamp: Utc::now(),
from_cache: false,
});
let recording = recorder.stop();
let exporter = SessionExporter::new(&recording);
let har = exporter.export(ExportFormat::Har).unwrap();
assert!(!har.is_empty());
let parsed: HarRoot = serde_json::from_slice(&har).unwrap();
assert_eq!(parsed.log.version, "1.2");
assert_eq!(parsed.log.entries.len(), 1);
}
#[test]
fn test_export_html() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.add_marker("test");
let recording = recorder.stop();
let exporter = SessionExporter::new(&recording);
let html = exporter.export(ExportFormat::Html).unwrap();
let html_str = String::from_utf8(html).unwrap();
assert!(html_str.contains("<!DOCTYPE html>"));
assert!(html_str.contains("Session Recording Report"));
assert!(html_str.contains("https://example.com"));
}
#[test]
fn test_extract_segment() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.add_marker("start");
std::thread::sleep(std::time::Duration::from_millis(50));
recorder.add_marker("middle");
std::thread::sleep(std::time::Duration::from_millis(50));
recorder.add_marker("end");
let recording = recorder.stop();
let segment = recording.extract_segment(25, 75);
assert!(segment.events.len() >= 1);
}
#[test]
fn test_create_attachment() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.add_marker("vuln_trigger");
let recording = recorder.stop();
let attachment = recording.create_attachment(0, 1000, "XSS vulnerability triggered");
assert_eq!(attachment.recording_id, recording.id);
assert!(!attachment.relevant_event_ids.is_empty());
assert_eq!(attachment.description, "XSS vulnerability triggered");
}
#[test]
fn test_http_method_from_string() {
assert_eq!(HttpMethod::from("GET"), HttpMethod::Get);
assert_eq!(HttpMethod::from("post"), HttpMethod::Post);
assert_eq!(HttpMethod::from("DELETE"), HttpMethod::Delete);
assert_eq!(HttpMethod::from("unknown"), HttpMethod::Get);
}
#[test]
fn test_recording_settings_default() {
let settings = RecordingSettings::default();
assert!(settings.capture_bodies);
assert!(settings.capture_screenshots);
assert!(settings.capture_console);
assert!(settings.redact_sensitive);
assert_eq!(settings.max_body_size, 1024 * 1024);
}
#[test]
fn test_recorder_clone() {
let recorder = SessionRecorder::new();
recorder.start("https://example.com");
recorder.add_marker("test");
let cloned = recorder.clone();
assert!(cloned.is_recording());
assert_eq!(cloned.event_count(), 1);
}
}