use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{support::xml::escape_attribute, tools::skill_provenance::digest_hex};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct FileReadProvenance {
pub(crate) identity: String,
pub(crate) tag: String,
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) sha256: String,
}
impl FileReadProvenance {
pub(crate) fn validates(&self, output: &str) -> bool {
self.tag.len() == 4
&& self.tag.bytes().all(|byte| byte.is_ascii_hexdigit())
&& self.start < self.end
&& output
.get(self.start..self.end)
.is_some_and(|body| digest_hex(body.as_bytes()) == self.sha256)
}
}
pub(crate) fn validated_file_reads(
tool_name: &str,
success: bool,
output: &str,
metadata: &Value,
) -> Vec<FileReadProvenance> {
if tool_name != "read" || !success {
return Vec::new();
}
let Some(files) = metadata
.pointer("/structured_result/files")
.and_then(Value::as_array)
else {
return Vec::new();
};
if files.len() > 8 {
return Vec::new();
}
let summaries = metadata.get("results").and_then(Value::as_array);
let mut cursor = 0;
let mut reads = Vec::new();
for (index, file) in files.iter().enumerate() {
let summary = match summaries {
Some(summaries) => summaries.get(index).unwrap_or(&Value::Null),
None => metadata,
};
let Some(text) = file.get("text").and_then(Value::as_str) else {
continue;
};
if summaries.is_some() {
let Some(path) = file.get("path").and_then(Value::as_str) else {
break;
};
let separator = if index == 0 { "" } else { "\n\n" };
let header = if text.starts_with("<File ") || text.starts_with("<Skill ") {
separator.to_string()
} else {
format!("{separator}--- FILE: {path} ---\n")
};
if !output
.get(cursor..)
.is_some_and(|tail| tail.starts_with(&header))
{
break;
}
cursor += header.len();
}
if !output
.get(cursor..)
.is_some_and(|tail| tail.starts_with(text))
{
break;
}
let start = cursor;
cursor += text.len();
if file.get("success") != Some(&Value::Bool(true))
|| file.get("complete") != Some(&Value::Bool(true))
|| file.get("request_complete") != Some(&Value::Bool(true))
|| summary.get("kind").and_then(Value::as_str) != Some("file")
|| summary.get("raw") != Some(&Value::Bool(false))
{
continue;
}
let Some(path) = summary.get("path").and_then(Value::as_str) else {
continue;
};
let Some(tag) = summary.get("hashline_tag").and_then(Value::as_str) else {
continue;
};
if path.len() > 4096 || path.chars().any(char::is_control) {
continue;
}
let header = format!("<File path=\"{}\" tag=\"{tag}\">\n", escape_attribute(path));
let (header, body, footer_len) = if let Some(body) = text
.strip_prefix(&header)
.and_then(|body| body.strip_suffix("\n</File>"))
{
(header, body, "\n</File>".len())
} else {
let header = format!("[{path}#{tag}]\n");
let Some(body) = text.strip_prefix(&header) else {
continue;
};
(header, body, 0)
};
let Some(selector) = summary.get("selector") else {
continue;
};
let identity = serde_json::json!([path, selector, tag]).to_string();
if identity.len() > 8192 {
continue;
}
let read = FileReadProvenance {
identity: digest_hex(identity.as_bytes()),
tag: tag.to_owned(),
start: start + header.len(),
end: cursor - footer_len,
sha256: digest_hex(body.as_bytes()),
};
if read.validates(output) {
reads.push(read);
}
}
reads
}