V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
kinglisky
V2EX  ›  分享发现

讲讲存档文件的包装设计

  •  1
     
  •   kinglisky · 2022-06-30 23:36:12 +08:00 · 1399 次点击
    这是一个创建于 875 天前的主题,其中的信息可能已经有所发展或是发生改变。

    原文发在掘金,一点实现想法,还请赐教~

    https://juejin.cn/post/7114706551373299719

    楔子

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

    需求背景是最近在撸的一个编辑器,编辑器基于 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 )等

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

    其他

    一些文件格式参考

    8 条回复    2022-07-01 18:07:21 +08:00
    icyalala
        1
    icyalala  
       2022-06-30 23:58:37 +08:00
    我觉得,二进制文档的设计可以再详细展开说一下,
    比如常见的规范 (例如 RIFF 那一坨)、向前兼容向后兼容、版本号、字节序、流式读写、完整性校验这些
    thedrwu
        2
    thedrwu  
       2022-07-01 00:25:21 +08:00 via Android
    可能现在没人听说过 OLE Structured Storage 了
    geelaw
        3
    geelaw  
       2022-07-01 06:04:51 +08:00
    @thedrwu #2 IStorage 和 IStream 的噩梦,其实很多人天天都在用,例如 doc/ppt/xls/one 都是。
    kinglisky
        4
    kinglisky  
    OP
       2022-07-01 09:38:27 +08:00
    @thedrwu 学到了~
    kinglisky
        5
    kinglisky  
    OP
       2022-07-01 09:38:45 +08:00
    @icyalala 还在踩坑,哈哈~
    codehz
        6
    codehz  
       2022-07-01 09:42:39 +08:00
    还可以用 SQLite3 数据库作文存档格式(官方也对比了多种方法的优缺点 https://www.sqlite.org/appfileformat.html
    SeanTheSheep
        7
    SeanTheSheep  
       2022-07-01 16:58:51 +08:00
    @codehz 以前还用 SQLite 数据库存配置信息,现在直接 JSON 一把梭
    @icyalala 版本号,完整性校验的确很重要
    codehz
        8
    codehz  
       2022-07-01 18:07:21 +08:00   ❤️ 1
    @SeanTheSheep 配置信息通常确实不用数据库存((
    版本号的话 sqlite3 给了一个应用版本号的全局字段(指不依赖具体表),拿来做迁移非常方便(
    相比 zip 这种,读写速度更好而且也可以压缩(手动 deflate 存字段,甚至配合自定义函数和视图以及触发器可以做透明压缩,无需修改程序其他部分的代码)
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1363 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 23:51 · PVG 07:51 · LAX 15:51 · JFK 18:51
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.