ftype.sh_探测文件类型
#fype-find-file-type
#!/bin/sh
# ==============================================================================
# ftype.sh —— 通过文件头(magic bytes)识别常见文件类型
#
# 纯 POSIX sh 实现(dash / bash / busybox ash 均可运行),只依赖
# dd / od / tr / cut / awk / sed 这些基础命令。
#
# 用法:
# ftype.sh [选项] 文件...
# ftype.sh -x "1f8b08" # 直接判断一段十六进制文件头
# cat xxx | ftype.sh # 无文件参数时读取标准输入
#
# 选项:
# -m, --mime 只输出 MIME 类型
# -b, --brief 不输出 "文件名: " 前缀
# -d, --deep 深入探测:压缩包解压后识别内部真实类型
# (gzip/xz/bzip2/zstd 解压探测;zip 识别 docx/xlsx/jar/apk 等)
# -x, --hex STR 直接对给定的十六进制字符串(文件头)做判断
# -s, --selftest 生成样本自检,验证各魔数规则是否正确
# -h, --help 显示帮助
#
# 退出码: 0 全部成功 / 1 有文件无法读取 / 2 用法错误
# ==============================================================================
set -u
PROG=${0##*/}
OPT_MIME=0
OPT_BRIEF=0
OPT_DEEP=0
OPT_HEX=""
FILES=""
# ------------------------------------------------------------------ 帮助 ----
usage() {
cat <&2; exit 2; }
# ------------------------------------------------------- 参数解析 ----------
while [ $# -gt 0 ]; do
case $1 in
-m|--mime) OPT_MIME=1; shift ;;
-b|--brief) OPT_BRIEF=1; shift ;;
-d|--deep) OPT_DEEP=1; shift ;;
-x|--hex) [ $# -ge 2 ] || die "$1 缺少参数"; OPT_HEX=$2; shift 2 ;;
-s|--selftest) SELFTEST=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "未知选项: $1" ;;
*) FILES="$FILES $1"; shift ;;
esac
done
while [ $# -gt 0 ]; do FILES="$FILES $1"; shift; done
# =============================================================================
# 基础工具函数
# =============================================================================
# 读文件前 512 字节,返回紧凑的十六进制字符串(小写、无空格)
hex_head() {
dd if="$1" bs=512 count=1 2>/dev/null | od -An -v -tx1 | tr -d ' \n'
}
# 读文件任意偏移处的 n 字节,返回十六进制字符串
# 用法: hex_at <文件> <字节偏移> <长度>
hex_at() {
dd if="$1" bs=1 skip="$2" count="$3" 2>/dev/null | od -An -v -tx1 | tr -d ' \n'
}
# 从十六进制字符串中截取 [offset, offset+len) 字节
# 用法: hex_field <字节偏移> <长度>
hex_field() {
printf '%s' "$1" | cut -c $(( $2 * 2 + 1 ))-$(( ($2 + $3) * 2 ))
}
# 十六进制串 -> 空格分隔的十进制字节(给 awk 兜底逻辑用)
hex_to_dec() {
printf '%s\n' "$1" | awk '{
s = $0; n = length(s)
for (i = 1; i + 1 <= n; i += 2) {
hi = index("0123456789abcdef", tolower(substr(s, i, 1))) - 1
lo = index("0123456789abcdef", tolower(substr(s, i + 1, 1))) - 1
if (hi < 0) hi = 0
if (lo < 0) lo = 0
printf "%d ", hi * 16 + lo
}
print ""
}'
}
# ---------------------------------------------------------------------------
# 兜底判定:从十进制字节流(stdin)判断 tar 校验和 / 文本 / 二进制
# 输出: tar | text | binary
# tar 的老格式(v7)在偏移 257 没有 "ustar" 魔数,只能靠 header 校验和识别
# ---------------------------------------------------------------------------
classify_tail() {
awk '
{ for (i = 1; i <= NF; i++) b[c++] = $1 * 0 + $i }
END {
if (c >= 512) {
s = ""
for (i = 148; i < 156; i++) s = s sprintf("%c", b[i])
sub(/[^0-7].*$/, "", s) # 只保留前导八进制数字
if (length(s) >= 6) {
stored = 0
for (i = 1; i <= length(s); i++)
stored = stored * 8 + (index("01234567", substr(s, i, 1)) - 1)
sum = 0
for (i = 0; i < 512; i++)
sum += (i >= 148 && i < 156) ? 32 : b[i] # 校验字段按空格计算
if (stored == sum) { print "tar"; exit }
}
}
nul = 0; odd = 0
for (i = 0; i < c; i++) {
v = b[i]
if (v == 0) nul++
if (v < 7 || (v >= 14 && v < 32) || v == 127) odd++
}
if (nul == 0 && odd == 0) print "text"; else print "binary"
}'
}
# =============================================================================
# 核心:由十六进制文件头判定类型
# 结果写入全局变量 _MIME / _DESC;无法识别时 _MIME 为空
# =============================================================================
_MIME=""
_DESC=""
classify_hex() {
local h="${1:-}"
local sub=""
_MIME=""; _DESC=""
# ---------------- 空 ----------------
if [ -z "$h" ]; then
_MIME="application/x-empty"; _DESC="空文件"; return
fi
# ---------------- 偏移 0 处的魔数 ----------------
case $h in
# ---- 压缩 / 归档 ----
1f8b*) _MIME="application/gzip"; _DESC="gzip 压缩数据" ;;
425a68*) _MIME="application/x-bzip2"; _DESC="bzip2 压缩数据" ;;
fd377a585a00*) _MIME="application/x-xz"; _DESC="xz 压缩数据" ;;
377abcaf271c*) _MIME="application/x-7z-compressed"; _DESC="7-Zip 归档" ;;
28b52ffd*) _MIME="application/zstd"; _DESC="Zstandard 压缩数据" ;;
04224d18*) _MIME="application/x-lz4"; _DESC="LZ4 压缩数据" ;;
1f9d*) _MIME="application/x-compress"; _DESC="Unix compress (.Z) 压缩数据" ;;
526172211a07*) _MIME="application/x-rar-compressed"; _DESC="RAR 归档" ;;
213c617263683e*) _MIME="application/x-archive"; _DESC="Unix ar 归档(静态库 / deb)" ;;
303730373031*) _MIME="application/x-cpio"; _DESC="cpio 归档 (newc)" ;;
303730373037*) _MIME="application/x-cpio"; _DESC="cpio 归档 (odc)" ;;
5041434b*) _MIME="application/x-git-packed-objects"; _DESC="Git pack 文件" ;;
504b0304*|504b0506*|504b0708*)
_MIME="application/zip"
_DESC="ZIP 归档(可能是 docx/xlsx/pptx/jar/apk/odt/whl)" ;;
# ---- 图片 ----
89504e470d0a1a0a*) _MIME="image/png"; _DESC="PNG 图片" ;;
ffd8ff*) _MIME="image/jpeg"; _DESC="JPEG 图片" ;;
4749463837*|4749463839*) _MIME="image/gif"; _DESC="GIF 图片" ;;
424d*) _MIME="image/bmp"; _DESC="BMP 图片" ;;
49492a00*|4d4d002a*) _MIME="image/tiff"; _DESC="TIFF 图片" ;;
00000100*) _MIME="image/vnd.microsoft.icon"; _DESC="ICO 图标" ;;
# ---- 音频 / 视频 ----
4f676753*) _MIME="audio/ogg"; _DESC="Ogg 音频/视频" ;;
664c6143*) _MIME="audio/x-flac"; _DESC="FLAC 无损音频" ;;
1a45dfa3*) _MIME="video/x-matroska"; _DESC="Matroska / WebM 视频" ;;
464c56*) _MIME="video/x-flv"; _DESC="Flash FLV 视频" ;;
494433*) _MIME="audio/mpeg"; _DESC="MP3 音频 (ID3)" ;;
fffb*|fff3*|fff2*|fffa*|fff7*) _MIME="audio/mpeg"; _DESC="MP3 音频 (MPEG 帧同步)" ;;
52494646*) # RIFF 容器,真实类型在偏移 8
sub=$(hex_field "$h" 8 4)
case $sub in
57454250) _MIME="image/webp"; _DESC="WebP 图片" ;;
57415645) _MIME="audio/x-wav"; _DESC="WAV 音频" ;;
41564920) _MIME="video/x-msvideo"; _DESC="AVI 视频" ;;
*) _MIME="audio/x-riff"; _DESC="RIFF 容器(未知子类型)" ;;
esac ;;
# ---- 文档 ----
25504446*) _MIME="application/pdf"; _DESC="PDF 文档" ;;
d0cf11e0a1b11ae1*) _MIME="application/x-ole-storage"
_DESC="OLE2 复合文档(旧版 doc/xls/ppt)" ;;
7b5c727466*) _MIME="application/rtf"; _DESC="RTF 富文本" ;;
# ---- 可执行 / 字节码 ----
7f454c46*) _MIME="application/x-executable"; _DESC="ELF 可执行文件/共享库" ;;
cafebabe*) _MIME="application/java-vm"; _DESC="Java class 字节码" ;;
cafed00d*) _MIME="application/x-java-pack200"; _DESC="Java pack200" ;;
0061736d01000000*) _MIME="application/wasm"; _DESC="WebAssembly 模块" ;;
cffaedfe*|cefaedfe*|feedfacf*|feedface*)
_MIME="application/x-mach-binary"; _DESC="Mach-O 可执行文件 (macOS)" ;;
4d5a*) _MIME="application/x-dosexec"; _DESC="DOS/Windows 可执行程序 (MZ)" ;;
# ---- 数据库 / 其他 ----
53514c69746520666f726d6174203300*)
_MIME="application/vnd.sqlite3"; _DESC="SQLite 3 数据库" ;;
# ---- 文本类 ----
efbbbf*) _MIME="text/plain"; _DESC="带 UTF-8 BOM 的文本" ;;
fffe*|feff*) _MIME="text/plain"; _DESC="UTF-16 文本(带 BOM)" ;;
3c3f786d6c*) _MIME="text/xml"; _DESC="XML 文本(/dev/null 2>&1 || return 1
ih=$("$cmd" -dc -- "$f" 2>/dev/null | dd bs=512 count=1 2>/dev/null | od -An -v -tx1 | tr -d ' \n')
[ -n "$ih" ] || return 1
classify_hex "$ih"
_IN_MIME=$_MIME; _IN_DESC=$_DESC
[ -n "$_IN_MIME" ]
}
# zip 内部探测:docx / xlsx / pptx / jar / apk / odt ...
probe_zip() {
local f="$1" list="" mt=""
_IN_MIME=""; _IN_DESC=""
command -v unzip >/dev/null 2>&1 || return 1
list=$(unzip -l -- "$f" 2>/dev/null) || return 1
case $list in
*"[Content_Types].xml"*)
case $list in
*word/*) _IN_MIME="application/vnd.openxmlformats-officedocument.wordprocessingml.document"; _IN_DESC="Word 文档 (.docx)" ;;
*xl/*) _IN_MIME="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; _IN_DESC="Excel 表格 (.xlsx)" ;;
*ppt/*) _IN_MIME="application/vnd.openxmlformats-officedocument.presentationml.presentation"; _IN_DESC="PowerPoint (.pptx)" ;;
*) _IN_MIME="application/vnd.openxmlformats-officedocument"; _IN_DESC="OOXML 文档" ;;
esac ;;
*"AndroidManifest.xml"*)
_IN_MIME="application/vnd.android.package-archive"; _IN_DESC="Android 安装包 (.apk)" ;;
*"META-INF/MANIFEST.MF"*)
_IN_MIME="application/java-archive"; _IN_DESC="Java JAR 包" ;;
*)
mt=$(unzip -p -- "$f" mimetype 2>/dev/null)
case $mt in
*opendocument.text*) _IN_MIME="application/vnd.oasis.opendocument.text"; _IN_DESC="OpenDocument 文本 (.odt)" ;;
*opendocument.spreadsheet*) _IN_MIME="application/vnd.oasis.opendocument.spreadsheet"; _IN_DESC="OpenDocument 表格 (.ods)" ;;
*opendocument.presentation*) _IN_MIME="application/vnd.oasis.opendocument.presentation"; _IN_DESC="OpenDocument 演示 (.odp)" ;;
*epub*) _IN_MIME="application/epub+zip"; _IN_DESC="EPUB 电子书" ;;
*) return 1 ;;
esac ;;
esac
return 0
}
# =============================================================================
# 单个文件的完整识别流程
# =============================================================================
classify_file() {
local f="$1" h="" tail="" iso=""
_MIME=""; _DESC=""
# 特殊文件类型先处理,避免读设备/FIFO 卡死
if [ -d "$f" ]; then _MIME="inode/directory"; _DESC="目录"; return 0; fi
if [ -c "$f" ]; then _MIME="inode/chardevice"; _DESC="字符设备"; return 0; fi
if [ -b "$f" ]; then _MIME="inode/blockdevice"; _DESC="块设备"; return 0; fi
if [ -p "$f" ]; then _MIME="inode/fifo"; _DESC="命名管道 (FIFO)"; return 0; fi
h=$(hex_head "$f")
# 空文件
if [ -z "$h" ]; then
if [ -s "$f" ]; then _MIME="application/octet-stream"; _DESC="无法读取(权限不足?)"; return 1; fi
_MIME="application/x-empty"; _DESC="空文件"; return 0
fi
classify_hex "$h"
# ---- deep:压缩包内部探测 ----
if [ "$OPT_DEEP" = 1 ] && [ -n "$_MIME" ]; then
local om=$_MIME on=$_DESC nm=$_MIME nd=$_DESC
case $om in
application/gzip) probe_inner "$f" gzip && nd="gzip 压缩数据 —— 内部是 $_IN_DESC(.tar.gz 之类)" ;;
application/x-xz) probe_inner "$f" xz && nd="xz 压缩数据 —— 内部是 $_IN_DESC" ;;
application/x-bzip2) probe_inner "$f" bzip2 && nd="bzip2 压缩数据 —— 内部是 $_IN_DESC" ;;
application/zstd) probe_inner "$f" zstd && nd="zstd 压缩数据 —— 内部是 $_IN_DESC" ;;
application/zip) probe_zip "$f" && { nm=$_IN_MIME; nd="ZIP 归档 —— $_IN_DESC"; } ;;
esac
_MIME=$nm; _DESC=$nd # 探测不到内部类型时 nm/nd 仍是外层结果
fi
[ -n "$_MIME" ] && return 0
# ---- 偏移 32769 的 ISO9660 ----
iso=$(hex_at "$f" 32769 5)
if [ "$iso" = "4344303031" ]; then
_MIME="application/x-iso9660-image"; _DESC="ISO 9660 光盘镜像"; return 0
fi
# ---- 兜底:tar 校验和 / 文本 / 二进制 ----
tail=$(dd if="$f" bs=512 count=1 2>/dev/null | od -An -v -tu1 | classify_tail)
case $tail in
tar) _MIME="application/x-tar"; _DESC="tar 归档 (v7 老格式,靠校验和识别)" ;;
text) _MIME="text/plain"; _DESC="纯文本" ;;
*) _MIME="application/octet-stream"; _DESC="未知二进制数据" ;;
esac
return 0
}
# =============================================================================
# 输出
# =============================================================================
emit() {
local name="${NAME_OVERRIDE:-$1}"
local body
if [ "$OPT_MIME" = 1 ]; then body="$_MIME"; else body="$_DESC [$_MIME]"; fi
if [ "$OPT_BRIEF" = 1 ]; then printf '%s\n' "$body"; else printf '%s: %s\n' "$name" "$body"; fi
}
# =============================================================================
# 自检
# =============================================================================
selftest() {
local pass=0 fail=0
_SELFTEST_DIR=$(mktemp -d 2>/dev/null) || _SELFTEST_DIR=${TMPDIR:-/tmp}/ftype_selftest.$$
dir=$_SELFTEST_DIR
mkdir -p "$dir" || die "无法创建临时目录"
trap 'rm -rf "$_SELFTEST_DIR"' EXIT INT TERM
printf 'hello\n' > "$dir/plain.txt"
tar -cf "$dir/s.tar" -C "$dir" plain.txt 2>/dev/null
tar --format=v7 -cf "$dir/v7.tar" -C "$dir" plain.txt 2>/dev/null
# 格式: "期望MIME|文件名|printf 格式串"
local samples="
application/gzip|gzip.bin|\037\213\010\000\000\000\000\000\000\003
application/x-bzip2|bz2.bin|BZh9\000\000
application/x-xz|xz.bin|\3757zXZ\000\000\001
application/x-7z-compressed|7z.bin|7z\274\257\047\034\000\004
application/zstd|zstd.bin|\050\265\057\375\000\000
application/zip|zip.bin|PK\003\004\024\000
application/x-rar-compressed|rar.bin|Rar!\032\007\000
application/x-archive|ar.bin|!\n
application/x-cpio|cpio.bin|070701\000\000
image/png|png.bin|\211PNG\r\n\032\n
image/jpeg|jpg.bin|\377\330\377\340\000\020JFIF
image/gif|gif.bin|GIF89a\000\000
image/bmp|bmp.bin|BM\000\000\000\000
image/tiff|tif.bin|II*\000\010\000
image/webp|webp.bin|RIFF\000\000\000\000WEBPVP8
audio/x-wav|wav.bin|RIFF\000\000\000\000WAVEfmt
video/x-msvideo|avi.bin|RIFF\000\000\000\000AVI
video/mp4|mp4.bin|\000\000\000\030ftypisom\000\000
video/quicktime|mov.bin|\000\000\000\030ftypqt \000\000
image/heic|heic.bin|\000\000\000\030ftypheic\000\000
audio/mpeg|mp3.bin|ID3\003\000\000\000
audio/x-flac|flac.bin|fLaC\000\000
video/x-matroska|mkv.bin|\032\105\337\243\001\000
application/pdf|pdf.bin|%PDF-1.7\n
application/x-ole-storage|doc.bin|\320\317\021\340\241\261\032\341\000\000
application/rtf|rtf.bin|{\\\\rtf1\\\\ansi
application/x-executable|elf.bin|\177ELF\002\001\001\000
application/x-dosexec|exe.bin|MZ\220\000\003\000
application/java-vm|class.bin|\312\376\272\276\000\000
application/wasm|wasm.bin|\000asm\001\000\000\000
application/x-mach-binary|macho.bin|\317\372\355\376\007\000
application/vnd.sqlite3|db.bin|SQLite format 3\000
text/x-shellscript|sh.bin|#!/bin/sh\nexit 0
application/x-tar|s.tar|
application/x-tar|v7.tar|
text/plain|plain.txt|
"
local IFS='
'
printf '=== ftype.sh 自检 ===\n'
for line in $samples; do
line=$(printf '%s' "$line" | sed 's/^ *//')
[ -n "$line" ] || continue
local want=${line%%|*}
local rest=${line#*|}
local nm=${rest%%|*}
local fmt=${rest#*|}
local p="$dir/$nm"
if [ -n "$fmt" ]; then printf '%b' "$fmt" > "$p"; fi
[ -f "$p" ] || { printf ' SKIP %s(样本缺失)\n' "$nm"; continue; }
OPT_MIME=1; OPT_BRIEF=1; OPT_DEEP=0
classify_file "$p"
if [ "$_MIME" = "$want" ]; then
pass=$((pass + 1))
printf ' \033[32mPASS\033[0m %-16s %s\n' "$nm" "$_MIME"
else
fail=$((fail + 1))
printf ' \033[31mFAIL\033[0m %-16s 期望 %s, 实得 %s (%s)\n' "$nm" "$want" "$_MIME" "$_DESC"
fi
done
printf '=== 通过 %d, 失败 %d ===\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
}
# =============================================================================
# main
# =============================================================================
if [ "${SELFTEST:-0}" = 1 ]; then
selftest
exit $?
fi
# --hex 模式
if [ -n "$OPT_HEX" ]; then
case $OPT_HEX in
*[!0-9a-fA-F]*) die "--hex 只接受十六进制字符(0-9 a-f),收到: $OPT_HEX" ;;
esac
hex=$(printf '%s' "$OPT_HEX" | tr 'A-F' 'a-f')
classify_hex "$hex"
if [ -z "$_MIME" ]; then
tail=$(hex_to_dec "$hex" | classify_tail)
case $tail in
text) _MIME="text/plain"; _DESC="纯文本" ;;
*) _MIME="application/octet-stream"; _DESC="未能识别的类型" ;;
esac
fi
OPT_BRIEF=1
emit "(hex) $OPT_HEX"
exit 0
fi
# 无文件参数 -> 读标准输入
if [ -z "$FILES" ]; then
tmp=$(mktemp 2>/dev/null) || tmp=${TMPDIR:-/tmp}/ftype_stdin.$$
trap 'rm -f "$tmp"' EXIT INT TERM
dd bs=64k count=1 2>/dev/null > "$tmp"
FILES=$tmp
NAME_OVERRIDE="(标准输入)"
fi
rc=0
for f in $FILES; do
case $f in -*) f="./$f" ;; esac
if [ ! -e "$f" ]; then
printf '%s: 无法访问(文件或路径不存在)\n' "$f" >&2; rc=1; continue
fi
if [ ! -r "$f" ]; then
printf '%s: 权限不足,无法读取\n' "$f" >&2; rc=1; continue
fi
classify_file "$f" || rc=1
emit "$f"
done
exit $rc