// tools/privacy-scan.ts: the release gate over every desktop artifact. Made-up terms only (the real ones live in a // file outside the repository). import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { collectTerms, kindOf, mask, parseTerms, scan, scanBuffer, squashfsOffset, type Term, UPSTREAM_PATHS } from '../tools/privacy-scan'; const T: Term[] = [{ text: 'Quillfeather', short: false }, { text: 'qz', short: true }]; // home folders are put together at run time, so tools/scrub.ts (which forbids them in the repository) passes const home = (root: string, name: string): string => `${root}${name}`; describe('privacy scan', () => { test('terms: comments and blanks skipped, never printed in full', () => { expect(parseTerms('# names\nQuillfeather\n\n qz \n')).toEqual(['Quillfeather', 'qz']); expect(mask('Quillfeather')).toBe('Q***'); const dir = mkdtempSync(join(tmpdir(), 'sb-terms-')); writeFileSync(join(dir, 't.txt'), `Quillfeather\nCRT\n${['hi', 'crt.fyi'].join('@')}\n`); // the release identity is never a forbidden term expect(collectTerms(join(dir, 't.txt'), false).map(t => t.text)).toEqual(['Quillfeather']); }); test('finds a term in any case, in UTF-8 and UTF-16LE', () => { const hits = scanBuffer('bin', Buffer.concat([Buffer.from('xx QUILLFEATHER yy'), Buffer.from('built by quillFeather', 'utf16le')]), T); expect(hits.map(h => h.encoding)).toEqual(['utf8', 'utf16le']); expect(hits.every(h => h.what === 'term Q***')).toBe(true); expect(scanBuffer('bin', Buffer.from('nothing to see'), T)).toEqual([]); }); test('a short term counts only in a path', () => { expect(scanBuffer('bin', Buffer.from('jazz quiz qz-mode'), T)).toEqual([]); expect(scanBuffer('bin', Buffer.from('/opt/qz/src'), T).some(h => h.what === 'term q***')).toBe(true); }); test('home folders of any account, generic ones allowed', () => { const hits = scanBuffer('bin', Buffer.from(`${home('/Users/', 'someone')}/work /home/runner/work ${home('C:\\Users\\', 'other')}\\x /Users/you/app /home/$USER`), []); expect(hits.map(h => h.what)).toEqual(['home folder of an account (s***)', 'home folder of an account (o***)']); }); test('upstream GTK icon paths pass, the same account elsewhere does not', () => { const gtk = UPSTREAM_PATHS.map(u => `inkscape:export-filename="${u}/x.png"`).join(' '); expect(scanBuffer('libgtk-3.so.0', Buffer.from(gtk), [])).toEqual([]); const other = UPSTREAM_PATHS[0]!.split('/').slice(0, 3).join('/') + '/secret/x'; expect(scanBuffer('bin', Buffer.from(other), []).length).toBe(1); }); test('folders, archives and debug files', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-scan-')); mkdirSync(join(dir, 'pkg/My.app/Contents'), { recursive: true }); writeFileSync(join(dir, 'pkg/My.app/Contents/clean.txt'), 'all good'); writeFileSync(join(dir, 'pkg/My.app/Contents/leak.bin'), Buffer.from(`panic at ${home('/Users/', 'quillfeather')}/src/main.rs`, 'utf8')); mkdirSync(join(dir, 'pkg/My.app.dSYM')); expect(spawnSync('tar', ['-czf', join(dir, 'bundle.tar.gz'), '-C', join(dir, 'pkg'), '.']).status).toBe(0); const r = scan([join(dir, 'bundle.tar.gz')], null, false); expect(r.errors).toEqual([]); const shown = r.hits.map(h => `${h.file}: ${h.what}`); expect(shown.some(s => s.startsWith('bundle.tar.gz!/') && s.includes('leak.bin') && s.includes('home folder'))).toBe(true); expect(shown.some(s => s.includes('My.app.dSYM: debug file'))).toBe(true); expect(r.files).toBeGreaterThanOrEqual(3); const clean = scan([join(dir, 'pkg/My.app/Contents/clean.txt')], null, false); expect(clean.hits).toEqual([]); expect(scan([join(dir, 'missing.dmg')], null, false).errors).toHaveLength(1); }); test('container kinds', () => { expect(kindOf('a/storebound_0.1.0_universal.dmg', new Uint8Array())).toBe('dmg'); expect(kindOf('storebound_0.1.0_universal.app.tar.gz', new Uint8Array())).toBe('tar'); expect(kindOf('storebound_0.1.0_amd64.deb', new Uint8Array())).toBe('deb'); expect(kindOf('storebound_0.1.0_amd64.AppImage', new Uint8Array())).toBe('appimage'); expect(kindOf('storebound_0.1.0_x64-setup.exe', Buffer.from('MZ....Nullsoft Install System'))).toBe('nsis'); expect(kindOf('storebound-desktop.exe', Buffer.from('MZ'))).toBe('file'); }); test('AppImage: the squashfs starts where the ELF runtime ends, not at an "hsqs" constant inside the runtime', () => { const img = Buffer.alloc(0x1000 + 16); img.write('\x7fELF', 0, 'latin1'); img[4] = 2; img[5] = 1; img.writeBigUInt64LE(0xc00n, 0x28); img.writeUInt16LE(64, 0x3a); img.writeUInt16LE(16, 0x3c); // 0xc00 + 64*16 = 0x1000 img.write('hsqs', 0x800, 'latin1'); img.write('hsqs', 0x1000, 'latin1'); expect(squashfsOffset(img)).toBe(0x1000); const plain = Buffer.alloc(0x900); plain.write('hsqs', 0x800, 'latin1'); expect(squashfsOffset(plain)).toBe(0x800); }); });