检测zip文件完整(进阶:APK文件渠道号)

2019-07-31  本文已影响0人  YocnZhao

朋友聊天讨论到一个问题,怎么检测zip的完整性。zip是我们常用的压缩格式,不管是Win/Mac/Linux下都很常用,我们做文件的下载也会经常用到,网络充满不确定性,对于多个小文件(比如配置文件)的下载,我们希望只发起一次连接,因为建立连接是很耗费资源的,即使现在http2.0可以对一条TCP连接进行复用,我们还是希望网络请求的次数越少越好,不管是对于稳定性还是成功失败的逻辑判断,都会有益处。

这个时候我们常用的其实就是把他们压缩成一个zip文件,下载下来之后解压就好了。

但很多时候zip会解压失败,如果我们的zip已经下载下来了,其实不存在没有访问权限的问题了,那原因除了空间不够之外,就是zip格式有问题了,zip文件为空或者只下载了一半。
这个时候就需要检测一下我们下载下来的zip是不是合法有效的zip了。
有这么几种思路:

  1. 直接解压,抛异常表明zip有问题
  2. 下载前得到zip文件的length,下载后检测文件大小
  3. 使用md5或sha1等摘要算法,下载下来后做md5,然后比对合法性
  4. 检测zip文件结尾的特殊编码格式,检测是否zip合法

这几种做法有利有弊,这里我们只看第4种。
我们讨论之前,可以大致了解一下zip的格式ZIP文件格式分析,我们关注的是End of central directory record,核心目录结束标记,每个zip只会出现一次。

Offset Bytes Description
0 4 End of central directory signature = 0x06054b50 核心目录结束标记(0x06054b50)
4 2 Number of this disk 当前磁盘编号
6 2 number of the disk with the start of the central directory 核心目录开始位置的磁盘编号
8 2 total number of entries in the central directory on this disk 该磁盘上所记录的核心目录数量
10 2 total number of entries in the central directory 核心目录结构总数
12 4 Size of central directory (bytes) 核心目录的大小
16 4 offset of start of central directory with respect to the starting disk number 核心目录开始位置相对于archive开始的位移
20 2 .ZIP file comment length(n) 注释长度
22 n .ZIP Comment 注释内容

我们可以看到,0x06054b50所在的位置其实是在zip.length减去22个字节,所以我们只需要seek到需要的位置,然后读4个字节看是否是0x06054b50,就可以确定zip是否完整。
下面是一个判断的代码

    //没有zip文件注释时候的目录结束符的偏移量
    private static final int RawEndOffset = 22;
    //0x06054b50占4个字节
    private static final int endOfDirLength = 4;
    //目录结束标识0x06054b50 的小端读取方式。
    private static final byte[] endOfDir = new byte[]{0x50, 0x4B, 0x05, 0x06};

    private boolean isZipFile(File file) throws IOException {
        if (file.exists() && file.isFile()) {
            if (file.length() <= RawEndOffset + endOfDirLength) {
                return false;
            }
            long fileLength = file.length();
            RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
            //seek到结束标记所在的位置
            randomAccessFile.seek(fileLength - RawEndOffset);
            byte[] end = new byte[endOfDirLength];
            //读取4个字节
            randomAccessFile.read(end);
            //关掉文件
            randomAccessFile.close();
            return isEndOfDir(end);
        } else {
            return false;
        }
    }

    /**
     * 是否符合文件夹结束标记
     */
    private boolean isEndOfDir(byte[] src) {
        if (src.length != endOfDirLength) {
            return false;
        }
        for (int i = 0; i < src.length; i++) {
            if (src[i] != endOfDir[i]) {
                return false;
            }
        }
        return true;
    }

有人可能注意到了,你上面写的结束标识明明是0x06054b50,为什么检测的时候是反着写的。这里就涉及到一个大端小端的问题,录音的时候也能会遇到大小端顺序的问题,反过来读就好了。

涉及到二进制的查看和编辑,我们可以使用010editor这个软件来查看文件的十六进制或者二进制,并且可以手动修改某个位置的二进制。


他的界面大致长这样子,小端显示的,我们可以看到我们要得到的06 05 4b 50

我们看上面的表格里面最后一个表格里的 .ZIP file comment length(n).ZIP Comment ,意思是描述长度是两个字节,描述长度是n,表示这个长度是可变的。这个有啥作用呢?
其实就是给了一个可以写额外的描述数据的地方(.ZIP Comment),他的长度由前面的.ZIP file comment length(n)来控制。也就是zip允许你在它的文件结尾后面额外的追加内容,而不会影响前面的数据。描述文件的长度是两个字节,也就是一个short的长度,所以理论上可以寻址216个位置。
举个例子:

