Repository Public
1import { execFileSync } from 'node:child_process';2import { randomBytes } from 'node:crypto';3import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs';4import { join } from 'node:path';5import { Readable } from 'node:stream';6import { pipeline } from 'node:stream/promises';7import ExcelJS from 'exceljs';8import { afterAll, beforeAll, describe, expect, it } from 'vitest';9import yazl from 'yazl';10import { config } from '../src/config.ts';11import { migrate, migrateDown, sql } from '../src/db.ts';12import { runSandboxed, withWorkDir } from '../src/tools.ts';13import { closeAll, signup, work } from './harness.ts';1415afterAll(closeAll);16const FX = '/tmp/sp-drive-test/fixtures';17let a: Awaited<ReturnType<typeof signup>>;18const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');19beforeAll(async () => {20 a = await signup();21 mkdirSync(FX, { recursive: true });22 writeFileSync(join(FX, 'px.png'), PNG_1PX);23 execFileSync('/usr/bin/sips', ['-z', '300', '400', join(FX, 'px.png'), '--out', join(FX, 'image.png')], { stdio: 'pipe' });24 execFileSync('/usr/bin/sips', ['-s', 'format', 'heic', join(FX, 'image.png'), '--out', join(FX, 'photo.heic')], { stdio: 'pipe' });25 writeFileSync(join(FX, 'lettre.txt'), 'Bonjour SP Drive\nCeci est une lettre.\n');26 execFileSync(config.soffice, ['--headless', `-env:UserInstallation=file://${FX}/lo`, '--convert-to', 'docx:MS Word 2007 XML', '--outdir', FX, join(FX, 'lettre.txt')], { stdio: 'pipe', timeout: 120000 });27 const wb = new ExcelJS.Workbook();28 const s1 = wb.addWorksheet('Budget'); s1.addRow(['Poste', 'Montant']); s1.addRow(['Loyer', 1200]); s1.addRow(['Total', { formula: 'B2', result: 1200 }]);29 wb.addWorksheet('Notes').addRow(['Deuxième feuille']);30 await wb.xlsx.writeFile(join(FX, 'budget.xlsx'));31 mkdirSync(join(FX, 'arch/sous'), { recursive: true }); writeFileSync(join(FX, 'arch/sous/a.txt'), 'a'); writeFileSync(join(FX, 'arch/b.txt'), 'bb');32 execFileSync('/usr/bin/tar', ['czf', join(FX, 'arch.tar.gz'), '-C', FX, 'arch']);33 execFileSync('/usr/bin/zip', ['-qr', join(FX, 'arch.zip'), 'arch'], { cwd: FX });34 // a zip-based "document" declaring 400 MB of zeros in a few hundred kilobytes35 const z = new yazl.ZipFile();36 z.addReadStream(Readable.from((function* () { const block = Buffer.alloc(1 << 20); for (let i = 0; i < 400; i++) yield block; })()), 'word/document.xml');37 z.end(); await pipeline(z.outputStream, createWriteStream(join(FX, 'bombe.docx')));38});39const up = async (name: string, data?: Buffer) => (await a.client.upload(a.rootId, name, data ?? readFileSync(join(FX, name)))).body.file;40const preview = (id: string, kind: string) => a.client.get(`/api/v1/files/${id}/preview/${kind}`);4142describe('previews and scanning', () => {43 it('answers "pending" until the worker has run, then serves thumbnails and HEIC renditions', async () => {44 const png = await up('image.png'), heic = await up('photo.heic');45 expect((await preview(png.id, 'thumb')).status).toBe(202);46 await work();47 const t = await preview(png.id, 'thumb');48 expect(t.status).toBe(200); expect(t.headers['content-type']).toBe('image/jpeg'); expect(t.raw.subarray(0, 3).toString('hex')).toBe('ffd8ff');49 const full = await preview(heic.id, 'image');50 expect(full.status).toBe(200); expect(full.raw.subarray(0, 3).toString('hex')).toBe('ffd8ff');51 expect((await a.client.get(`/api/v1/folders/${a.rootId}`)).body.files.find((f: any) => f.id === png.id)).toMatchObject({ thumb: true, scan: 'clean' });52 expect((await preview(png.id, 'nope')).status).toBe(404);53 // derived previews are encrypted at rest like originals54 const [p] = await sql<{ storage_key: string }[]>`select p.storage_key from previews p join file_versions v on v.object_id = p.object_id where v.file_id = ${png.id} and p.kind = 'thumb'`;55 expect(readFileSync(join(config.blobDir, p!.storage_key)).subarray(0, 3).toString('hex')).not.toBe('ffd8ff');56 });57 it('converts office documents to PDF in the sandbox and keeps the original untouched', async () => {58 const original = readFileSync(join(FX, 'lettre.docx'));59 const f = await up('lettre.docx');60 await work();61 const pdf = await preview(f.id, 'pdf');62 expect(pdf.status, JSON.stringify(pdf.body)).toBe(200);63 expect(pdf.raw.subarray(0, 5).toString()).toBe('%PDF-');64 expect((await preview(f.id, 'thumb')).status).toBe(200);65 expect((await a.client.get(`/api/v1/files/${f.id}/content?download=1`)).raw.equals(original)).toBe(true);66 expect((await a.client.get(`/api/v1/files/${f.id}`)).body.previews).toEqual({ pdf: 'ready' });67 });68 it('reports a failed conversion instead of pretending, and refuses archive bombs before opening them', async () => {69 const broken = await up('cassé.docx', randomBytes(5000));70 const bomb = await up('bombe.docx');71 await work();72 const r = await preview(broken.id, 'pdf');73 expect(r.status).toBe(422); expect(r.body.error).toBe('preview_failed'); expect(r.body.message).toBeTruthy();74 const b = await preview(bomb.id, 'pdf');75 expect(b.status).toBe(422); expect(b.body.message).toMatch(/unreasonable size/);76 expect((await a.client.get(`/api/v1/files/${broken.id}/content?download=1`)).status).toBe(200); // the file itself stays available77 });78 it('reads workbooks sheet by sheet in a separate process', async () => {79 const f = await up('budget.xlsx');80 await work();81 const r = await preview(f.id, 'sheet');82 expect(r.status, JSON.stringify(r.body)).toBe(200);83 expect(r.body.sheets.map((s: any) => s.name)).toEqual(['Budget', 'Notes']);84 expect(r.body.sheets[0].rows).toEqual([['Poste', 'Montant'], ['Loyer', '1200'], ['Total', '1200']]);85 });86 it('inventories ZIP and TAR archives without extracting them', async () => {87 const zip = await up('arch.zip'), tgz = await up('arch.tar.gz');88 await work();89 const z = (await preview(zip.id, 'archive')).body, t = (await preview(tgz.id, 'archive')).body;90 expect(z.entries.filter((e: any) => !e.dir).map((e: any) => `${e.path}:${e.size}`).sort()).toEqual(['arch/b.txt:2', 'arch/sous/a.txt:1']);91 expect(t.entries.filter((e: any) => !e.dir).map((e: any) => `${e.path}:${e.size}`).sort()).toEqual(['arch/b.txt:2', 'arch/sous/a.txt:1']);92 expect(z.suspicious).toBe(false);93 // hostile entry names are displayed inert94 const evil = new yazl.ZipFile(); evil.addBuffer(Buffer.from('x'), 'ok/../../../../etc/passwd'.replace(/\.\.\//g, 'up/')); evil.end();95 const chunks: Buffer[] = []; for await (const c of evil.outputStream) chunks.push(c as Buffer);96 const raw = Buffer.concat(chunks); const patched = Buffer.from(raw.toString('latin1').replace(/up\//g, '../'), 'latin1'); // "../" written straight into the archive97 const e = await up('evil.zip', patched);98 await work();99 const l = await preview(e.id, 'archive');100 if (l.status === 200) for (const en of l.body.entries) expect(en.path).not.toMatch(/\.\./); else expect(l.status).toBe(422); // listed safely or refused, never trusted101 });102 it('flags infected uploads of the owner, blocks previews and warns', async () => {103 const f = await up('eicar.txt', Buffer.from('X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'));104 await work();105 expect((await a.client.get(`/api/v1/files/${f.id}`)).body.file.scan).toBe('infected');106 expect((await a.client.get(`/api/v1/files/${f.id}/content`)).status).toBe(451);107 expect((await a.client.get(`/api/v1/zip?files=${f.id}`)).status).toBe(400); // left out of archives108 const n = (await a.client.get('/api/v1/notifications')).body.find((x: any) => x.kind === 'scan.infected');109 expect(n.data.signature).toMatch(/eicar/i);110 });111 it('runs converters without network, without the home directory and with a time limit', async () => {112 await withWorkDir(async (work) => {113 const net = await runSandboxed(work, '/usr/bin/curl', ['-s', '-m', '5', '-o', '/dev/null', '-w', '%{http_code}', 'http://127.0.0.1:5432'], { timeoutMs: 15000 });114 expect(net.code).not.toBe(0);115 const key = await runSandboxed(work, '/bin/cat', [config.masterKeyFile], { timeoutMs: 5000 });116 expect(key.code).not.toBe(0); expect(key.stdout).toBe('');117 const home = await runSandboxed(work, '/bin/ls', [`${process.env.HOME}/.ssh`], { timeoutMs: 5000 });118 expect(home.code).not.toBe(0);119 const blobs = await runSandboxed(work, '/bin/ls', [config.blobDir], { timeoutMs: 5000 });120 expect(blobs.code).not.toBe(0);121 const pg = await runSandboxed(work, '/opt/homebrew/opt/postgresql@17/bin/psql', ['-h', '/tmp', '-l'], { timeoutMs: 8000 });122 expect(pg.code).not.toBe(0); // the database's local socket is out of reach too123 const outside = await runSandboxed(work, '/usr/bin/touch', ['/tmp/sp-drive-test/data/blobs/escape'], { timeoutMs: 5000 });124 expect(outside.code).not.toBe(0);125 expect((await runSandboxed(work, '/usr/bin/touch', [join(work, 'ok')], { timeoutMs: 5000 })).code).toBe(0);126 await expect(runSandboxed(work, '/bin/sleep', ['30'], { timeoutMs: 1000 })).rejects.toThrow(/exceeded/);127 });128 });129});130131describe('migrations', () => {132 it('revert and re-apply cleanly', async () => {133 expect(await migrateDown()).toBe('001_init');134 expect(await sql`select 1 from information_schema.tables where table_name = 'files'`).toHaveLength(0);135 expect(await migrate()).toEqual(['001_init']);136 expect(await sql`select 1 from information_schema.tables where table_name = 'files'`).toHaveLength(1);137 });138});139