前言

将线上打包系统开放给业务部门后,常常能收到打包失败的邮件,排查后发现大多数是因为业务部门人员在上传应用图标时都上传了 JPG 格式的图片,而 Android 中要求应用图标必须为 PNG 格式。

Android 图标格式错误

出现这种情况大概率是业务人员和设计部门直接通过微信发送文件,导致文件格式在传输过程中发生了变更。

与业务部门说明后发现效果仍无改善,遂与 Web 端同事沟通,限制应用图标的上传格式为 PNG。重新部署后,打包失败率下降了,但偶尔还是会有相同的原因导致打包失败,于是再次排查原因。

排查后发现,业务人员确实是上传的 .png 为后缀的文件,但实际上这个文件却仍旧是 JPG 格式。询问 Web 端同事收到的答复是仅在上传时限制了文件后缀。

这种方法肯定是不合理的,今天就来讲讲文件格式的判断。

Filename Extension

我们最初接触计算机时,就习惯把文件后缀叫做文件类型,文件后缀正确的叫法应该是文件扩展名(Filename Extension),它是早期操作系统用来标志文件格式的一种机制,更重要的作用是让系统决定运行该文件的应用。

选择运行文件的应用

也就是说,这是开发者和操作系统之间的一种约定,与文件的实际类型并没有绝对关系。

文件头

那么操作系统根据什么来判断文件类型呢?

答案是文件头。

文件头是位于文件开头的一段承担一定任务的数据,一般都在开头的部分。

许多文本编辑器都可以查看文件的十六进制数据,你也可以使用『WinHex』或者『Hex Fiend』等工具查看:

PNG 文件的 Hex 数据

位于开头的部分数据就是文件头。

当然有的文件没有文件头,比如 TXT。

文件头包含的信息可以很丰富,比如图片类型的文件就可以将图像的尺寸等信息放在文件头,那么图像浏览软件就可以识别出具体参数。

Magic Number

文件头可以判断文件类型,但这也说得太笼统,因为文件头可以包含一些其他的信息。而真正用来判断文件类型的,是 Magic Number。

Magic Number 可以翻译为幻数,它是一些文件格式规范所要求的特殊标签值,表示文件符合这种规范。

Magic Number 的取值往往很随意,这也是为什么它叫 Magic Number 🤣。但也有一些 Magic Number 在选择时添加了一些风趣的元素。比如 Java 中的 CLASS 文件头十六进制幻数为 CA FE BA BE,着实把咖啡 ☕ 玩明白了;MS-DOS 的可执行文件(比如 EXE)的幻数为 4D 5A,对应 ASCII 结果为 MZ,是 MS-DOS 架构师 Mark Zbikowski 的名字缩写;同样使用名字缩写的还有 ZIP 文件,幻数为 50 4B 03 04,前两位对应 ASCII 结果为 PK,是 PKZIP 算法发明者 Phil Katz 的名字缩写。

常见 Magic Number 对照表

匹配校验

有了以上的了解,就可以很轻松地写出校验代码。以 Java 为例:

public class FileMatcher {

    private static final String MAGIC_NUMBER_PNG = "89504E470D0A1A0A";

    public static boolean isPngFile(File file) {
        byte[] bytes = fileToBytes(file);
        if (bytes == null) {
            return false;
        }
        String hex = bytesToHex(bytes);
        if (hex == null) {
            return false;
        }
        return hex.startsWith(MAGIC_NUMBER_PNG);
    }

    private static byte[] fileToBytes(File file) {
        FileInputStream fis = null;
        ByteArrayOutputStream bos = null;
        try {
            fis = new FileInputStream(file);
            bos = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = fis.read(buffer)) != -1) {
                bos.write(buffer, 0, bytesRead);
            }
            return bos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        } finally {
            try {
                if (fis != null) {
                    fis.close();
                }
                if (bos != null) {
                    bos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private static String bytesToHex(byte[] src) {
        StringBuilder sb = new StringBuilder();
        if (src == null || src.length == 0) {
            return null;
        }
        String hex;
        for (byte b : src) {
            hex = Integer.toHexString(b & 0xFF).toUpperCase();
            if (hex.length() < 2) {
                sb.append(0);
            }
            sb.append(hex);
        }
        return sb.toString();
    }
}

逻辑很简单,分三步走:

  1. 获取文件的字节数组。
  2. 把字节数组转为十六进制字符串。
  3. 将十六进制字符串前面一定位数与 Magic Number 做比较。

转换的代码相信大家的项目里基本都有封装,所以实际写的代码就几行罢了。

后记

通过 Magic Number 校验文件类型在大多数情况下是可行的,但许多工具同时也提供了篡改文件头的功能,比如『WinHex』。

还需要注意的是不同文件扩展名的 Magic Number 有可能相同,比如 APK 和 ZIP,因为 APK 本身就是以 ZIP 格式压缩的,所以根据实际应用场景可以结合多种方案判断。