修改之前
修改之后
看上面两个文件,修改之前长度为0,我们把它改成2(注意大小端),我们改成2,然后随便在后面追加两个byte,保存,打开修改之后的zip,发现是可以正常运行的,甚至我们可以在长度是2的基础上追加多个byte,其实还是可以打开的。
所以回到标题内容,其实apk就是zip,我们同样可以在apk的Comment后面追加内容,比如可以当做渠道来源,或者完成这样的需求:h5网页A上下载的需要打开某个ActivityA,h5网页B上下载的需要打开某个ActivityB。

原理还是上面的原理,写入渠道或者配置,读取apk渠道或者配置,做相应统计或者操作。

    //magic -> yocn
    private static final byte[] MAGIC = new byte[]{0x79, 0x6F, 0x63, 0x6E};
    //没有zip文件注释时候的目录结束符的偏移量
    private static final int RawEndOffset = 22;
    //0x06054b50占4个字节
    private static final int endOfDirLength = 4;
    //目录结束标识0x06054b50 的小端读取方式。
    private static final byte[] endOfDir = new byte[]{0x50, 0x4B, 0x05, 0x06};
    //注释长度占两个字节,所以理论上可以支持 2^16 个字节。
    private static final int commentLengthBytes = 2;
    //注释长度
    private static final int commentLength = 8;

    private boolean isZipFile(File file) throws IOException {
        if (file.exists() && file.isFile()) {
            if (file.length() <= RawEndOffset + endOfDirLength) {
                return false;
            }
            long fileLength = file.length();
            RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
            //seek到结束标记所在的位置
            randomAccessFile.seek(fileLength - RawEndOffset);
            byte[] end = new byte[endOfDirLength];
            //读取4个字节
            randomAccessFile.read(end);
            //关掉文件
            randomAccessFile.close();
            return isEndOfDir(end);
        } else {
            return false;
        }
    }

    /**
     * 是否符合文件夹结束标记
     */
    private boolean isEndOfDir(byte[] src) {
        if (src.length != endOfDirLength) {
            return false;
        }
        for (int i = 0; i < src.length; i++) {
            if (src[i] != endOfDir[i]) {
                return false;
            }
        }
        return true;
    }

    /**
     * zip(apk)尾追加渠道信息
     */
    private void write2Zip(File file, String channelInfo) throws IOException {
        if (isZipFile(file)) {
            long fileLength = file.length();
            RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
            //seek到结束标记所在的位置
            randomAccessFile.seek(fileLength - commentLengthBytes);
            byte[] lengthBytes = new byte[2];
            lengthBytes[0] = commentLength;
            lengthBytes[1] = 0;
            randomAccessFile.write(lengthBytes);
            randomAccessFile.write(getChannel(channelInfo));
            randomAccessFile.close();
        }
    }

    /**
     * 获取zip(apk)文件结尾
     *
     * @param file 目标哦文件
     */
    private String getZipTail(File file) throws IOException {
        long fileLength = file.length();
        RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
        //seek到magic的位置
        randomAccessFile.seek(fileLength - MAGIC.length);
        byte[] magicBytes = new byte[MAGIC.length];
        //读取magic
        randomAccessFile.read(magicBytes);
        //如果不是magic结尾,返回空
        if (!isMagicEnd(magicBytes)) return "";
        //seek到读到信息的offest
        randomAccessFile.seek(fileLength - commentLength);
        byte[] lengthBytes = new byte[commentLength];
        //读取渠道
        randomAccessFile.read(lengthBytes);
        randomAccessFile.close();
        char[] lengthChars = new char[commentLength];
        for (int i = 0; i < commentLength; i++) {
            lengthChars[i] = (char) lengthBytes[i];
        }
        return String.valueOf(lengthChars);
    }

    /**
     * 是否以魔数结尾
     *
     * @param end 检测的byte数组
     * @return 是否结尾
     */
    private boolean isMagicEnd(byte[] end) {
        for (int i = 0; i < end.length; i++) {
            if (MAGIC[i] != end[i]) {
                return false;
            }
        }
        return true;
    }

    /**
     * 生成渠道byte数组
     */
    private byte[] getChannel(String s) {
        byte[] src = s.getBytes();
        byte[] channelBytes = new byte[commentLength];
        System.arraycopy(src, 0, channelBytes, 0, commentLength);
        return channelBytes;
    }

  //读取源apk的路径
  public static String getSourceApkPath(Context context, String packageName) {
    if (TextUtils.isEmpty(packageName))
        return null;
    try {
        ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(packageName, 0);
            return appInfo.sourceDir;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
    return null;
}

这里使用了一个魔数的概念,表明是否是写入了我们特定的渠道,只有写了我们特定渠道的基础上才会去读取,防止读到了没有写过的文件。
读取渠道的时候首先获取安装包的绝对路径。Android系统在用户安装app时,会把用户安装的apk拷贝一份到/data/apk/路径下,通过getSourceApkPath 可以获取该apk的绝对路径。如果使用rw可能会有权限问题,所以读取的时候只使用r就可以了。

参考:
ZIP文件格式分析
全民K歌增量升级方案

上一篇下一篇

猜你喜欢

热点阅读