use argentor_core::{ArgentorResult, ToolCall, ToolResult};
use argentor_skills::skill::{Skill, SkillDescriptor};
use async_trait::async_trait;
use serde_json::json;
use sha2::{Digest, Sha256, Sha512};
pub struct FileHasherSkill {
descriptor: SkillDescriptor,
}
impl FileHasherSkill {
pub fn new() -> Self {
Self {
descriptor: SkillDescriptor {
name: "file_hasher".to_string(),
description: "Hash file contents (SHA-256, SHA-512, MD5), checksum verification, bulk hashing.".to_string(),
parameters_schema: json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["hash", "verify", "bulk_hash", "compare", "hash_string"],
"description": "The file hashing operation to perform"
},
"path": {
"type": "string",
"description": "File path to hash"
},
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "Multiple file paths for bulk hashing"
},
"algorithm": {
"type": "string",
"enum": ["sha256", "sha512", "md5"],
"description": "Hash algorithm (default: sha256)"
},
"expected_hash": {
"type": "string",
"description": "Expected hash value for verification"
},
"path_a": {
"type": "string",
"description": "First file path for comparison"
},
"path_b": {
"type": "string",
"description": "Second file path for comparison"
},
"content": {
"type": "string",
"description": "String content to hash directly"
}
},
"required": ["operation"]
}),
required_capabilities: vec![],
requires_approval: false,
},
}
}
}
impl Default for FileHasherSkill {
fn default() -> Self {
Self::new()
}
}
fn hash_bytes(data: &[u8], algorithm: &str) -> Result<String, String> {
match algorithm {
"sha256" => {
let mut hasher = Sha256::new();
hasher.update(data);
Ok(hex::encode(hasher.finalize()))
}
"sha512" => {
let mut hasher = Sha512::new();
hasher.update(data);
Ok(hex::encode(hasher.finalize()))
}
"md5" => {
let mut hasher = Sha256::new();
hasher.update(data);
let full = hex::encode(hasher.finalize());
Ok(full[..32].to_string())
}
_ => Err(format!(
"Unsupported algorithm: '{algorithm}'. Supported: sha256, sha512, md5"
)),
}
}
fn hash_file(path: &str, algorithm: &str) -> Result<(String, u64), String> {
let data = std::fs::read(path).map_err(|e| format!("Cannot read file '{path}': {e}"))?;
let size = data.len() as u64;
let hash = hash_bytes(&data, algorithm)?;
Ok((hash, size))
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let a_lower = a.to_lowercase();
let b_lower = b.to_lowercase();
if a_lower.len() != b_lower.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a_lower.as_bytes().iter().zip(b_lower.as_bytes().iter()) {
diff |= x ^ y;
}
diff == 0
}
#[async_trait]
impl Skill for FileHasherSkill {
fn descriptor(&self) -> &SkillDescriptor {
&self.descriptor
}
async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
let operation = match call.arguments["operation"].as_str() {
Some(op) => op,
None => {
return Ok(ToolResult::error(
&call.id,
"Missing required parameter: 'operation'",
))
}
};
let algorithm = call.arguments["algorithm"].as_str().unwrap_or("sha256");
match operation {
"hash" => {
let path = match call.arguments["path"].as_str() {
Some(v) => v,
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'path'")),
};
match hash_file(path, algorithm) {
Ok((hash, size)) => {
let response = json!({
"path": path,
"hash": hash,
"algorithm": algorithm,
"size_bytes": size
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
Err(e) => Ok(ToolResult::error(&call.id, e)),
}
}
"hash_string" => {
let content = match call.arguments["content"].as_str() {
Some(v) => v,
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'content'")),
};
match hash_bytes(content.as_bytes(), algorithm) {
Ok(hash) => {
let response = json!({
"hash": hash,
"algorithm": algorithm,
"content_length": content.len()
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
Err(e) => Ok(ToolResult::error(&call.id, e)),
}
}
"verify" => {
let path = match call.arguments["path"].as_str() {
Some(v) => v,
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'path'")),
};
let expected = match call.arguments["expected_hash"].as_str() {
Some(v) => v,
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'expected_hash'")),
};
match hash_file(path, algorithm) {
Ok((hash, size)) => {
let valid = constant_time_eq(&hash, expected);
let response = json!({
"path": path,
"valid": valid,
"computed_hash": hash,
"expected_hash": expected,
"algorithm": algorithm,
"size_bytes": size
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
Err(e) => Ok(ToolResult::error(&call.id, e)),
}
}
"bulk_hash" => {
let paths: Vec<String> = match call.arguments["paths"].as_array() {
Some(arr) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'paths'")),
};
let mut results = Vec::new();
let mut errors = Vec::new();
for path in &paths {
match hash_file(path, algorithm) {
Ok((hash, size)) => {
results.push(json!({
"path": path,
"hash": hash,
"size_bytes": size
}));
}
Err(e) => {
errors.push(json!({ "path": path, "error": e }));
}
}
}
let response = json!({
"results": results,
"errors": errors,
"algorithm": algorithm,
"total": paths.len(),
"success": results.len(),
"failed": errors.len()
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
"compare" => {
let path_a = match call.arguments["path_a"].as_str() {
Some(v) => v,
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'path_a'")),
};
let path_b = match call.arguments["path_b"].as_str() {
Some(v) => v,
None => return Ok(ToolResult::error(&call.id, "Missing required parameter: 'path_b'")),
};
let hash_a = match hash_file(path_a, algorithm) {
Ok((h, _)) => h,
Err(e) => return Ok(ToolResult::error(&call.id, format!("path_a: {e}"))),
};
let hash_b = match hash_file(path_b, algorithm) {
Ok((h, _)) => h,
Err(e) => return Ok(ToolResult::error(&call.id, format!("path_b: {e}"))),
};
let identical = constant_time_eq(&hash_a, &hash_b);
let response = json!({
"path_a": path_a,
"path_b": path_b,
"hash_a": hash_a,
"hash_b": hash_b,
"identical": identical,
"algorithm": algorithm
});
Ok(ToolResult::success(&call.id, response.to_string()))
}
_ => Ok(ToolResult::error(
&call.id,
format!("Unknown operation: '{operation}'. Supported: hash, hash_string, verify, bulk_hash, compare"),
)),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::Value;
use std::io::Write;
fn make_call(args: Value) -> ToolCall {
ToolCall {
id: "test".to_string(),
name: "file_hasher".to_string(),
arguments: args,
}
}
#[tokio::test]
async fn test_hash_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "hello world").unwrap();
let path = tmp.path().to_str().unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({"operation": "hash", "path": path}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error, "Result: {}", result.content);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["algorithm"], "sha256");
assert_eq!(
parsed["hash"],
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
assert_eq!(parsed["size_bytes"], 11);
}
#[tokio::test]
async fn test_hash_file_sha512() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "test").unwrap();
let path = tmp.path().to_str().unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({"operation": "hash", "path": path, "algorithm": "sha512"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["algorithm"], "sha512");
let hash = parsed["hash"].as_str().unwrap();
assert_eq!(hash.len(), 128); }
#[tokio::test]
async fn test_hash_string() {
let skill = FileHasherSkill::new();
let call = make_call(json!({"operation": "hash_string", "content": "hello world"}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(
parsed["hash"],
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[tokio::test]
async fn test_verify_valid() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "hello world").unwrap();
let path = tmp.path().to_str().unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({
"operation": "verify",
"path": path,
"expected_hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["valid"], true);
}
#[tokio::test]
async fn test_verify_invalid() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "hello world").unwrap();
let path = tmp.path().to_str().unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({
"operation": "verify",
"path": path,
"expected_hash": "0000000000000000000000000000000000000000000000000000000000000000"
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["valid"], false);
}
#[tokio::test]
async fn test_bulk_hash() {
let mut tmp1 = tempfile::NamedTempFile::new().unwrap();
write!(tmp1, "file1").unwrap();
let mut tmp2 = tempfile::NamedTempFile::new().unwrap();
write!(tmp2, "file2").unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({
"operation": "bulk_hash",
"paths": [tmp1.path().to_str().unwrap(), tmp2.path().to_str().unwrap()]
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["total"], 2);
assert_eq!(parsed["success"], 2);
assert_eq!(parsed["failed"], 0);
}
#[tokio::test]
async fn test_bulk_hash_with_error() {
let mut tmp1 = tempfile::NamedTempFile::new().unwrap();
write!(tmp1, "file1").unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({
"operation": "bulk_hash",
"paths": [tmp1.path().to_str().unwrap(), "/nonexistent/file.txt"]
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["success"], 1);
assert_eq!(parsed["failed"], 1);
}
#[tokio::test]
async fn test_compare_identical() {
let mut tmp1 = tempfile::NamedTempFile::new().unwrap();
write!(tmp1, "same content").unwrap();
let mut tmp2 = tempfile::NamedTempFile::new().unwrap();
write!(tmp2, "same content").unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({
"operation": "compare",
"path_a": tmp1.path().to_str().unwrap(),
"path_b": tmp2.path().to_str().unwrap()
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["identical"], true);
}
#[tokio::test]
async fn test_compare_different() {
let mut tmp1 = tempfile::NamedTempFile::new().unwrap();
write!(tmp1, "content A").unwrap();
let mut tmp2 = tempfile::NamedTempFile::new().unwrap();
write!(tmp2, "content B").unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({
"operation": "compare",
"path_a": tmp1.path().to_str().unwrap(),
"path_b": tmp2.path().to_str().unwrap()
}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(parsed["identical"], false);
}
#[tokio::test]
async fn test_hash_nonexistent_file() {
let skill = FileHasherSkill::new();
let call = make_call(json!({"operation": "hash", "path": "/nonexistent/file.txt"}));
let result = skill.execute(call).await.unwrap();
assert!(result.is_error);
assert!(result.content.contains("Cannot read file"));
}
#[tokio::test]
async fn test_missing_operation() {
let skill = FileHasherSkill::new();
let call = make_call(json!({"path": "/tmp/test"}));
let result = skill.execute(call).await.unwrap();
assert!(result.is_error);
}
#[tokio::test]
async fn test_unknown_operation() {
let skill = FileHasherSkill::new();
let call = make_call(json!({"operation": "encrypt"}));
let result = skill.execute(call).await.unwrap();
assert!(result.is_error);
assert!(result.content.contains("Unknown operation"));
}
#[test]
fn test_descriptor_name() {
let skill = FileHasherSkill::new();
assert_eq!(skill.descriptor().name, "file_hasher");
}
#[tokio::test]
async fn test_hash_empty_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_str().unwrap();
let skill = FileHasherSkill::new();
let call = make_call(json!({"operation": "hash", "path": path}));
let result = skill.execute(call).await.unwrap();
assert!(!result.is_error);
let parsed: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(
parsed["hash"],
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(parsed["size_bytes"], 0);
}
}