注册
zip

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

朋友聊天讨论到一个问题,怎么检测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只会出现一次。



































































OffsetBytesDescription
04End of central directory signature = 0x06054b50核心目录结束标记(0x06054b50)
42Number of this disk当前磁盘编号
62number of the disk with the start of the central directory核心目录开始位置的磁盘编号
82total number of entries in the central directory on this disk该磁盘上所记录的核心目录数量
102total number of entries in the central directory核心目录结构总数
124Size of central directory (bytes)核心目录的大小
164offset of start of central directory with respect to the starting disk number核心目录开始位置相对于archive开始的位移
202.ZIP file comment length(n)注释长度
22n.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的长度,所以理论上可以寻址2^16^个位置。

举个例子:

修改之前:

修改之前


修改之后

修改之后

看上面两个文件,修改之前长度为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歌增量升级方案


作者:Yocn
链接:https://juejin.cn/post/7220985690795933756
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册