use std::collections::HashSet;
use std::path::Path;
use model_core::event::{DomainEvent, EventPayload, FilePayload};
use semantic_action::{SemanticAction, SemanticEvidenceKind, attr_keys as attrs, evidence_roles};
pub(super) fn observed(events: &[DomainEvent], actions: &[SemanticAction]) -> HashSet<String> {
let mut observed = events
.iter()
.filter_map(|event| match &event.payload {
EventPayload::File(payload) => Some(payload.operation.clone()),
_ => None,
})
.collect::<HashSet<_>>();
if events.iter().any(|event| {
matches!(
&event.payload,
EventPayload::File(payload) if payload.metadata.contains_key("truncate_source")
)
}) {
observed.insert("truncate".to_string());
}
for action in actions {
if action.evidence.iter().any(|evidence| {
evidence.kind == SemanticEvidenceKind::Event
&& evidence.role == evidence_roles::file::OPEN
}) {
observed.insert("open".to_string());
}
match action.kind.as_str() {
"file.read" => {
observed.insert("read".to_string());
}
"file.write" => {
observed.insert("write".to_string());
}
"file.bulk_read" => {
if positive_u64_attribute(action, attrs::file_bulk_read::READ_COUNT) {
observed.insert("read".to_string());
}
}
"file.modify" => {
if action.attributes.contains_key("truncate_source") {
observed.insert("truncate".to_string());
}
if let Some(operation) = action
.attributes
.get(attrs::file::OPERATION)
.or_else(|| action.attributes.get("operation"))
{
observed.insert(operation.clone());
}
}
_ => {}
}
}
observed
}
pub(super) fn require(
events: &[DomainEvent],
actions: &[SemanticAction],
expected_file_path: &Path,
expected_mmap: Option<(u64, u64)>,
) -> Result<(), String> {
let mut failures = Vec::new();
let mut matching_mmap = false;
for event in events {
let EventPayload::File(payload) = &event.payload else {
continue;
};
if payload.operation != "close" && payload.path.is_none() && !path_read_fault(payload) {
failures.push(format!("{} missing path", payload.operation));
}
if payload.result.is_none() {
failures.push(format!("{} missing syscall result", payload.operation));
}
match payload.operation.as_str() {
"read" | "write" => {
if !payload.metadata.contains_key("fd") {
failures.push(format!("{} missing fd", payload.operation));
}
if !payload.metadata.contains_key("size") {
failures.push(format!("{} missing transferred size", payload.operation));
}
}
"rename" => {
require_metadata(payload, "target_path", &mut failures);
require_metadata(payload, "target_path_captured_size", &mut failures);
}
"truncate" => {
if !payload.metadata.contains_key("length")
&& !payload.metadata.contains_key("truncate_source")
{
failures.push("truncate missing length or truncate_source".to_string());
}
}
"mmap_shared" => {
if let Some((expected_length, expected_offset)) = expected_mmap {
require_mmap_metadata(
payload,
expected_length,
expected_offset,
&mut matching_mmap,
&mut failures,
);
} else {
failures.push("mmap_shared observed while mmap is not configured".to_string());
}
}
"open" | "mkdir" | "rmdir" | "unlink" | "close" => {}
_ => failures.push(format!("unexpected file operation {}", payload.operation)),
}
if matches!(
payload.operation.as_str(),
"open" | "mkdir" | "rmdir" | "rename" | "unlink" | "truncate" | "mmap_shared"
) {
require_metadata(payload, "path_captured_size", &mut failures);
require_metadata(payload, "path_max_bytes", &mut failures);
require_metadata(payload, "syscall", &mut failures);
}
}
if let Some((expected_mmap_length, expected_mmap_offset)) = expected_mmap
&& !matching_mmap
{
failures.push(format!(
"mmap_shared missing expected length={} offset={} shared=true",
expected_mmap_length, expected_mmap_offset
));
}
require_workload_file_io(events, actions, expected_file_path, &mut failures);
if failures.is_empty() {
Ok(())
} else {
Err(failures.join("; "))
}
}
fn require_mmap_metadata(
payload: &FilePayload,
expected_length: u64,
expected_offset: u64,
matching_mmap: &mut bool,
failures: &mut Vec<String>,
) {
require_metadata(payload, "length", failures);
require_metadata(payload, "protection", failures);
require_metadata(payload, "flags", failures);
require_metadata(payload, "offset", failures);
require_metadata(payload, "shared", failures);
require_metadata(payload, "mapped_address", failures);
if payload
.metadata
.get("length")
.is_some_and(|value| value == &expected_length.to_string())
&& payload
.metadata
.get("offset")
.is_some_and(|value| value == &expected_offset.to_string())
&& payload
.metadata
.get("shared")
.is_some_and(|value| value == "true")
{
*matching_mmap = true;
}
}
fn require_metadata(payload: &FilePayload, key: &'static str, failures: &mut Vec<String>) {
if !payload.metadata.contains_key(key) {
failures.push(format!("{} missing {key}", payload.operation));
}
}
fn path_read_fault(payload: &FilePayload) -> bool {
payload
.metadata
.get("path_read_fault")
.is_some_and(|value| value == "true")
}
fn require_workload_file_io(
events: &[DomainEvent],
actions: &[SemanticAction],
expected_file_path: &Path,
failures: &mut Vec<String>,
) {
let expected_path = expected_file_path.display().to_string();
for (operation, action_kind, bytes_key) in [
("read", "file.read", attrs::file::BYTES_READ),
("write", "file.write", attrs::file::BYTES_WRITTEN),
] {
if has_file_event(events, operation, &expected_path)
|| has_file_action(actions, action_kind, &expected_path, bytes_key)
{
continue;
}
failures.push(format!(
"workload file {operation} missing retained event or semantic action for {expected_path}"
));
}
}
fn has_file_event(events: &[DomainEvent], operation: &str, expected_path: &str) -> bool {
events.iter().any(|event| {
let EventPayload::File(payload) = &event.payload else {
return false;
};
payload.operation == operation
&& payload.path.as_deref() == Some(expected_path)
&& payload
.metadata
.get("size")
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|size| size > 0)
})
}
fn has_file_action(
actions: &[SemanticAction],
action_kind: &str,
expected_path: &str,
bytes_key: &str,
) -> bool {
actions.iter().any(|action| {
action.kind.as_str() == action_kind
&& action
.attributes
.get(attrs::file::PATH)
.is_some_and(|path| path == expected_path)
&& positive_u64_attribute(action, bytes_key)
})
}
fn positive_u64_attribute(action: &SemanticAction, key: &str) -> bool {
action
.attributes
.get(key)
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|value| value > 0)
}