feat(detmir): add rust-first operations tooling
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
|
||||
use clap::Parser;
|
||||
use detmir_aw_client::ActivityWatchClient;
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600/api/0";
|
||||
const DEFAULT_SQLITE_PATH: &str = "data/dlp-events.sqlite3";
|
||||
const DEFAULT_STATE_PATH: &str = "data/dlp-aggregator-state.json";
|
||||
const DEFAULT_BUCKET_PREFIXES: &str = "aw-file-operations_,aw-dlp-incidents_";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Aggregate AWatch-rus DLP buckets into a local warehouse database.")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = DEFAULT_AW_URL)]
|
||||
aw_url: String,
|
||||
|
||||
#[arg(long)]
|
||||
postgres_dsn: Option<String>,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_SQLITE_PATH)]
|
||||
sqlite_path: PathBuf,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_STATE_PATH)]
|
||||
state_path: PathBuf,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_BUCKET_PREFIXES)]
|
||||
bucket_prefixes: String,
|
||||
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
|
||||
#[arg(long, default_value_t = 24)]
|
||||
lookback_hours: i64,
|
||||
|
||||
#[arg(long, default_value_t = 60)]
|
||||
overlap_seconds: i64,
|
||||
|
||||
#[arg(long, default_value_t = 10_000)]
|
||||
limit: usize,
|
||||
|
||||
#[arg(long, default_value_t = 15)]
|
||||
timeout: u64,
|
||||
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn apply_env(mut self) -> Self {
|
||||
self.aw_url = env_value("AW_URL", &self.aw_url);
|
||||
self.postgres_dsn = self
|
||||
.postgres_dsn
|
||||
.or_else(|| std::env::var("DLP_AGGREGATOR_POSTGRES_DSN").ok())
|
||||
.filter(|value| !value.is_empty());
|
||||
self.sqlite_path = env_path("DLP_AGGREGATOR_SQLITE_PATH", self.sqlite_path);
|
||||
self.state_path = env_path("DLP_AGGREGATOR_STATE_PATH", self.state_path);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Bucket {
|
||||
#[serde(default)]
|
||||
r#type: String,
|
||||
#[serde(default)]
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RawAwEvent {
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
timestamp: String,
|
||||
#[serde(default)]
|
||||
duration: f64,
|
||||
#[serde(default)]
|
||||
data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AwEvent {
|
||||
bucket_id: String,
|
||||
hostname: String,
|
||||
stream_type: String,
|
||||
event_id: String,
|
||||
timestamp: String,
|
||||
duration: f64,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
struct State {
|
||||
#[serde(default)]
|
||||
last_end: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DryRunSummary {
|
||||
aw_url: String,
|
||||
start: String,
|
||||
end: String,
|
||||
selected_buckets: Vec<String>,
|
||||
fetched_events: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RunSummary {
|
||||
aw_url: String,
|
||||
target: String,
|
||||
target_path: String,
|
||||
state_path: String,
|
||||
start: String,
|
||||
end: String,
|
||||
selected_buckets: usize,
|
||||
fetched_events: usize,
|
||||
inserted_events: usize,
|
||||
}
|
||||
|
||||
fn env_value(name: &str, fallback: &str) -> String {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
||||
fn env_path(name: &str, fallback: PathBuf) -> PathBuf {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: &str) -> Result<DateTime<Utc>> {
|
||||
let normalized = value.replace('Z', "+00:00");
|
||||
Ok(DateTime::parse_from_rfc3339(&normalized)
|
||||
.with_context(|| format!("parse timestamp {value}"))?
|
||||
.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn format_aw_timestamp(value: DateTime<Utc>) -> String {
|
||||
value.to_rfc3339_opts(SecondsFormat::Micros, true)
|
||||
}
|
||||
|
||||
fn load_state(path: &Path) -> Result<State> {
|
||||
if !path.exists() {
|
||||
return Ok(State::default());
|
||||
}
|
||||
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn save_state(path: &Path, state: &State) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent)
|
||||
.with_context(|| format!("create temp file in {}", parent.display()))?;
|
||||
serde_json::to_writer_pretty(&mut temp, state)?;
|
||||
std::io::Write::write_all(&mut temp, b"\n")?;
|
||||
temp.persist(path)
|
||||
.map_err(|err| anyhow!("persist {}: {}", path.display(), err))?;
|
||||
return Ok(());
|
||||
}
|
||||
fs::write(path, serde_json::to_string_pretty(state)? + "\n")
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
fn get_start_time(cli: &Cli, state: &State) -> Result<DateTime<Utc>> {
|
||||
if let Some(since) = &cli.since {
|
||||
return parse_timestamp(since);
|
||||
}
|
||||
if let Some(last_end) = &state.last_end {
|
||||
return Ok(parse_timestamp(last_end)? - TimeDelta::seconds(cli.overlap_seconds));
|
||||
}
|
||||
Ok(Utc::now() - TimeDelta::hours(cli.lookback_hours))
|
||||
}
|
||||
|
||||
fn parse_prefixes(value: &str) -> Result<Vec<String>> {
|
||||
let prefixes: Vec<String> = value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
if prefixes.is_empty() {
|
||||
bail!("at least one bucket prefix is required");
|
||||
}
|
||||
Ok(prefixes)
|
||||
}
|
||||
|
||||
fn bucket_stream_type(bucket_id: &str, bucket: &Bucket) -> Option<&'static str> {
|
||||
if bucket_id.starts_with("aw-file-operations_") || bucket.r#type == "aw.file.operation" {
|
||||
return Some("file_operation");
|
||||
}
|
||||
if bucket_id.starts_with("aw-dlp-incidents_") || bucket.r#type == "aw.dlp.incident" {
|
||||
return Some("dlp_incident");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn select_buckets(
|
||||
buckets: &BTreeMap<String, Bucket>,
|
||||
prefixes: &[String],
|
||||
) -> Vec<(String, Bucket, &'static str)> {
|
||||
buckets
|
||||
.iter()
|
||||
.filter_map(|(bucket_id, bucket)| {
|
||||
let stream_type = bucket_stream_type(bucket_id, bucket)?;
|
||||
if prefixes.iter().any(|prefix| bucket_id.starts_with(prefix)) {
|
||||
Some((bucket_id.clone(), bucket.clone(), stream_type))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_events_path(
|
||||
bucket_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
limit: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"/buckets/{}/events?start={}&end={}&limit={}",
|
||||
urlencoding::encode(bucket_id),
|
||||
urlencoding::encode(&format_aw_timestamp(start)),
|
||||
urlencoding::encode(&format_aw_timestamp(end)),
|
||||
limit
|
||||
)
|
||||
}
|
||||
|
||||
fn normalized_data(value: Value) -> Value {
|
||||
if value.is_object() {
|
||||
value
|
||||
} else {
|
||||
json!({ "raw": value })
|
||||
}
|
||||
}
|
||||
|
||||
fn event_key(bucket_id: &str, timestamp: &str, duration: f64, data: &Value) -> String {
|
||||
let payload = serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string());
|
||||
format!("{bucket_id}|{timestamp}|{duration}|{payload}")
|
||||
}
|
||||
|
||||
fn event_id(bucket_id: &str, event: &RawAwEvent, data: &Value) -> String {
|
||||
match &event.id {
|
||||
Some(Value::String(value)) if !value.is_empty() => value.clone(),
|
||||
Some(Value::Number(value)) => value.to_string(),
|
||||
Some(Value::Bool(value)) => value.to_string(),
|
||||
_ => event_key(bucket_id, &event.timestamp, event.duration, data),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_bucket_events(
|
||||
client: &ActivityWatchClient,
|
||||
bucket_id: &str,
|
||||
bucket: &Bucket,
|
||||
stream_type: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AwEvent>> {
|
||||
let path = build_events_path(bucket_id, start, end, limit);
|
||||
let raw_events: Vec<RawAwEvent> = client.get_json(&path)?;
|
||||
Ok(raw_events
|
||||
.into_iter()
|
||||
.map(|raw| {
|
||||
let data = normalized_data(raw.data.clone());
|
||||
let hostname = if bucket.hostname.is_empty() {
|
||||
first_string(&data, &["hostname"]).unwrap_or_default()
|
||||
} else {
|
||||
bucket.hostname.clone()
|
||||
};
|
||||
AwEvent {
|
||||
bucket_id: bucket_id.to_string(),
|
||||
hostname,
|
||||
stream_type: stream_type.to_string(),
|
||||
event_id: event_id(bucket_id, &raw, &data),
|
||||
timestamp: raw.timestamp,
|
||||
duration: raw.duration,
|
||||
data,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn first_string(data: &Value, keys: &[&str]) -> Option<String> {
|
||||
let object = data.as_object()?;
|
||||
for key in keys {
|
||||
if let Some(value) = object.get(*key) {
|
||||
match value {
|
||||
Value::Null => {}
|
||||
Value::String(s) if !s.is_empty() => return Some(s.clone()),
|
||||
other => {
|
||||
let rendered = other.to_string();
|
||||
if !rendered.is_empty() {
|
||||
return Some(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bool_as_int(value: Option<&Value>) -> i64 {
|
||||
match value {
|
||||
Some(Value::Bool(value)) => i64::from(*value),
|
||||
Some(Value::String(value)) => i64::from(matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "y"
|
||||
)),
|
||||
Some(Value::Number(value)) => i64::from(value.as_i64().unwrap_or(0) != 0),
|
||||
Some(Value::Null) | None => 0,
|
||||
Some(_) => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_schema(connection: &Connection) -> Result<()> {
|
||||
connection.execute_batch(
|
||||
r#"
|
||||
create table if not exists dlp_events (
|
||||
id integer primary key autoincrement,
|
||||
bucket_id text not null,
|
||||
event_id text not null,
|
||||
stream_type text not null,
|
||||
hostname text not null,
|
||||
username text,
|
||||
event_ts text not null,
|
||||
duration real not null default 0,
|
||||
operation text,
|
||||
file_path text,
|
||||
old_file_path text,
|
||||
extension text,
|
||||
archive_hint integer not null default 0,
|
||||
rule_id text,
|
||||
action text,
|
||||
severity text,
|
||||
signal_type text,
|
||||
message text,
|
||||
source text,
|
||||
screenshot_path text,
|
||||
raw_json text not null,
|
||||
ingested_at text not null,
|
||||
unique (bucket_id, event_id)
|
||||
);
|
||||
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
||||
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
||||
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
||||
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
||||
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
||||
|
||||
create view if not exists dlp_file_operations as
|
||||
select *
|
||||
from dlp_events
|
||||
where stream_type = 'file_operation';
|
||||
|
||||
create view if not exists dlp_incidents as
|
||||
select *
|
||||
from dlp_events
|
||||
where stream_type = 'dlp_incident';
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect_sqlite(path: &Path) -> Result<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let connection = Connection::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||
connection.execute_batch(
|
||||
r#"
|
||||
pragma journal_mode=WAL;
|
||||
pragma synchronous=NORMAL;
|
||||
pragma foreign_keys=ON;
|
||||
"#,
|
||||
)?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn insert_events(connection: &mut Connection, events: &[AwEvent]) -> Result<usize> {
|
||||
let ingested_at = format_aw_timestamp(Utc::now());
|
||||
let tx = connection.transaction()?;
|
||||
let mut inserted = 0;
|
||||
{
|
||||
let mut statement = tx.prepare(
|
||||
r#"
|
||||
insert or ignore into dlp_events (
|
||||
bucket_id,
|
||||
event_id,
|
||||
stream_type,
|
||||
hostname,
|
||||
username,
|
||||
event_ts,
|
||||
duration,
|
||||
operation,
|
||||
file_path,
|
||||
old_file_path,
|
||||
extension,
|
||||
archive_hint,
|
||||
rule_id,
|
||||
action,
|
||||
severity,
|
||||
signal_type,
|
||||
message,
|
||||
source,
|
||||
screenshot_path,
|
||||
raw_json,
|
||||
ingested_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)?;
|
||||
for event in events {
|
||||
let raw_json = serde_json::to_string(&event.data)?;
|
||||
let archive_hint = bool_as_int(event.data.get("archiveHint"));
|
||||
let row_count = statement.execute(params![
|
||||
event.bucket_id,
|
||||
event.event_id,
|
||||
event.stream_type,
|
||||
event.hostname,
|
||||
first_string(&event.data, &["username", "user"]),
|
||||
event.timestamp,
|
||||
event.duration,
|
||||
first_string(&event.data, &["operation"]),
|
||||
first_string(&event.data, &["path", "filePath"]),
|
||||
first_string(&event.data, &["oldPath", "oldFilePath"]),
|
||||
first_string(&event.data, &["extension"]),
|
||||
archive_hint,
|
||||
first_string(&event.data, &["ruleId", "rule"]),
|
||||
first_string(&event.data, &["action"]),
|
||||
first_string(&event.data, &["severity"]),
|
||||
first_string(&event.data, &["signalType"]),
|
||||
first_string(&event.data, &["message"]),
|
||||
first_string(&event.data, &["source"]),
|
||||
first_string(
|
||||
&event.data,
|
||||
&["screenshotPath", "capturePath", "artifactPath"]
|
||||
),
|
||||
raw_json,
|
||||
ingested_at,
|
||||
])?;
|
||||
inserted += usize::from(row_count > 0);
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<()> {
|
||||
if cli.postgres_dsn.is_some() {
|
||||
bail!(
|
||||
"PostgreSQL mode is not supported by the Rust DLP aggregator yet; use SQLite mode or the legacy Python script"
|
||||
);
|
||||
}
|
||||
let prefixes = parse_prefixes(&cli.bucket_prefixes)?;
|
||||
let state = load_state(&cli.state_path)?;
|
||||
let start = get_start_time(cli, &state)?;
|
||||
let end = Utc::now();
|
||||
let client = ActivityWatchClient::new(&cli.aw_url, Duration::from_secs(cli.timeout))?;
|
||||
let buckets: BTreeMap<String, Bucket> = client.get_json("/buckets")?;
|
||||
let selected = select_buckets(&buckets, &prefixes);
|
||||
let mut events = Vec::new();
|
||||
for (bucket_id, bucket, stream_type) in &selected {
|
||||
events.extend(fetch_bucket_events(
|
||||
&client,
|
||||
bucket_id,
|
||||
bucket,
|
||||
stream_type,
|
||||
start,
|
||||
end,
|
||||
cli.limit,
|
||||
)?);
|
||||
}
|
||||
|
||||
if cli.dry_run {
|
||||
let summary = DryRunSummary {
|
||||
aw_url: cli.aw_url.clone(),
|
||||
start: format_aw_timestamp(start),
|
||||
end: format_aw_timestamp(end),
|
||||
selected_buckets: selected
|
||||
.iter()
|
||||
.map(|(bucket_id, _bucket, _stream_type)| bucket_id.clone())
|
||||
.collect(),
|
||||
fetched_events: events.len(),
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&summary)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut connection = connect_sqlite(&cli.sqlite_path)?;
|
||||
ensure_schema(&connection)?;
|
||||
let inserted = insert_events(&mut connection, &events)?;
|
||||
save_state(
|
||||
&cli.state_path,
|
||||
&State {
|
||||
last_end: Some(format_aw_timestamp(end)),
|
||||
},
|
||||
)?;
|
||||
let summary = RunSummary {
|
||||
aw_url: cli.aw_url.clone(),
|
||||
target: "sqlite".to_string(),
|
||||
target_path: cli.sqlite_path.display().to_string(),
|
||||
state_path: cli.state_path.display().to_string(),
|
||||
start: format_aw_timestamp(start),
|
||||
end: format_aw_timestamp(end),
|
||||
selected_buckets: selected.len(),
|
||||
fetched_events: events.len(),
|
||||
inserted_events: inserted,
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&summary)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse().apply_env();
|
||||
run(&cli)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selects_expected_bucket_prefixes() {
|
||||
let buckets = BTreeMap::from([
|
||||
(
|
||||
"aw-file-operations_HOST".to_string(),
|
||||
Bucket {
|
||||
r#type: "aw.file.operation".to_string(),
|
||||
hostname: "HOST".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"aw-dlp-incidents_HOST".to_string(),
|
||||
Bucket {
|
||||
r#type: "aw.dlp.incident".to_string(),
|
||||
hostname: "HOST".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"aw-watcher-window_HOST".to_string(),
|
||||
Bucket {
|
||||
r#type: "currentwindow".to_string(),
|
||||
hostname: "HOST".to_string(),
|
||||
},
|
||||
),
|
||||
]);
|
||||
let selected = select_buckets(
|
||||
&buckets,
|
||||
&parse_prefixes("aw-file-operations_,aw-dlp-incidents_").unwrap(),
|
||||
);
|
||||
assert_eq!(selected.len(), 2);
|
||||
assert_eq!(selected[0].2, "dlp_incident");
|
||||
assert_eq!(selected[1].2, "file_operation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_row_helpers_match_python_contract() {
|
||||
let data = json!({
|
||||
"username": "igor",
|
||||
"path": "C:/x.zip",
|
||||
"oldFilePath": "C:/x.tmp",
|
||||
"archiveHint": "true",
|
||||
"rule": "archive",
|
||||
"signalType": "file_operation"
|
||||
});
|
||||
assert_eq!(
|
||||
first_string(&data, &["username", "user"]).as_deref(),
|
||||
Some("igor")
|
||||
);
|
||||
assert_eq!(
|
||||
first_string(&data, &["oldPath", "oldFilePath"]).as_deref(),
|
||||
Some("C:/x.tmp")
|
||||
);
|
||||
assert_eq!(bool_as_int(data.get("archiveHint")), 1);
|
||||
assert_eq!(
|
||||
first_string(&data, &["ruleId", "rule"]).as_deref(),
|
||||
Some("archive")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_events_once() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("dlp.sqlite");
|
||||
let mut connection = connect_sqlite(&path).unwrap();
|
||||
ensure_schema(&connection).unwrap();
|
||||
let event = AwEvent {
|
||||
bucket_id: "aw-file-operations_HOST".to_string(),
|
||||
hostname: "HOST".to_string(),
|
||||
stream_type: "file_operation".to_string(),
|
||||
event_id: "1".to_string(),
|
||||
timestamp: "2026-05-31T10:00:00Z".to_string(),
|
||||
duration: 0.0,
|
||||
data: json!({"username":"igor","archiveHint":true}),
|
||||
};
|
||||
assert_eq!(
|
||||
insert_events(&mut connection, std::slice::from_ref(&event)).unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(insert_events(&mut connection, &[event]).unwrap(), 0);
|
||||
let count: i64 = connection
|
||||
.query_row("select count(*) from dlp_events", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user