1use anyhow::{Context, Result};
2use clap::Subcommand;
3use std::path::PathBuf;
4
5#[derive(Subcommand)]
6pub enum FiletypeAction {
7 #[command(about = "Identify file type from magic bytes")]
8 Identify {
9 #[arg(help = "Input as hex string (or use --file)")]
10 input: Option<String>,
11 #[arg(long, help = "Read input from a file path")]
12 file: Option<PathBuf>,
13 },
14}
15
16pub struct Signature {
17 pub name: &'static str,
18 pub description: &'static str,
19 pub offset: usize,
20}
21
22pub fn run(action: FiletypeAction) -> Result<()> {
23 match action {
24 FiletypeAction::Identify { input, file } => {
25 let data = match (input, file) {
26 (Some(_), Some(_)) => {
27 anyhow::bail!("Provide exactly one of <input> or --file, not both")
28 }
29 (None, None) => {
30 anyhow::bail!("Provide exactly one of <input> or --file")
31 }
32 (Some(hex_str), None) => {
33 hex::decode(hex_str.trim()).context("Failed to decode input as hex")?
34 }
35 (None, Some(path)) => std::fs::read(&path)
36 .with_context(|| format!("Failed to read file: {}", path.display()))?,
37 };
38
39 let matches = identify(&data);
40 if matches.is_empty() {
41 println!("No known file type signature matched");
42 } else {
43 for sig in matches {
44 println!("{} - {} (offset {})", sig.name, sig.description, sig.offset);
45 }
46 }
47 }
48 }
49 Ok(())
50}
51
52fn matches_at(data: &[u8], offset: usize, magic: &[u8]) -> bool {
53 data.len() >= offset + magic.len() && &data[offset..offset + magic.len()] == magic
54}
55
56fn starts_with(data: &[u8], magic: &[u8]) -> bool {
57 matches_at(data, 0, magic)
58}
59
60pub fn identify(data: &[u8]) -> Vec<Signature> {
61 let mut out = Vec::new();
62
63 let push = |out: &mut Vec<Signature>, name, description, offset| {
64 out.push(Signature {
65 name,
66 description,
67 offset,
68 });
69 };
70
71 if starts_with(data, &[0x89, 0x50, 0x4E, 0x47]) {
72 push(&mut out, "PNG", "Portable Network Graphics image", 0);
73 }
74 if starts_with(data, &[0xFF, 0xD8, 0xFF]) {
75 push(&mut out, "JPEG", "JPEG image", 0);
76 }
77 if starts_with(data, b"GIF87a") {
78 push(&mut out, "GIF", "GIF image (87a)", 0);
79 }
80 if starts_with(data, b"GIF89a") {
81 push(&mut out, "GIF", "GIF image (89a)", 0);
82 }
83 if starts_with(data, b"BM") {
84 push(&mut out, "BMP", "Bitmap image", 0);
85 }
86 if starts_with(data, &[0x00, 0x00, 0x01, 0x00]) {
87 push(&mut out, "ICO", "Windows icon", 0);
88 }
89
90 if starts_with(data, b"%PDF") {
91 push(&mut out, "PDF", "Portable Document Format", 0);
92 }
93
94 if starts_with(data, &[0x50, 0x4B, 0x03, 0x04]) {
95 push(&mut out, "ZIP", "ZIP archive (also JAR/APK/DOCX/etc.)", 0);
96 }
97 if starts_with(data, &[0x50, 0x4B, 0x05, 0x06]) {
98 push(&mut out, "ZIP", "ZIP archive (empty)", 0);
99 }
100 if starts_with(data, &[0x50, 0x4B, 0x07, 0x08]) {
101 push(&mut out, "ZIP", "ZIP archive (spanned)", 0);
102 }
103 if starts_with(data, &[0x1F, 0x8B]) {
104 push(&mut out, "GZIP", "gzip compressed data", 0);
105 }
106 if starts_with(data, b"BZh") {
107 push(&mut out, "BZIP2", "bzip2 compressed data", 0);
108 }
109 if starts_with(data, &[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) {
110 push(&mut out, "XZ", "XZ compressed data", 0);
111 }
112 if starts_with(data, &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) {
113 push(&mut out, "7Z", "7-Zip archive", 0);
114 }
115 if starts_with(data, &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00]) {
116 push(&mut out, "RAR", "RAR archive (v5)", 0);
117 } else if starts_with(data, &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00]) {
118 push(&mut out, "RAR", "RAR archive (v4)", 0);
119 }
120
121 if starts_with(data, &[0x7F, 0x45, 0x4C, 0x46]) {
122 push(&mut out, "ELF", "ELF executable", 0);
123 }
124 if starts_with(data, b"MZ") {
125 push(&mut out, "PE", "DOS/PE executable (MZ)", 0);
126 }
127 if starts_with(data, &[0xCA, 0xFE, 0xBA, 0xBE]) {
128 push(&mut out, "Java class", "Java class file", 0);
129 push(&mut out, "Mach-O", "Mach-O universal (fat) binary", 0);
130 }
131 if starts_with(data, &[0xFE, 0xED, 0xFA, 0xCE]) {
132 push(&mut out, "Mach-O", "Mach-O executable (32-bit)", 0);
133 }
134 if starts_with(data, &[0xFE, 0xED, 0xFA, 0xCF]) {
135 push(&mut out, "Mach-O", "Mach-O executable (64-bit)", 0);
136 }
137
138 if starts_with(data, &[0x49, 0x44, 0x33]) {
139 push(&mut out, "MP3", "MP3 audio (ID3 tag)", 0);
140 }
141 if starts_with(data, &[0xFF, 0xFB]) {
142 push(&mut out, "MP3", "MP3 audio (MPEG frame)", 0);
143 }
144 if starts_with(data, b"OggS") {
145 push(&mut out, "OGG", "Ogg container", 0);
146 }
147 if starts_with(data, b"fLaC") {
148 push(&mut out, "FLAC", "FLAC audio", 0);
149 }
150 if starts_with(data, b"RIFF") {
151 if matches_at(data, 8, b"WAVE") {
152 push(&mut out, "WAV", "WAVE audio (RIFF)", 0);
153 } else if matches_at(data, 8, b"WEBP") {
154 push(&mut out, "WEBP", "WebP image (RIFF)", 0);
155 } else if matches_at(data, 8, b"AVI ") {
156 push(&mut out, "AVI", "AVI video (RIFF)", 0);
157 } else {
158 push(&mut out, "RIFF", "RIFF container (unknown form)", 0);
159 }
160 }
161
162 if starts_with(data, b"SQLite format 3\0") {
163 push(&mut out, "SQLite", "SQLite 3 database", 0);
164 }
165
166 if matches_at(data, 257, b"ustar") {
167 push(&mut out, "TAR", "tar archive (ustar)", 257);
168 }
169
170 out
171}