diff --git a/packages/db/src/hierarchy-writer-coverage.test.ts b/packages/db/src/hierarchy-writer-coverage.test.ts index 747e62f4..fc5265fd 100644 --- a/packages/db/src/hierarchy-writer-coverage.test.ts +++ b/packages/db/src/hierarchy-writer-coverage.test.ts @@ -15,11 +15,17 @@ * (`export { companies as c } from`), an export of a locally bound * alias (`import { companies }; export { companies as co }`), and a * binding derived from a namespace (`export const co = ns.companies`) - * all propagate symbol identity to the consumer. Destructuring a - * NON-class property from a schema namespace over-approximates: the - * binding is treated as a schema namespace itself (fail-closed), and - * single-alias derivation from a namespace (`const t = ns.companies`) - * is folded in to a fixed depth. + * all propagate symbol identity to the consumer. The DEFAULT-export + * slot is an export name like any other (`export { companies as + * default } from`, `export default companies`), so a bare default + * import of a conduit resolves against the map too; namespace-member + * write targets match the source's EXPORTED names — renames included + * (`M.co` after `export { companies as co } from`) — not just the + * original class symbols. Destructuring a NON-class property from a + * schema namespace over-approximates: the binding is treated as a + * schema namespace itself (fail-closed), and single-alias derivation + * from a namespace (`const t = ns.companies`) is folded in to a + * fixed depth. * (ii) Literal prong — a class-table name inside a string or template * span that also carries SQL context fails outside the allowlist. * Spans are produced by a real lexer, so comments cannot hide code @@ -27,7 +33,13 @@ * migrations are excluded from this prong only (per contract). * (iii) Raw-execution prong — content-independent. A file is RAW-CAPABLE * when it imports a database driver or the createDb/createPgliteDb - * factories (directly or via namespace). In a raw-capable file + * factories — directly, via namespace, by destructuring a factory + * symbol out of a schema namespace, or from a capability CONDUIT. + * Conduits carry their own exported-name maps computed to a + * fixpoint, so a factory renamed at any hop (`export { createDb as + * mk } from`, `export default createDb`) still marks every module + * down the chain, and a tracked dynamic import of a conduit is an + * import edge like a static one. In a raw-capable file * outside the allowlist and register, EVERY `.execute(`, `.query(`, * and `.unsafe(` call fails, on any receiver, with any argument — * there is no tagged-template exemption (§6.3(b): "regardless of what @@ -43,7 +55,9 @@ * Unanalyzable import routes fail everywhere except their own enumerated * disposition registers below: a dynamic import whose specifier is not a * single string literal, and createRequire (tracked through import aliasing - * — `createRequire as x` — and destructuring from the module namespace; a + * — `createRequire as x` — and destructuring from the module namespace, + * whether that namespace came from a static `import * as` or a tracked + * dynamic import; a * route that hides the NAME entirely, e.g. re-exporting createRequire from a * helper, falls under the value-flow residual). A LITERAL dynamic import is * an import edge like any static import (contract rev 9): its bindings @@ -543,6 +557,21 @@ function computeSchemaConduits(files: FileFacts[], fileSet: Set): Schema mine.named.add(m[1]!); } } + // The default slot is an export name like any other: + // `export default companies;` / `export default mod.companies;` + for (const m of f.code.matchAll(/export\s+default\s+(\w+)\s*;/g)) { + if (aliases.named.includes(m[1]!)) mine.named.add('default'); + if (aliases.namespaces.includes(m[1]!)) mine.ns.add('default'); + } + for (const ns of aliases.namespaces) { + if ( + new RegExp( + `export\\s+default\\s+${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${CLASS_SYMBOLS.join('|')})\\b`, + ).test(f.code) + ) { + mine.named.add('default'); + } + } } if (mine.named.size + mine.ns.size > before) { conduits.set(f.rel, mine); @@ -556,6 +585,10 @@ function computeSchemaConduits(files: FileFacts[], fileSet: Set): Schema interface ClassAliases { named: string[]; // local identifiers bound to class-table symbols namespaces: string[]; // namespace identifiers over a schema source + // Member names under which a class symbol is reachable on SOME imported + // schema source (class symbols plus every renamed conduit export the file + // imports) — the alternation for namespace-member write targets. + memberSyms: Set; } /** @@ -605,13 +638,21 @@ function destructureBindings( function classAliases(f: FileFacts, conduits: SchemaConduits, fileSet: Set): ClassAliases { const named = new Set(); const namespaces = new Set(); + const memberSyms = new Set(CLASS_SYMBOLS); for (const m of f.code.matchAll(IMPORT_RE)) { - const [, typeOnly, , namedList, nsName, , spec] = m; + const [, typeOnly, defaultWith, namedList, nsName, defaultBare, spec] = m; if (typeOnly) continue; const src = schemaExportsOf(f.rel, spec!, conduits, fileSet); if (src === null || (src.named.size === 0 && src.ns.size === 0)) continue; + for (const n of src.named) memberSyms.add(n); if (nsName) namespaces.add(nsName); if (namedList) importBindings(namedList, src, named, namespaces); + // A default import binds whatever the source exports as `default`. + const dflt = defaultWith ?? defaultBare; + if (dflt) { + if (src.named.has('default')) named.add(dflt); + if (src.ns.has('default')) namespaces.add(dflt); + } } // Literal dynamic imports of a schema source are import edges like any // other (contract rev 9): both binding shapes feed prong (i). @@ -621,6 +662,7 @@ function classAliases(f: FileFacts, conduits: SchemaConduits, fileSet: Set, ): { driver: Set; factory: Set } { const driver = new Set(); - const factory = new Set(); + // Factory conduits carry a NAME MAP (module → exported names under which a + // factory is reachable) so renames propagate hop by hop, exactly like the + // schema export map — a literal-name gate at any hop would launder. + const factoryNames = new Map>(); const isDriverSpec = (rel: string, spec: string): boolean => { if (DRIVER_SPECIFIERS.includes(spec)) return true; const r = resolveSpecifier(rel, spec, fileSet); return r !== null && driver.has(r); }; - const isFactorySpec = (rel: string, spec: string): boolean => { - if (spec === '@mosaicstack/db') return true; + const factoryNamesOf = (rel: string, spec: string): Set | null => { + if (spec === '@mosaicstack/db') return new Set(DB_FACTORY_SYMBOLS); const r = resolveSpecifier(rel, spec, fileSet); - return r !== null && factory.has(r); + return r !== null ? (factoryNames.get(r) ?? null) : null; }; let changed = true; while (changed) { @@ -691,71 +740,93 @@ function computeCapabilityConduits( driver.add(f.rel); changed = true; } - if (!factory.has(f.rel) && isFactorySpec(f.rel, spec)) { - const named = m[2]; - if (named === undefined || /\b(createDb|createPgliteDb)\b/.test(named)) { - factory.add(f.rel); - changed = true; - } - } } // `export { default as x } from 'postgres'` matches EXPORT_FROM_RE's // named branch above; `export x from` is not valid syntax — covered. - // Export-of-local factory bindings: a module that BINDS the factory - // (named import, namespace member, or tracked dynamic import) and - // exports that binding under any name is a factory conduit even with - // no `export … from` clause (`export const mk = mod.createDb`). - if (!factory.has(f.rel)) { - const locals = new Set(); - const nss = new Set(); - for (const im of f.code.matchAll(IMPORT_RE)) { - const [, typeOnly, , namedList, nsName, , spec] = im; - if (typeOnly || !isFactorySpec(f.rel, spec!)) continue; - if (nsName) nss.add(nsName); - if (namedList) { - for (const part of namedList.split(',')) { - const am = /^(createDb|createPgliteDb)(?:\s+as\s+(\w+))?$/.exec(part.trim()); - if (am) locals.add(am[2] ?? am[1]!); - } - } - } - for (const dm of f.code.matchAll( - /(?:const|let|var)\s*(?:\{([^}]*)\}|(\w+))\s*=\s*await\s+import\s*\(\s*(['"])([^'"]+)\3\s*\)/g, - )) { - const [, pattern, nsName, , spec] = dm; - if (!isFactorySpec(f.rel, spec!)) continue; - if (nsName) nss.add(nsName); - if (pattern) { - for (const part of pattern.split(',')) { - const pm = /^(createDb|createPgliteDb)\s*(?::\s*(\w+))?/.exec(part.trim()); - if (pm) locals.add(pm[2] ?? pm[1]!); - } - } - } - for (const ns of nss) { - for (const am of f.code.matchAll( - new RegExp( - `(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}\\s*\\.\\s*(?:createDb|createPgliteDb)\\b`, - 'g', - ), - )) { - locals.add(am[1]!); - } - } - for (const local of locals) { - if ( - new RegExp(`export\\s+(?:const|let|var|function)\\s+${local}\\b`).test(f.code) || - new RegExp(`export\\s*\\{[^}]*\\b${local}\\b[^}]*\\}`).test(f.code) - ) { - factory.add(f.rel); - changed = true; - break; + const mine = factoryNames.get(f.rel) ?? new Set(); + const before = mine.size; + for (const m of f.code.matchAll(EXPORT_FROM_RE)) { + if (m[1]) continue; + const src = factoryNamesOf(f.rel, m[3]!); + if (src === null || src.size === 0) continue; + if (m[2] === undefined) { + // `export *` / `export * as x` — over-approximated to the source's + // names (membership is what confers capability on consumers). + for (const n of src) mine.add(n); + } else { + for (const part of m[2].split(',')) { + const seg = part.trim(); + if (!seg || seg.startsWith('type ')) continue; + const am = /^(\w+)\s+as\s+(\w+)$/.exec(seg); + if (src.has(am ? am[1]! : seg)) mine.add(am ? am[2]! : seg); } } } + // Locals bound to a factory (named import — aliased or not — namespace + // member extraction, or tracked dynamic-import destructure), then + // exported under ANY name and by ANY form, braces or default included + // (`export const mk = mod.createDb`, `export default createDb`). + const locals = new Set(); + const nss = new Map>(); + for (const im of f.code.matchAll(IMPORT_RE)) { + const [, typeOnly, , namedList, nsName, , spec] = im; + if (typeOnly) continue; + const src = factoryNamesOf(f.rel, spec!); + if (src === null || src.size === 0) continue; + if (nsName) nss.set(nsName, src); + if (namedList) { + for (const part of namedList.split(',')) { + const am = /^(\w+)(?:\s+as\s+(\w+))?$/.exec(part.trim()); + if (am && src.has(am[1]!)) locals.add(am[2] ?? am[1]!); + } + } + } + for (const dm of f.code.matchAll( + /(?:const|let|var)\s*(?:\{([^}]*)\}|(\w+))\s*=\s*await\s+import\s*\(\s*(['"])([^'"]+)\3\s*\)/g, + )) { + const [, pattern, nsName, , spec] = dm; + const src = factoryNamesOf(f.rel, spec!); + if (src === null || src.size === 0) continue; + if (nsName) nss.set(nsName, src); + if (pattern) { + for (const part of pattern.split(',')) { + const pm = /^(\w+)\s*(?::\s*(\w+))?/.exec(part.trim()); + if (pm && src.has(pm[1]!)) locals.add(pm[2] ?? pm[1]!); + } + } + } + for (const [ns, src] of nss) { + for (const am of f.code.matchAll( + new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}\\s*\\.\\s*(\\w+)\\b`, 'g'), + )) { + if (src.has(am[2]!)) locals.add(am[1]!); + } + } + if (locals.size > 0) { + for (const m of f.code.matchAll(/export\s*\{([^}]*)\}(?!\s*from)/g)) { + for (const part of m[1]!.split(',')) { + const seg = part.trim(); + if (!seg || seg.startsWith('type ')) continue; + const am = /^(\w+)(?:\s+as\s+(\w+))?$/.exec(seg); + if (am && locals.has(am[1]!)) mine.add(am[2] ?? am[1]!); + } + } + for (const local of locals) { + if (new RegExp(`export\\s+(?:const|let|var|function)\\s+${local}\\b`).test(f.code)) { + mine.add(local); + } + if (new RegExp(`export\\s+default\\s+${local}\\b`).test(f.code)) { + mine.add('default'); + } + } + } + if (mine.size > before) { + factoryNames.set(f.rel, mine); + changed = true; + } } } - return { driver, factory }; + return { driver, factory: new Set(factoryNames.keys()) }; } /** Value-import edge naming one of `symbols` (named import from anywhere, or literal dynamic package import while using the symbol). */ @@ -854,6 +925,25 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { const alias = /\bcreateRequire\s*:\s*(\w+)/.exec(m[1]!); if (alias) crNames.add(alias[1]!); } + // Destructuring from a STATIC or dynamically-bound module namespace: + // import * as M from 'node:module'; const { createRequire: x } = M; + const modNs = new Set(); + for (const m of code.matchAll(/import\s*\*\s*as\s+(\w+)\s*from\s*['"](?:node:)?module['"]/g)) { + modNs.add(m[1]!); + } + for (const m of code.matchAll( + /(?:const|let|var)\s+(\w+)\s*=\s*await\s+import\s*\(\s*['"](?:node:)?module['"]\s*\)/g, + )) { + modNs.add(m[1]!); + } + for (const ns of modNs) { + for (const m of code.matchAll( + new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*${ns}\\b`, 'g'), + )) { + const alias = /\bcreateRequire\s*(?::\s*(\w+))?/.exec(m[1]!); + if (alias) crNames.add(alias[1] ?? 'createRequire'); + } + } for (const n of crNames) { if (n === 'createRequire' || new RegExp(`\\b${n}\\s*\\(`).test(code)) { violations.push({ file: rel, prong: 'dynamic-import', detail: 'createRequire()' }); @@ -869,7 +959,9 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { // Word boundaries live inside each alternative: the bracket form ends in // `]` (non-word), where a trailing `\b` could never match. const targets: string[] = [...aliases.named].map((a) => `${a}\\b`); - const symAlt = CLASS_SYMBOLS.join('|'); + // Namespace members match the source's EXPORTED names (renames included), + // not just the original class symbols. + const symAlt = [...aliases.memberSyms].join('|'); for (const ns of aliases.namespaces) { // Allow intermediate property segments (ns.schema.companies — nested // namespace re-exports) and literal computed access (ns['companies']). @@ -877,9 +969,10 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\[\\s*['"\`](?:${symAlt})['"\`]\\s*\\]`); } if (targets.length > 0) { - // `\(\s*\(*` tolerates argument parenthesization: .insert((companies)). + // The argument prefix tolerates parenthesization, spread, and array + // wrapping: .insert((companies)), .insert(...[companies]). const writeRe = new RegExp( - `\\.\\s*(insert|update|delete)\\s*\\(\\s*\\(*\\s*(${targets.join('|')})`, + `\\.\\s*(insert|update|delete)\\s*\\(\\s*(?:(?:\\(|\\[|\\.\\.\\.)\\s*)*(${targets.join('|')})`, 'g', ); for (const m of code.matchAll(writeRe)) { @@ -924,7 +1017,7 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { // block comments, and quoting (including escaped quotes in span text). if (!inAllowlist && !isSchemaDefinition) { const kw = - '(?:insert\\s+into|update|delete\\s+from|from|join|truncate(?:\\s+table)?|alter\\s+table|drop\\s+table|references|into|copy)'; + '(?:insert\\s+into|update|delete\\s+from|from|join|truncate(?:\\s+table)?|alter\\s+table|drop\\s+table|references|into|copy|on|lock(?:\\s+table)?)'; const gap = '(?:\\s|/\\*[\\s\\S]*?\\*/|--[^\\n]*\\n)+'; const q = `(?:\\\\?["'\`])?`; const sqlAdjacentRe = new RegExp( @@ -956,6 +1049,13 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { if (r !== null && driverConduits.has(r)) conduitDriver = true; if (r !== null && factoryConduits.has(r)) factoryConduitImport = true; } + // A TRACKED dynamic import of a conduit is an import edge like any + // static one (contract rev 9) — for capability too, not just prong (i). + for (const m of f.code.matchAll(/=\s*await\s+import\s*\(\s*(['"])([^'"]+)\1\s*\)/g)) { + const r = resolveSpecifier(rel, m[2]!, fileSet); + if (r !== null && driverConduits.has(r)) conduitDriver = true; + if (r !== null && factoryConduits.has(r)) factoryConduitImport = true; + } const driverImport = literalDriver || conduitDriver; // Factory capability: a named/namespace edge to the factory symbols, OR // ANY value import from a factory conduit — the conduit may re-export @@ -967,6 +1067,12 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { (aliases.namespaces.length > 0 && new RegExp( `\\b(${aliases.namespaces.join('|')})(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(createDb|createPgliteDb)\\b`, + ).test(code)) || + // Destructuring a factory symbol OUT of a schema namespace confers + // capability whatever the local rename: const { createDb: mk } = dbns. + (aliases.namespaces.length > 0 && + new RegExp( + `(?:const|let|var)\\s*\\{[^}]*\\b(?:createDb|createPgliteDb)\\b[^}]*\\}\\s*=\\s*(?:await\\s+)?(?:${aliases.namespaces.join('|')})\\b`, ).test(code)); if (driverImport) { violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'direct driver import' }); @@ -995,8 +1101,10 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] { if (!driverImport) { // DI residual backstop: db/client-shaped receivers fire regardless of // detected capability (a handle can arrive by injection). + // The receiver may be a dotted name OR a literal bracketed member with + // a conventional name: this['db'].query(…). for (const m of code.matchAll( - /\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)\s*\.\s*(execute|query|unsafe)\s*\(/g, + /(?:\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)|\[\s*['"](?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)['"]\s*\])\s*\.\s*(execute|query|unsafe)\s*\(/g, )) { violations.push({ file: rel, @@ -1356,6 +1464,114 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => { name: 'E39 computed-member eval', src: `export function f(s: string) { return (globalThis as never)['eval'](s); }`, }, + { + name: 'E40 schema symbol renamed to default through conduit', + src: `import c from './evasion-mid9.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid9.ts', + src: `export { companies as default } from '@mosaicstack/db';`, + }, + ], + }, + { + name: 'E41 export default of locally bound schema symbol', + src: `import c from './evasion-mid10.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid10.ts', + src: `import { companies } from '@mosaicstack/db';\nexport default companies;`, + }, + ], + }, + { + name: 'E42 export default of dynamic namespace member', + src: `import c from './evasion-mid11.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid11.ts', + src: `const mod = await import('@mosaicstack/db');\nexport default mod.companies;`, + }, + ], + }, + { + name: 'E43 namespace member under renamed export', + src: `import * as M from './evasion-mid12.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(M.co).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid12.ts', + src: `export { companies as co } from '@mosaicstack/db';`, + }, + ], + }, + { + name: 'E44 dynamic namespace member under renamed export', + src: `import { db } from './x.js';\nconst M = await import('./evasion-mid13.js');\nexport async function f() { await db.insert(M.co).values({}); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid13.ts', + src: `export { companies as co } from '@mosaicstack/db';`, + }, + ], + }, + { + name: 'E45 two-hop factory export-from rename', + src: `import { mk } from './evasion-mid15.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid14.ts', + src: `export { createDb as mk } from '@mosaicstack/db';`, + }, + { + rel: 'packages/db/src/evasion-mid15.ts', + src: `export { mk } from './evasion-mid14.js';`, + }, + ], + }, + { + name: 'E46 export default of locally bound factory', + src: `import mk from './evasion-mid16.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid16.ts', + src: `import { createDb } from '@mosaicstack/db';\nexport default createDb;`, + }, + ], + }, + { + name: 'E47 tracked dynamic import of a factory conduit', + src: `const F = await import('./evasion-mid17.js');\nexport async function f(t: string) { await F.mk('u').execute('DELETE FROM ' + t); }`, + extras: [ + { + rel: 'packages/db/src/evasion-mid17.ts', + src: `export { createDb as mk } from '@mosaicstack/db';`, + }, + ], + }, + { + name: 'E48 factory destructure-rename from schema namespace', + src: `import * as dbns from '@mosaicstack/db';\nconst { createDb: mk } = dbns;\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`, + }, + { + name: 'E49 createRequire destructured from static module namespace', + src: `import * as M from 'node:module';\nconst { createRequire: mkReq } = M;\nconst req = mkReq(import.meta.url);\nexport const pg = req('postgres');`, + }, + { + name: 'E50 spread-argument schema write', + src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(...[companies]).values({}); }`, + }, + { + name: 'E51 GRANT ON class table in SQL span', + src: `export const q = 'GRANT SELECT ON hierarchy_grants TO auditor';`, + }, + { + name: 'E52 LOCK TABLE class table in SQL span', + src: `export const q = 'LOCK TABLE hierarchy_grants IN ACCESS EXCLUSIVE MODE';`, + }, + { + name: 'E53 bracketed conventional receiver', + src: `export class R { async f(t: string) { await this['db'].query('TRUNCATE ' + t); } }`, + }, ]; const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [ {