为什么讲这个?很简单,因为做需求碰到了,没找到什么特别有用的最佳实践,这里分享一些自己的思路。
需求背景是最近在撸的一个编辑器,编辑器基于 Electron 实现,桌面端编辑类的软件有个存档就很正常了。
归档文件,又作存档文件,是由一个或多个计算机文件以及元数据组成的文件,用于将多个数据文件收集到一个文件中,以便于传输和存储,或者压缩以减少存储空间。也称打包文件,归档并压缩时常称为压缩文件。通常会存储目录结构,错误检测与纠正信息,注释,有时还使用加密。
存档文件十分常见,最常见的如:
或者说一种文件格式就是一种存档表现,存档文件大多支持以下一个或多个特性
最好理解的就是 zip 文件,其支持了多个文件的存储、压缩、加密与校验(CRC 校验文件完整性),其也是很多存档文件包装的常用格式。
咋一看不同软件存档格式都是不一样的,但其内部实现一般逃不出以下的套路:
这类存档文件一般由专业软件产生,其经过严格设计,比较典型的例子就是 Photoshop 所使用的 PSD 文件,其文件规范指定一系列字节区间数据定义。
附:Adobe Photoshop File Formats Specification。
其他类似的文件还有 PDF、FBX 及 Office 早期的存档文件 DOC、XLS、PPT 都为专有的二进制存档文件,这类专有存档格式依赖其开放的文件标准,没公开其文件规范则很难进行解析。
鲁迅曾说过:
“这个世界上本没有那么多文件,改后缀的人多了,也便成了新文件”
很好理解,很多软件生成的存档文件不过是将常见的文件进行二次包装修改后缀所得,常用于包装的格式有:JSON、XML/HTML 与 ZIP。
Sketch 文件就是个很典型的例子,其文件本质就是一个 zip 文件,改后缀后可直接看到文件内容:
还有就是常见的 Office 存档(DOCX、XLSX、PPTX…),其本质还是个 ZIP 包,文件的后缀中的 X 表示其内部文件描述是基于 Office Open XML 实现的。
excalidraw 的存档文件(excalidraw)与 processon 的存档文件(pos)其都是基于单个 JSON 文件封装。
顺手扒了下语雀的存档文件(lake),其存档是基于单个 XML/HTML 实现的。
是否有方法可以快速知晓一个文件是否为包装格式?这时候就需要一个可以查看二进制内容的编辑器了,通过编辑器查看文件数据与组织结构,可以通过一些特定的标志判别出文件格式。
语雀 lake
processon pos
zip
对于 JSON 与 XML 一类的文本格式包装,通过 hex editor 是可以直接知晓内部数据结构的,但对于二进制文件而言,就需要一些特殊的文件标识来确定文件格式了。
以 ZIP 文件为例,其文件规范中一些文件头字段是固定的,如头部的 50 4B 03 04,这就是一个明显标识,我们可以通过其确定文件为压缩文件。
当有人简单包装文件格式时,就一定会有人想把文件内容隐藏。
例如存档文件中涉及一些核心技术实现或是隐私数据,这时候隐藏存档文件内容就很重要了。
该如何实现呢?
上面讨论过了,文件存档不外乎两种思路:
专有格式的存档天然具有隐蔽性,只要不公开格式规范是很难破解存档信息的,当然其设计维护的成本也是比较高的。
包装类型的存档类型文件想要隐藏原始信息就需要对原始文件进行重新编码,以隐藏原始的格式特征。这里可以参考 Figma 存档文件(fig),其存档文件明显是经过编码处理的。
至于具体的编码规则可以自行定义,一般是将原始文件转为 Buffer/ArrayBuffer 再针对其字节编码,例如:
50 4B 03 04文件读取解析时使用相反操作即可,只要不惧加解密与读写的性能与维护的成本,相信您一定可以设计出最为隐蔽的文件~
实际演示一个基于 Zip 文件封装文件的例子,先来实现 Zip 文件的读写:
import fs from 'fs';
import path from 'path';
import AdmZip from 'adm-zip';
interface IArchiveFileWriteOptions {// 存档文件路径dest: string;files: Array<{// zip 文件内的文件路径dest: string;// 需要写入 zip 本地文件路径local?: string;// 需要写入 zip 数据source?: Buffer | string;}>;
}
class ZipFile {async read(entry: string): Promise {return new AdmZip(entry);}async write(options: IArchiveFileWriteOptions): Promise {const { dest, files } = options;const zip = new AdmZip();// 往 zip 容器中写入文件files.forEach((file) => {const { dest: destName, source, local } = file;if (source) {if (Buffer.isBuffer(source)) {zip.addFile(destName, source);return;}zip.addFile(destName, Buffer.from(source, 'utf-8'));return;}if (local) {zip.addLocalFile(local, destName);return;}});const zipFileBuffer = await zip.toBufferPromise();await fs.promises.writeFile(dest, zipFileBuffer);}
}
(async function main() {const zipFile = new ZipFile();const dest = path.resolve(__dirname, 'demo.myfile');await zipFile.write({dest,files: [{dest: 'content.text',source: '扶桑若木',},],});console.log('write:', dest);const zipRes = await zipFile.read(dest);console.log('content.text --->', zipRes.readAsText('content.text'));
})();
目前并未对 demo.myfile 进行加密处理,所以可以看到 zip 文件头的标识:
接下来针对原始 zip 文件做 AES 加密处理:
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import AdmZip from 'adm-zip';
import { streamToBuffer, bufferToStream } from './src/utils/stream';
import type { Transform } from 'stream';
interface IMyFileWriteOptions {// 存档文件路径dest: string;files: Array<{// zip 文件内的文件路径dest: string;// 需要写入 zip 本地文件路径local?: string;// 需要写入 zip 数据source?: Buffer | string;}>;
}
class MyCipher {algorithm: string = 'aes-128-cbc';password: string = '0000111122223333';salt: string = '0000111122223333';iv: string = '0000111122223333';get keyBuffer(): Buffer {return crypto.scryptSync(this.password, this.salt, 16);}get ivBuffer(): Buffer {return Buffer.from(this.iv, 'utf-8');}async createEncipher(): Promise {return crypto.createCipheriv(this.algorithm, this.keyBuffer, this.ivBuffer);}async createDecipher(): Promise {return crypto.createDecipheriv(this.algorithm, this.keyBuffer, this.ivBuffer);}
}
class MyFile {private MyCipher = new MyCipher();async read(entry: string): Promise {const decipher = await this.MyCipher.createDecipher();const readStream = fs.createReadStream(entry);// 读取文件流 -> 解密const zipBuffer = await streamToBuffer(readStream.pipe(decipher));return new AdmZip(zipBuffer);}async write(options: IMyFileWriteOptions): Promise {const { dest, files } = options;const zip = new AdmZip();// 往 zip 容器中写入文件files.forEach((file) => {const { dest: destName, source, local } = file;if (source) {if (Buffer.isBuffer(source)) {zip.addFile(destName, source);return;}zip.addFile(destName, Buffer.from(source, 'utf-8'));return;}if (local) {zip.addLocalFile(local, destName);return;}});const zipFileBuffer = await zip.toBufferPromise();const encipher = await this.MyCipher.createEncipher();const writeStream = fs.createWriteStream(dest);return new Promise((resolve) => {// zip buffer -> 加密 -> 写入文件bufferToStream(zipFileBuffer).pipe(encipher).pipe(writeStream).on('close', () => {resolve();});});}
}
(async function main() {const myFile = new MyFile();const dest = path.resolve(__dirname, 'demo.myfile');await myFile.write({dest,files: [{dest: 'content.text',source: '扶桑若木',},],});console.log('write:', dest);const zipRes = await myFile.read(dest);console.log('content.text --->', zipRes.readAsText('content.text'));
})();
Zip 文件头已经看不到了~
虽然讨论了很多关于存档文件包装与编码的实现,但实际针对存档内容组织也是很重要的一环,例如:
这些都需要详细设计,考虑后期升级与版本管理之类的操作~