/** * lib/support-bundle.ts — the REPRODUCTION half of a support report. * * A report that carries only the CLI's outputs (`runs/`) tells support WHAT * happened; it cannot tell them WHY until they hold what the CLI READ. The * DemoGestionFlotte XD-005 incident (2026-09-04) was diagnosed from the * envelope alone only because the contradiction was mechanical — a scaffolder * defect on a client corpus would have needed the corpus. This module turns * the caller's `inputs[]` (paths the failing CLI read, relative to the * project) into an attachable, emailable snapshot: * * - path-traversal guarded (`validatePathSecurity`), symlinks never followed; * - an exclusion list (VCS, build output, previous reports, local secrets, * the client-sources binaries) — `inputs/` never carries a secret file; * - text only, every file through `scrubSecrets` (a binary cannot be * scrubbed, so it is never included — said out loud in the summary); * - hard caps (total / per-file / count) — over the total cap NOTHING is * attached, the report stays valid on its own and says so; * - a deterministic zip (sorted entries, one fixed date, UTF-8 names so * `entité.md` survives every extractor) plus `repro.md` telling support * how to re-run the verbatim command against the bundled layout. * * Consumed by support-report/cli/create. Pure functions + async fs reads — * tests run it against temp directories. */ import { createHash } from 'node:crypto' import { lstat, readdir, readFile } from 'node:fs/promises' import path from 'node:path' import JSZip from 'jszip' import { minimatch } from 'minimatch' import { validatePathSecurity } from './fs.js' import { scopeLabel, type RuleContradiction } from './rule-contradictions.js' import { scrubSecrets, type ReportVersions } from './support-report.js' // --------------------------------------------------------------------------- // Limits + exclusions // --------------------------------------------------------------------------- /** Uncompressed total — markdown deflates ~5×, so the mail attachment stays * well under the usual 20-25 MB ceilings. */ export const BUNDLE_TOTAL_CAP_BYTES = 25 * 1024 * 1024 export const BUNDLE_FILE_CAP_BYTES = 5 * 1024 * 1024 export const BUNDLE_MAX_FILES = 5000 /** Never bundled, whatever `inputs[]` names. Matched against the posix path * relative to projectPath (`minimatch`, dot: true). */ export const BUNDLE_DEFAULT_EXCLUDES: readonly string[] = [ '**/.git/**', '**/node_modules/**', '**/bin/**', '**/obj/**', '**/dist/**', '.smartstack/support/**', // previous reports — a bundle never nests a bundle '**/*.Local.json', // appsettings.Local.json — the gitignored secrets file '**/.env', '**/.env.*', '**/secrets.json', '**/*.pfx', '**/*.p12', '**/*.key', '**/*.pem', '**/raw/**', // .smartstack/sources/SRC-NNN/raw/ — binaries the CLIs never read ] /** Extensions read as text without sniffing. Anything else is sniffed for a * NUL byte in its first 8 KiB. */ export const BUNDLE_TEXT_EXTENSIONS: ReadonlySet = new Set([ '.md', '.json', '.yml', '.yaml', '.txt', '.csv', '.cs', '.ts', '.tsx', '.js', '.mjs', '.cjs', '.css', '.html', '.xml', '.csproj', '.sln', '.props', '.targets', '.config', '.sql', '.editorconfig', '.hbs', ]) const KNOWN_BINARY_EXTENSIONS: ReadonlySet = new Set([ '.pdf', '.docx', '.xlsx', '.pptx', '.doc', '.xls', '.zip', '.gz', '.7z', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.dll', '.exe', '.pdb', '.nupkg', '.db', '.sqlite', ]) const SNIFF_BYTES = 8 * 1024 // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type SkipReason = 'excluded' | 'binary' | 'file-too-large' | 'over-cap' | 'symlink' | 'missing' | 'too-many-files' /** One bundled file — `relPath` is posix, relative to projectPath. */ export interface BundledFile { relPath: string /** Bytes AFTER scrubbing (what the bundle carries). */ bytes: number scrubbed: boolean } export interface SkippedFile { relPath: string reason: SkipReason } export interface BundleSummary { requested: string[] files: BundledFile[] skipped: SkippedFile[] totalBytes: number cap: number /** The total cap tripped — the caller attaches NOTHING. */ overCap: boolean } export interface CollectedInputs { summary: BundleSummary /** relPath → scrubbed content, in `summary.files` order. */ contents: Map } export interface CollectOptions { projectPath: string /** Paths relative to projectPath (a file or a directory each). */ inputs: string[] excludes?: string[] totalCapBytes?: number fileCapBytes?: number maxFiles?: number } // --------------------------------------------------------------------------- // Collection // --------------------------------------------------------------------------- function toPosix(p: string): string { return p.replace(/\\/g, '/') } /** Text when the extension says so, binary when it says so, else a NUL sniff. */ export function isTextInput(relPath: string, head: Buffer): boolean { const ext = path.posix.extname(relPath).toLowerCase() if (BUNDLE_TEXT_EXTENSIONS.has(ext)) return true if (KNOWN_BINARY_EXTENSIONS.has(ext)) return false return !head.subarray(0, SNIFF_BYTES).includes(0) } function isExcluded(rel: string, excludes: readonly string[]): boolean { return excludes.some((pattern) => minimatch(rel, pattern, { dot: true })) } /** * Enumerate, guard, filter, scrub. Throws `FileSystemError` when an input * escapes projectPath — that is a spec error the caller must refuse whole. * Every other anomaly is a `skipped` row, never a throw. */ export async function collectInputs(opts: CollectOptions): Promise { const projectRoot = path.resolve(opts.projectPath) const excludes = [...BUNDLE_DEFAULT_EXCLUDES, ...(opts.excludes ?? [])] const totalCap = opts.totalCapBytes ?? BUNDLE_TOTAL_CAP_BYTES const fileCap = opts.fileCapBytes ?? BUNDLE_FILE_CAP_BYTES const maxFiles = opts.maxFiles ?? BUNDLE_MAX_FILES const relOf = (abs: string): string => toPosix(path.relative(projectRoot, abs)) const skipped: SkippedFile[] = [] const candidates = new Set() const walk = async (dirAbs: string): Promise => { const entries = await readdir(dirAbs, { withFileTypes: true }) for (const entry of entries) { const abs = path.join(dirAbs, entry.name) const rel = relOf(abs) if (entry.isSymbolicLink()) { skipped.push({ relPath: rel, reason: 'symlink' }) continue } if (entry.isDirectory()) { // Prune excluded subtrees early (`.git`, `node_modules`) — one probe path // under the directory tells whether the pattern swallows it. if (isExcluded(`${rel}/__probe__`, excludes)) { skipped.push({ relPath: `${rel}/`, reason: 'excluded' }) continue } await walk(abs) } else if (entry.isFile()) { candidates.add(abs) } } } for (const input of opts.inputs) { const abs = path.resolve(projectRoot, input) validatePathSecurity(abs, projectRoot) const rel = relOf(abs) || '.' let st try { st = await lstat(abs) } catch { skipped.push({ relPath: rel, reason: 'missing' }) continue } if (st.isSymbolicLink()) { skipped.push({ relPath: rel, reason: 'symlink' }) continue } if (st.isDirectory()) await walk(abs) else if (st.isFile()) candidates.add(abs) } const files: BundledFile[] = [] const contents = new Map() let total = 0 let overCap = false const ordered = [...candidates].map((abs) => ({ abs, rel: relOf(abs) })).sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0)) for (const { abs, rel } of ordered) { if (isExcluded(rel, excludes)) { skipped.push({ relPath: rel, reason: 'excluded' }) continue } if (overCap) { skipped.push({ relPath: rel, reason: 'over-cap' }) continue } if (files.length >= maxFiles) { skipped.push({ relPath: rel, reason: 'too-many-files' }) continue } const st = await lstat(abs) if (st.size > fileCap) { skipped.push({ relPath: rel, reason: 'file-too-large' }) continue } const buf = await readFile(abs) if (!isTextInput(rel, buf)) { skipped.push({ relPath: rel, reason: 'binary' }) continue } const text = buf.toString('utf8') const clean = scrubSecrets(text) const bytes = Buffer.byteLength(clean, 'utf8') if (total + bytes > totalCap) { overCap = true skipped.push({ relPath: rel, reason: 'over-cap' }) continue } total += bytes files.push({ relPath: rel, bytes, scrubbed: clean !== text }) contents.set(rel, clean) } return { summary: { requested: [...opts.inputs], files, skipped, totalBytes: total, cap: totalCap, overCap }, contents, } } // --------------------------------------------------------------------------- // Command rewrite + repro.md // --------------------------------------------------------------------------- /** The bundle's root marker in a rewritten command. */ export const BUNDLE_INPUTS_MARKER = '/inputs' function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** * Point every absolute mention of the project at the bundled layout — * both slash styles, `file:///`, case-insensitive on Windows. `rewritten` * false when the command names no absolute project path (relative specs * such as audit-ba's `baRoot: ".smartstack/ba"` simply run from `inputs/`). */ export function rewriteCommandForBundle(command: string, projectPath: string): { command: string; rewritten: boolean } { const root = toPosix(path.resolve(projectPath)).replace(/\/+$/, '') if (root === '') return { command, rewritten: false } const segments = root.split('/').filter((s) => s !== '') const pattern = segments.map(escapeRe).join('[\\\\/]+') const re = new RegExp(`(?:file:\\/\\/\\/)?${pattern}(?=[\\\\/"'\\s]|$)`, process.platform === 'win32' ? 'gi' : 'g') let rewritten = false let out = command.replace(re, () => { rewritten = true return BUNDLE_INPUTS_MARKER }) // The tail after the marker keeps the client's slash style — fold it (a // JSON-escaped `\\` is ONE separator, never two). out = out.replace(new RegExp(`${escapeRe(BUNDLE_INPUTS_MARKER)}((?:\\\\+[^\\s"'\\\\]*)+)`, 'g'), (_m, tail: string) => BUNDLE_INPUTS_MARKER + tail.replace(/\\+/g, '/')) return { command: out, rewritten } } export interface ReproArgs { fingerprint: string classification: string command: string rewrittenCommand: string rewritten: boolean contradictions?: RuleContradiction[] disputedRuleIds?: string[] summary: BundleSummary | null versions: ReportVersions } /** `repro.md` — how support re-runs the command on the bundled inputs. */ export function renderReproMd(a: ReproArgs): string { const lines: string[] = [] lines.push(`# Reproduction — ${a.fingerprint}`) lines.push('') lines.push('## Disposition de l’archive') lines.push('') lines.push('```') lines.push(`support-${a.fingerprint}/`) lines.push(' report.md — le rapport') lines.push(' report.json — le même, machine') lines.push(' repro.md — ce fichier') lines.push(' runs/ — sorties capturées (stdout/stderr, secrets masqués)') if (a.summary && a.summary.files.length > 0) { lines.push(' inputs.manifest.json — inventaire des entrées jointes') lines.push(' inputs/ — ce que la CLI a LU, arborescence relative au projet client') } lines.push('```') lines.push('') lines.push('## Commande') lines.push('') lines.push('Verbatim côté client :') lines.push('') lines.push('```') lines.push(a.command) lines.push('```') lines.push('') if (a.summary && a.summary.files.length > 0) { if (a.rewritten) { lines.push(`Réécrite pour l’archive (le chemin du projet client devient \`${BUNDLE_INPUTS_MARKER}\`, à remplacer par le chemin absolu du dossier \`inputs/\` extrait) :`) lines.push('') lines.push('```') lines.push(a.rewrittenCommand) lines.push('```') } else { lines.push('La commande ne nomme aucun chemin absolu du projet : ses chemins sont RELATIFS. L’exécuter depuis le dossier `inputs/` extrait (cwd = `inputs/`), la CLI installée par `ss install` sur le poste de reproduction.') } lines.push('') lines.push('Une CLI d’audit en `dryRun` n’écrit rien ; sans `dryRun`, elle n’écrit que ses verdicts sous `inputs/.smartstack/ba/**/_audit/`.') } else if (a.summary && a.summary.overCap) { lines.push('⚠️ Les entrées dépassaient le plafond du bundle : AUCUNE n’est jointe. Demander au client un `inputs[]` resserré (par exemple `.smartstack/ba//`).') } else { lines.push('Aucune entrée jointe (le rapport a été produit sans `inputs[]`). Pour reproduire, demander au client ce que la CLI a lu — pour `audit-ba`, l’arbre `.smartstack/ba`.') } lines.push('') lines.push('## Observation attendue') lines.push('') if (a.contradictions && a.contradictions.length > 0) { lines.push('Le même envelope doit porter, sur la même portée, un finding miroir en erreur ET sa règle primaire en `ok` :') lines.push('') lines.push('| Miroir | Primaire | Portée |') lines.push('|---|---|---|') for (const c of a.contradictions) lines.push(`| \`${c.mirrorRuleId}\` (${c.mirrorSeverity}) | \`${c.primaryRuleId}\` (ok) | ${scopeLabel(c.scope)} |`) lines.push('') lines.push('À vérifier côté CLI : le prédicat du miroir contre celui de la règle primaire (une exemption manquante ?), et le `dedupOf` du registre (la paire est-elle vraiment le même évaluateur ?).') } else if (a.disputedRuleIds && a.disputedRuleIds.length > 0) { lines.push(`Verdict contesté sur : ${a.disputedRuleIds.map((r) => `\`${r}\``).join(', ')} — confronter chaque finding au contrat de sa règle (table de son \`/ba-audit-*\` SKILL.md) et à la ligne de document que l’analyste cite dans \`report.md\`.`) } else { lines.push(`Classification \`${a.classification}\` — la signature d’échec normalisée de \`runs/\` doit se reproduire à l’identique.`) } lines.push('') lines.push('## Versions du poste client') lines.push('') lines.push('| Composant | Version |') lines.push('|---|---|') lines.push(`| CLI SmartStack installée | ${a.versions.cliInstalled} |`) lines.push(`| CLI SmartStack — dernière publiée (npm) | ${a.versions.cliLatest} |`) lines.push(`| Socle SmartStack | ${a.versions.socle} |`) lines.push(`| @atlashub/smartstack (web) | ${a.versions.web} |`) lines.push(`| Node.js | ${a.versions.node} |`) lines.push(`| OS | ${a.versions.os} |`) lines.push('') return lines.join('\n') } // --------------------------------------------------------------------------- // Zip // --------------------------------------------------------------------------- export interface ZipEntry { /** Posix path inside the archive. */ path: string content: string | Buffer } /** * Deterministic archive: sorted entries, ONE date for every entry, DEFLATE 6, * UNIX platform, UTF-8 names (jszip's default — the accented BA file names * survive Windows Explorer's extractor). Built from memory, never by zipping * the report folder — a re-invocation cannot nest the previous archive. */ export async function buildZip(entries: ZipEntry[], opts: { date: Date }): Promise { const zip = new JSZip() const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) for (const e of sorted) zip.file(e.path, e.content, { date: opts.date, createFolders: false }) return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 }, platform: 'UNIX' }) } export function sha256Of(buf: Buffer): string { return createHash('sha256').update(buf).digest('hex') }