ftype.sh_探测文件类型

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
Leave a Reply

Your email address will not be published. Required fields are marked *