/** * support-bundle — the reproduction half of a support report. * * Pins what makes `inputs/` SAFE to email: an input escaping the project is a * refusal (throw), symlinks are never followed, the exclusion list holds * (VCS, build output, previous reports, local secrets, client-sources * binaries), every text file is scrubbed, binaries are skipped, the caps * trip cleanly — and the zip is deterministic with accented names intact. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import JSZip from 'jszip' import { FileSystemError } from '../fs.js' import { BUNDLE_INPUTS_MARKER, buildZip, collectInputs, isTextInput, renderReproMd, rewriteCommandForBundle, sha256Of, type BundleSummary, } from '../support-bundle.js' let root: string let projectPath: string const w = (rel: string, body: string | Buffer): void => { const abs = path.join(projectPath, ...rel.split('/')) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, body) } beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'ss-bundle-')) projectPath = path.join(root, 'client-project') fs.mkdirSync(projectPath, { recursive: true }) }) afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) }) /** * A file symlink needs a privilege on Windows; a directory JUNCTION does not, * and `lstat` reports it as a symbolic link all the same — so the guard is * exercised on every machine: a real symlink where allowed, a junction * otherwise. Returns null only when neither can be created. */ function linkDirectory(target: string, link: string): 'symlink' | 'junction' | null { try { fs.symlinkSync(target, link, 'dir') return 'symlink' } catch { try { fs.symlinkSync(target, link, 'junction') return 'junction' } catch { return null } } } describe('collectInputs — enumeration + guards', () => { it('walks a directory into sorted posix relative paths, contents scrubbed', async () => { w('.smartstack/ba/FLOTTE/PARC/entité.md', '### ENT-001 — Vehicle\n') w('.smartstack/ba/FLOTTE/PARC/vehicules/screen.md', '### SCR-… (SmartModuleHome)\n- **Entité** : — (agrégation)\n') w('.smartstack/ba/FLOTTE/index.md', '# FLOTTE\nconnection: Server=db;Password=Sup3rS3cret!;\n') const { summary, contents } = await collectInputs({ projectPath, inputs: ['.smartstack/ba'] }) expect(summary.files.map((f) => f.relPath)).toEqual([ '.smartstack/ba/FLOTTE/PARC/entité.md', '.smartstack/ba/FLOTTE/PARC/vehicules/screen.md', '.smartstack/ba/FLOTTE/index.md', ]) expect(summary.overCap).toBe(false) expect(summary.files.find((f) => f.relPath.endsWith('index.md'))!.scrubbed).toBe(true) expect(contents.get('.smartstack/ba/FLOTTE/index.md')).toContain('Password=') expect(contents.get('.smartstack/ba/FLOTTE/index.md')).not.toContain('Sup3rS3cret!') expect(summary.totalBytes).toBe(summary.files.reduce((n, f) => n + f.bytes, 0)) }) it('every default exclusion is skipped as `excluded` — VCS, build output, previous reports, local secrets, sources raw/', async () => { w('.git/HEAD', 'ref: refs/heads/develop\n') w('node_modules/x/index.js', 'module.exports = 1\n') w('src/Acme.Api/bin/Debug/x.txt', 'built\n') w('src/Acme.Api/obj/x.txt', 'built\n') w('src/Acme.Api/appsettings.Local.json', '{"Password":"x"}\n') w('src/Acme.Api/appsettings.json', '{"Logging":{}}\n') w('.env.local', 'TOKEN=abc\n') w('.smartstack/support/deadbeef0000/report.md', '# old report\n') w('.smartstack/sources/SRC-001/raw/x.pdf', Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0x01])) w('.smartstack/sources/SRC-001/source.md', '# SRC-001\n') const { summary } = await collectInputs({ projectPath, inputs: ['.'] }) expect(summary.files.map((f) => f.relPath)).toEqual(['.smartstack/sources/SRC-001/source.md', 'src/Acme.Api/appsettings.json']) const excluded = summary.skipped.filter((s) => s.reason === 'excluded').map((s) => s.relPath) for (const must of ['.git/', 'node_modules/', 'src/Acme.Api/bin/', 'src/Acme.Api/obj/', 'src/Acme.Api/appsettings.Local.json', '.env.local', '.smartstack/support/', '.smartstack/sources/SRC-001/raw/']) { expect(excluded, `missing exclusion ${must}`).toContain(must) } }) it('an input escaping projectPath throws FileSystemError — the caller refuses whole', async () => { fs.writeFileSync(path.join(root, 'outside.md'), 'secret') await expect(collectInputs({ projectPath, inputs: ['../outside.md'] })).rejects.toBeInstanceOf(FileSystemError) await expect(collectInputs({ projectPath, inputs: ['.smartstack/../../outside.md'] })).rejects.toBeInstanceOf(FileSystemError) }) it('a link is never followed (it may point outside the project) — skipped as `symlink`, whether walked into or named directly', async () => { const outside = path.join(root, 'outside-dir') fs.mkdirSync(outside) fs.writeFileSync(path.join(outside, 'secret.md'), 'secret') w('docs/a.md', 'a\n') const kind = linkDirectory(outside, path.join(projectPath, 'docs', 'linkdir')) expect(kind, 'neither a symlink nor a junction could be created on this machine').not.toBeNull() // Walked into from its parent… const walked = await collectInputs({ projectPath, inputs: ['docs'] }) expect(walked.summary.files.map((f) => f.relPath)).toEqual(['docs/a.md']) expect(walked.summary.skipped).toContainEqual({ relPath: 'docs/linkdir', reason: 'symlink' }) // …and named directly as an input. const direct = await collectInputs({ projectPath, inputs: ['docs/linkdir'] }) expect(direct.summary.files).toEqual([]) expect(direct.summary.skipped).toContainEqual({ relPath: 'docs/linkdir', reason: 'symlink' }) expect([...walked.contents.values(), ...direct.contents.values()].join('')).not.toContain('secret') }) it('a missing input is `missing`, not fatal; a binary (NUL bytes, unknown extension) is `binary`', async () => { w('docs/a.md', 'a\n') w('docs/blob.bin', Buffer.from([0x00, 0xff, 0x10, 0x00])) const { summary } = await collectInputs({ projectPath, inputs: ['docs', 'nope/absent.md'] }) expect(summary.files.map((f) => f.relPath)).toEqual(['docs/a.md']) expect(summary.skipped).toContainEqual({ relPath: 'nope/absent.md', reason: 'missing' }) expect(summary.skipped).toContainEqual({ relPath: 'docs/blob.bin', reason: 'binary' }) }) it('the total cap trips: overCap, the tripping file and every later one `over-cap`', async () => { w('docs/a.md', 'x'.repeat(100) + '\n') w('docs/b.md', 'y'.repeat(100) + '\n') w('docs/c.md', 'z'.repeat(100) + '\n') const { summary } = await collectInputs({ projectPath, inputs: ['docs'], totalCapBytes: 150 }) expect(summary.overCap).toBe(true) expect(summary.files.map((f) => f.relPath)).toEqual(['docs/a.md']) expect(summary.skipped.filter((s) => s.reason === 'over-cap').map((s) => s.relPath)).toEqual(['docs/b.md', 'docs/c.md']) }) it('a single file over the per-file cap is `file-too-large`, the rest still bundles', async () => { w('docs/big.md', 'x'.repeat(2000)) w('docs/small.md', 'ok\n') const { summary } = await collectInputs({ projectPath, inputs: ['docs'], fileCapBytes: 1000 }) expect(summary.files.map((f) => f.relPath)).toEqual(['docs/small.md']) expect(summary.skipped).toContainEqual({ relPath: 'docs/big.md', reason: 'file-too-large' }) expect(summary.overCap).toBe(false) }) it('isTextInput: extension allowlist, known binaries, NUL sniff', () => { expect(isTextInput('a/entité.md', Buffer.from([0x00]))).toBe(true) // extension wins expect(isTextInput('a/x.pdf', Buffer.from('plain'))).toBe(false) expect(isTextInput('a/LICENSE', Buffer.from('MIT'))).toBe(true) expect(isTextInput('a/LICENSE', Buffer.from([0x4d, 0x00]))).toBe(false) }) }) describe('rewriteCommandForBundle', () => { it('rewrites the project path in both slash styles (and file:///) to the bundle marker', () => { const win = 'C:\\Dev\\Clients\\Demo\\features\\flotte' const cmd = `npx tsx skills/x/index.ts --spec '{"baRoot":"C:\\\\Dev\\\\Clients\\\\Demo\\\\features\\\\flotte\\\\.smartstack\\\\ba","projectRoot":"C:/Dev/Clients/Demo/features/flotte"}' --workdir file:///C:/Dev/Clients/Demo/features/flotte` const out = rewriteCommandForBundle(cmd, win) expect(out.rewritten).toBe(true) expect(out.command).not.toMatch(/Clients/i) expect(out.command).toContain(`"projectRoot":"${BUNDLE_INPUTS_MARKER}"`) expect(out.command).toContain(`--workdir ${BUNDLE_INPUTS_MARKER}`) expect(out.command).toContain(`${BUNDLE_INPUTS_MARKER}/.smartstack/ba`) }) it('a command with only relative paths is left alone, rewritten:false', () => { const cmd = `npx tsx skills/x/index.ts --spec '{"baRoot":".smartstack/ba"}'` expect(rewriteCommandForBundle(cmd, 'D:\\Dev\\proj')).toEqual({ command: cmd, rewritten: false }) }) }) describe('buildZip / renderReproMd', () => { const summary: BundleSummary = { requested: ['.smartstack/ba'], files: [{ relPath: '.smartstack/ba/FLOTTE/PARC/entité.md', bytes: 12, scrubbed: false }], skipped: [], totalBytes: 12, cap: 1000, overCap: false, } it('round-trips through JSZip with the accented name intact, identical bytes across builds (fixed date)', async () => { const entries = [ { path: 'support-abc/inputs/.smartstack/ba/FLOTTE/PARC/entité.md', content: '### ENT-001\n' }, { path: 'support-abc/report.md', content: '# rapport\n' }, ] const date = new Date('2026-09-04T10:00:00.000Z') const a = await buildZip(entries, { date }) const b = await buildZip([...entries].reverse(), { date }) expect(a.equals(b)).toBe(true) expect(sha256Of(a)).toBe(sha256Of(b)) const zip = await JSZip.loadAsync(a) expect(Object.keys(zip.files).sort()).toEqual(['support-abc/inputs/.smartstack/ba/FLOTTE/PARC/entité.md', 'support-abc/report.md']) expect(await zip.file('support-abc/inputs/.smartstack/ba/FLOTTE/PARC/entité.md')!.async('string')).toBe('### ENT-001\n') }) it('repro.md carries the layout, the rewritten command and the contradiction pair', () => { const md = renderReproMd({ fingerprint: 'abc123def456', classification: 'rule-contradiction', command: `npx tsx skills/x/index.ts --spec '{"baRoot":"D:/proj/.smartstack/ba"}'`, rewrittenCommand: `npx tsx skills/x/index.ts --spec '{"baRoot":"${BUNDLE_INPUTS_MARKER}/.smartstack/ba"}'`, rewritten: true, contradictions: [ { mirrorRuleId: 'XD-005', primaryRuleId: 'SCR-003', scope: { app: 'FLOTTE', module: 'PARC' }, mirrorSeverity: 'err', mirrorMessage: 'm', mirrorEvidence: [], primaryMessage: 'p' }, ], summary, versions: { cliInstalled: '5.19.2', cliLatest: '5.19.2', socle: '3.66.0', web: '3.66.0', node: 'v24.0.0', os: 'win32 10.0.26200' }, }) expect(md).toContain('support-abc123def456/') expect(md).toContain(' inputs/ ') expect(md).toContain(`${BUNDLE_INPUTS_MARKER}/.smartstack/ba`) expect(md).toContain('| `XD-005` (err) | `SCR-003` (ok) | FLOTTE / PARC |') expect(md).toContain('| CLI SmartStack installée | 5.19.2 |') }) it('repro.md says when nothing was attached (no inputs / over cap)', () => { const base = { fingerprint: 'f', classification: 'cli-internal', command: 'c', rewrittenCommand: 'c', rewritten: false, versions: { cliInstalled: '1', cliLatest: '1', socle: '1', web: '1', node: '1', os: '1' } } expect(renderReproMd({ ...base, summary: null })).toContain('Aucune entrée jointe') expect(renderReproMd({ ...base, summary: { ...summary, files: [], totalBytes: 0, overCap: true } })).toContain('AUCUNE n’est jointe') }) })