讲讲存档文件的包装设计

1,108

楔子

为什么讲这个?很简单,因为做需求碰到了,没找到什么特别有用的最佳实践,这里分享一些自己的思路。

需求背景是最近在撸的一个编辑器,编辑器基于 Electron 实现,桌面端编辑类的软件有个存档就很正常了。

存档文件

归档文件,又作存档文件,是由一个或多个计算机文件以及元数据组成的文件,用于将多个数据文件收集到一个文件中,以便于传输和存储,或者压缩以减少存储空间。也称打包文件,归档并压缩时常称为压缩文件。通常会存储目录结构,错误检测与纠正信息,注释,有时还使用加密

存档文件十分常见,最常见的如:

  • ZIP、RAR、TAR 等压缩包
  • PS 、AI、XD、PDF、SKetch 等设计文件
  • DOCX、XLSX、PPTX 等 office 存档

或者说一种文件格式就是一种存档表现,存档文件大多支持以下一个或多个特性

最好理解的就是 zip 文件,其支持了多个文件的存储、压缩、加密与校验(CRC 校验文件完整性),其也是很多存档文件包装的常用格式。

存档文件格式

咋一看不同软件存档格式都是不一样的,但其内部实现一般逃不出以下的套路:

  • 专有格式文件,其内部按文件规范以指定的规则(如字节区间)存储数据
  • 基于现有的文件格式做包装,通过修改文件后缀或编码来创建新格式

专有格式文件

这类存档文件一般由专业软件产生,其经过严格设计,比较典型的例子就是 Photoshop 所使用的 PSD 文件,其文件规范指定一系列字节区间数据定义。

image.png

附:Adobe Photoshop File Formats Specification

其他类似的文件还有 PDFFBX 及 Office 早期的存档文件 DOC、XLS、PPT 都为专有的二进制存档文件,这类专有存档格式依赖其开放的文件标准,没公开其文件规范则很难进行解析。

基于现有文件包装

鲁迅曾说过:

“这个世界上本没有那么多文件,改后缀的人多了,也便成了新文件”

很好理解,很多软件生成的存档文件不过是将常见的文件进行二次包装修改后缀所得,常用于包装的格式有:JSON、XML/HTML 与 ZIP。

基于 ZIP

Sketch 文件就是个很典型的例子,其文件本质就是一个 zip 文件,改后缀后可直接看到文件内容:

image.png

image.png

附:Sketch File format

还有就是常见的 Office 存档(DOCX、XLSX、PPTX...),其本质还是个 ZIP 包,文件的后缀中的 X 表示其内部文件描述是基于 Office Open XML 实现的。

image.png

基于 JSON

excalidraw 的存档文件(excalidraw)与 processon 的存档文件(pos)其都是基于单个 JSON 文件封装。

image.png

基于 XML/HTML

顺手扒了下语雀的存档文件(lake),其存档是基于单个 XML/HTML 实现的。

image.png

如何查看原始文件格式?

是否有方法可以快速知晓一个文件是否为包装格式?这时候就需要一个可以查看二进制内容的编辑器了,通过编辑器查看文件数据与组织结构,可以通过一些特定的标志判别出文件格式。

语雀 lake

image.png

processon pos

image.png

zip

image.png

对于 JSON 与 XML 一类的文本格式包装,通过 hex editor 是可以直接知晓内部数据结构的,但对于二进制文件而言,就需要一些特殊的文件标识来确定文件格式了。

以 ZIP 文件为例,其文件规范中一些文件头字段是固定的,如头部的 50 4B 03 04,这就是一个明显标识,我们可以通过其确定文件为压缩文件。

隐藏文件格式

当有人简单包装文件格式时,就一定会有人想把文件内容隐藏。

例如存档文件中涉及一些核心技术实现或是隐私数据,这时候隐藏存档文件内容就很重要了。

该如何实现呢?

上面讨论过了,文件存档不外乎两种思路:

  • 专有格式
  • 基于现有格式包装

专有格式的存档天然具有隐蔽性,只要不公开格式规范是很难破解存档信息的,当然其设计维护的成本也是比较高的。

包装类型的存档类型文件想要隐藏原始信息就需要对原始文件进行重新编码,以隐藏原始的格式特征。这里可以参考 Figma 存档文件(fig),其存档文件明显是经过编码处理的。

image.png

至于具体的编码规则可以自行定义,一般是将原始文件转为 Buffer/ArrayBuffer 再针对其字节编码,例如:

  • 逐字节与 255 相减,存其差值绝对值
  • 替换文件中一些特殊编码标识,例如替换 zip 文件的 50 4B 03 04
  • 在原始 buffer 中按规则插入一些特殊字节片段
  • 使用 AES、DES、RSA、DSA、ECC 等算法对 Buffer 进行加密
  • 取 buffer 不同片段进行不同编码
  • ...

文件读取解析时使用相反操作即可,只要不惧加解密与读写的性能维护的成本,相信您一定可以设计出最为隐蔽的文件~

image.png

自定义存档文件实现

实际演示一个基于 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<AdmZip> {
        return new AdmZip(entry);
    }

    async write(options: IArchiveFileWriteOptions): Promise<void> {
        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'));
})();

image.png

目前并未对 demo.myfile 进行加密处理,所以可以看到 zip 文件头的标识:

image.png

接下来针对原始 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<Transform> {
        return crypto.createCipheriv(this.algorithm, this.keyBuffer, this.ivBuffer);
    }

    async createDecipher(): Promise<Transform> {
        return crypto.createDecipheriv(this.algorithm, this.keyBuffer, this.ivBuffer);
    }
}

class MyFile {
    private MyCipher = new MyCipher();

    async read(entry: string): Promise<AdmZip> {
        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<void> {
        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'));
})();

image.png

Zip 文件头已经看不到了~

image.png

存档文件清单

虽然讨论了很多关于存档文件包装与编码的实现,但实际针对存档内容组织也是很重要的一环,例如:

  • 一个 zip 文件该放哪些东西
  • 文件目录结构如何组织
  • 是否需要放置文件清单(manifest)、文件签名(sign)与版本文件(version)等

这些都需要详细设计,考虑后期升级与版本管理之类的操作~

其他

一些文件格式参考