use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use std::path::Path;
use digest::Digest;
use crate::interpreter::{ExecResult, OutputData, OutputNode};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
pub struct Checksum;
#[derive(Parser, Debug)]
#[command(name = "checksum", about = "Compute or verify file hashes")]
struct ChecksumArgs {
#[arg(short = 'a', long = "algo")]
algo: Option<String>,
#[arg(short = 'c', long = "check")]
check: Option<String>,
#[arg(long = "sha256")]
sha256: bool,
#[arg(long = "sha1")]
sha1: bool,
#[arg(long = "md5")]
md5: bool,
#[command(flatten)]
global: GlobalFlags,
paths: Vec<String>,
}
#[async_trait]
impl Tool for Checksum {
fn name(&self) -> &str {
"checksum"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&ChecksumArgs::command(),
"checksum",
"Compute or verify file hashes",
[
("SHA256 of a file", "checksum README.md"),
("MD5", "checksum -a md5 file.tar.gz"),
("Hash stdin", "echo hello | checksum"),
("Verify", "checksum -c checksums.txt"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match ChecksumArgs::try_parse_from(
std::iter::once("checksum".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("checksum: {e}")),
};
parsed.global.apply(ctx);
let algo = parsed.algo.clone()
.or_else(|| args.get_string("algo", usize::MAX))
.or_else(|| {
if parsed.sha256 {
Some("sha256".into())
} else if parsed.sha1 {
Some("sha1".into())
} else if parsed.md5 {
Some("md5".into())
} else {
None
}
})
.unwrap_or_else(|| "sha256".to_string());
if !matches!(algo.as_str(), "sha256" | "sha1" | "md5") {
return ExecResult::failure(
1,
format!("checksum: unknown algorithm '{}' (use sha256, sha1, or md5)", algo),
);
}
if let Some(check_path) = parsed.check.clone().or_else(|| args.get_string("check", usize::MAX)) {
return self.verify_checksums(ctx, &check_path, &algo).await;
}
let paths = match ctx.expand_paths(&args.positional).await {
Ok(p) => p,
Err(e) => return ExecResult::failure(1, format!("checksum: {}", e)),
};
if paths.is_empty() {
let input = ctx.read_stdin_to_string().await.unwrap_or_default();
let hash = compute_hash(input.as_bytes(), &algo);
let text = format!("{} -", hash);
let node = OutputNode::new(&hash).with_cells(vec!["-".to_string(), algo]);
return ExecResult::with_output_and_text(
OutputData::table(
vec!["HASH".to_string(), "FILE".to_string(), "ALGO".to_string()],
vec![node],
),
text,
);
}
let mut nodes = Vec::new();
let mut text_lines = Vec::new();
for path in &paths {
let resolved = ctx.resolve_path(path);
match ctx.backend.read(Path::new(&resolved), None).await {
Ok(data) => {
let hash = compute_hash(&data, &algo);
let line = format!("{} {}", hash, path);
nodes.push(
OutputNode::new(&hash)
.with_cells(vec![path.clone(), algo.clone()]),
);
text_lines.push(line);
}
Err(e) => {
return ExecResult::failure(1, format!("checksum: {}: {}", path, e));
}
}
}
let text = text_lines.join("\n");
let output = OutputData::table(
vec!["HASH".to_string(), "FILE".to_string(), "ALGO".to_string()],
nodes,
);
ExecResult::with_output_and_text(output, text)
}
}
impl Checksum {
async fn verify_checksums(
&self,
ctx: &mut ExecContext,
check_path: &str,
algo: &str,
) -> ExecResult {
let resolved = ctx.resolve_path(check_path);
let data = match ctx.backend.read(Path::new(&resolved), None).await {
Ok(d) => d,
Err(e) => {
return ExecResult::failure(1, format!("checksum: {}: {}", check_path, e))
}
};
let content = match String::from_utf8(data) {
Ok(s) => s,
Err(_) => {
return ExecResult::failure(
1,
format!("checksum: {}: invalid UTF-8", check_path),
)
}
};
let mut failures = 0;
let mut output = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let (expected_hash, rest) = match line.split_once(' ') {
Some(parts) => parts,
None => {
output.push_str(&format!("{}: FAILED (malformed line)\n", line));
failures += 1;
continue;
}
};
let filename = rest.trim_start_matches([' ', '*']);
let file_resolved = ctx.resolve_path(filename);
match ctx.backend.read(Path::new(&file_resolved), None).await {
Ok(file_data) => {
let actual_hash = compute_hash(&file_data, algo);
if actual_hash == expected_hash {
output.push_str(&format!("{}: OK\n", filename));
} else {
output.push_str(&format!("{}: FAILED\n", filename));
failures += 1;
}
}
Err(e) => {
output.push_str(&format!("{}: FAILED ({})\n", filename, e));
failures += 1;
}
}
}
if output.ends_with('\n') {
output.pop();
}
if failures > 0 {
ExecResult::from_output(1, output, format!("checksum: {} computed checksum(s) did NOT match", failures))
} else {
ExecResult::with_output(OutputData::text(output))
}
}
}
fn compute_hash(data: &[u8], algo: &str) -> String {
match algo {
"sha256" => hex_encode(sha2::Sha256::digest(data).as_slice()),
"sha1" => hex_encode(sha1::Sha1::digest(data).as_slice()),
"md5" => hex_encode(md5::Md5::digest(data).as_slice()),
_ => unreachable!("algorithm validated before calling compute_hash"),
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("hello.txt"), b"hello")
.await
.expect("write failed");
mem.write(Path::new("world.txt"), b"world")
.await
.expect("write failed");
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_sha256_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional
.push(Value::String("/hello.txt".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(result.ok());
let out = result.text_out();
assert!(out.contains(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
));
assert!(out.contains("/hello.txt"));
}
#[tokio::test]
async fn test_md5_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional
.push(Value::String("/hello.txt".into()));
args.named
.insert("algo".to_string(), Value::String("md5".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("5d41402abc4b2a76b9719d911017c592"));
}
#[tokio::test]
async fn test_sha1_stdin() {
let mut ctx = make_ctx().await;
ctx.set_stdin("hello".to_string());
let mut args = ToolArgs::new();
args.named
.insert("algo".to_string(), Value::String("sha1".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(result.ok());
let out = result.text_out();
assert!(out.contains("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"));
assert!(out.contains('-')); }
#[tokio::test]
async fn test_multiple_files() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional
.push(Value::String("/hello.txt".into()));
args.positional
.push(Value::String("/world.txt".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(result.ok());
let out = result.text_out();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("/hello.txt"));
assert!(lines[1].contains("/world.txt"));
}
#[tokio::test]
async fn test_verify_ok() {
let hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
let checksum_content = format!("{} /hello.txt", hash);
let mem = MemoryFs::new();
mem.write(Path::new("hello.txt"), b"hello")
.await
.expect("write failed");
mem.write(
Path::new("sums.txt"),
checksum_content.as_bytes(),
)
.await
.expect("write failed");
let mut vfs = VfsRouter::new();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let mut args = ToolArgs::new();
args.named
.insert("check".to_string(), Value::String("/sums.txt".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("OK"));
}
#[tokio::test]
async fn test_verify_fail() {
let checksum_content = "0000000000000000000000000000000000000000000000000000000000000000 /hello.txt";
let mem = MemoryFs::new();
mem.write(Path::new("hello.txt"), b"hello")
.await
.expect("write failed");
mem.write(Path::new("sums.txt"), checksum_content.as_bytes())
.await
.expect("write failed");
let mut vfs = VfsRouter::new();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let mut args = ToolArgs::new();
args.named
.insert("check".to_string(), Value::String("/sums.txt".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.text_out().contains("FAILED"));
}
#[tokio::test]
async fn test_unknown_algo() {
let mut ctx = make_ctx().await;
ctx.set_stdin("data".to_string());
let mut args = ToolArgs::new();
args.named
.insert("algo".to_string(), Value::String("blake3".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("unknown algorithm"));
}
#[tokio::test]
async fn test_file_not_found() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional
.push(Value::String("/nope.txt".into()));
let result = Checksum.execute(args, &mut ctx).await;
assert!(!result.ok());
}
}