use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use kaish_glob::{FileType, SNIFF_PREFIX_LEN};
use kaish_types::ReadRange;
use crate::interpreter::{ExecResult, OutputData, OutputNode};
use crate::tools::{schema_from_clap, ExecContext, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema};
pub struct File;
#[derive(Parser, Debug)]
#[command(name = "file", about = "Identify file type by content (magic bytes)")]
struct FileArgs {
#[arg(short = 'i', long = "mime")]
mime: bool,
#[arg(short = 'b', long = "brief")]
brief: bool,
#[command(flatten)]
global: GlobalFlags,
paths: Vec<String>,
}
#[async_trait]
impl Tool for File {
fn name(&self) -> &str {
"file"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&FileArgs::command(),
"file",
"Identify file type by content (magic bytes), not by extension",
[
("Identify a file", "file photo"),
("MIME type only", "file -i photo"),
("No filename prefix", "file -b photo"),
("Identify stdin", "cat photo | file"),
],
)
}
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 argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("file: {e}")),
};
let parsed = match FileArgs::try_parse_from(
std::iter::once("file".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("file: {e}")),
};
parsed.global.apply(ctx);
if args.positional.is_empty() {
let bytes = match ctx.read_stdin_to_bytes().await {
Ok(b) => b.unwrap_or_default(),
Err(e) => return ExecResult::failure(1, format!("file: {e}")),
};
let id = Identity::of(&bytes);
let text = render_line("-", &id.describe(parsed.mime), parsed.brief);
return ExecResult::with_output_and_text(
OutputData::table(headers(), vec![OutputNode::new("-").with_cells(id.cells())]),
text,
);
}
let paths = match ctx.expand_paths(&args.positional).await {
Ok(p) => p,
Err(e) => return ExecResult::failure(1, format!("file: {}", e)),
};
let mut nodes = Vec::new();
let mut lines = Vec::new();
for path in &paths {
let resolved = ctx.resolve_path(path);
let head = match ctx
.backend
.read(&resolved, Some(ReadRange::bytes(0, SNIFF_PREFIX_LEN as u64)))
.await
{
Ok(b) => b,
Err(e) => return ExecResult::failure(1, format!("file: {}: {}", path, e)),
};
let id = Identity::of(&head);
nodes.push(OutputNode::new(path).with_cells(id.cells()));
lines.push(render_line(path, &id.describe(parsed.mime), parsed.brief));
}
ExecResult::with_output_and_text(
OutputData::table(headers(), nodes),
lines.join("\n"),
)
}
}
fn headers() -> Vec<String> {
vec!["FILE".to_string(), "TYPE".to_string(), "MIME".to_string()]
}
enum Identity {
Known(FileType),
Empty,
Opaque,
}
impl Identity {
fn of(bytes: &[u8]) -> Self {
if bytes.is_empty() {
Identity::Empty
} else {
match kaish_glob::classify(bytes) {
Some(ft) => Identity::Known(ft),
None => Identity::Opaque,
}
}
}
fn parts(&self) -> (&str, &str) {
match self {
Identity::Known(ft) => (ft.category.as_str(), ft.mime),
Identity::Empty => ("empty", "inode/x-empty"),
Identity::Opaque => ("data", "application/octet-stream"),
}
}
fn describe(&self, mime_only: bool) -> String {
let (word, mime) = self.parts();
if mime_only {
mime.to_string()
} else {
match self {
Identity::Known(_) => format!("{word} ({mime})"),
_ => word.to_string(),
}
}
}
fn cells(&self) -> Vec<String> {
let (word, mime) = self.parts();
vec![word.to_string(), mime.to_string()]
}
}
fn render_line(name: &str, desc: &str, brief: bool) -> String {
if brief {
desc.to_string()
} else {
format!("{}: {}", name, desc)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::path::Path;
use std::sync::Arc;
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
const WAV: &[u8] = b"RIFF\x24\x00\x00\x00WAVEfmt ";
async fn ctx_with(files: &[(&str, &[u8])]) -> ExecContext {
let mem = MemoryFs::new();
for (name, bytes) in files {
mem.write(Path::new(name), bytes).await.expect("write failed");
}
let mut vfs = VfsRouter::new();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
fn arg(path: &str) -> ToolArgs {
let mut a = ToolArgs::new();
a.positional.push(Value::String(path.into()));
a
}
#[tokio::test]
async fn identifies_png_by_content() {
let mut ctx = ctx_with(&[("photo.bin", PNG)]).await;
let result = File.execute(arg("/photo.bin"), &mut ctx).await;
assert!(result.ok());
let out = result.text_out();
assert!(out.contains("/photo.bin:"), "got: {out}");
assert!(out.contains("image"), "got: {out}");
assert!(out.contains("image/png"), "got: {out}");
}
#[tokio::test]
async fn extension_does_not_lie() {
let mut ctx = ctx_with(&[("notes.txt", PNG)]).await;
let result = File.execute(arg("/notes.txt"), &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("image"));
}
#[tokio::test]
async fn wav_reports_audio() {
let mut ctx = ctx_with(&[("sound", WAV)]).await;
let result = File.execute(arg("/sound"), &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("audio"), "got: {}", result.text_out());
}
#[tokio::test]
async fn plain_text_reports_text() {
let mut ctx = ctx_with(&[("notes", b"just some plain prose, no magic\n")]).await;
let result = File.execute(arg("/notes"), &mut ctx).await;
assert!(result.ok());
assert_eq!(result.text_out(), "/notes: text (text/plain)");
}
#[tokio::test]
async fn opaque_binary_reports_data_not_a_guess() {
let mut ctx = ctx_with(&[("blob", b"\x00\x01\x02\xff\xfe not utf8 \x80")]).await;
let result = File.execute(arg("/blob"), &mut ctx).await;
assert!(result.ok());
assert_eq!(result.text_out(), "/blob: data");
}
#[tokio::test]
async fn empty_file_reports_empty() {
let mut ctx = ctx_with(&[("nothing", b"")]).await;
let result = File.execute(arg("/nothing"), &mut ctx).await;
assert!(result.ok());
assert_eq!(result.text_out(), "/nothing: empty");
let mut a = arg("/nothing");
a.flags.insert("mime".into());
let result = File.execute(a, &mut ctx).await;
assert_eq!(result.text_out(), "/nothing: inode/x-empty");
}
#[tokio::test]
async fn mime_flag_prints_mime_only() {
let mut ctx = ctx_with(&[("photo", PNG)]).await;
let mut a = arg("/photo");
a.flags.insert("mime".into());
let result = File.execute(a, &mut ctx).await;
assert_eq!(result.text_out(), "/photo: image/png");
}
#[tokio::test]
async fn brief_flag_drops_filename() {
let mut ctx = ctx_with(&[("photo", PNG)]).await;
let mut a = arg("/photo");
a.flags.insert("brief".into());
let result = File.execute(a, &mut ctx).await;
assert_eq!(result.text_out(), "image (image/png)");
}
#[tokio::test]
async fn reads_stdin_when_no_paths() {
let mut ctx = ctx_with(&[]).await;
ctx.set_stdin("GIF89a".to_string());
let result = File.execute(ToolArgs::new(), &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("image"), "got: {}", result.text_out());
assert!(result.text_out().starts_with("-:"), "got: {}", result.text_out());
}
#[tokio::test]
async fn missing_file_is_an_error() {
let mut ctx = ctx_with(&[]).await;
let result = File.execute(arg("/nope"), &mut ctx).await;
assert!(!result.ok());
}
}