Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { compileCustomRule } from './scan.mjs'
export const CONFIG_NAMES = ['sparepack.yaml', 'sparepack.yml']

const FILE_KEYS = ['include', 'interfaces', 'tests']
const KNOWN_KEYS = new Set([...FILE_KEYS, 'task', 'fixtures', 'redact', 'scanRules', 'allowFindings', 'out', 'stripPrefix'])
const KNOWN_KEYS = new Set([...FILE_KEYS, 'task', 'fixtures', 'redact', 'scanRules', 'allowFindings', 'out', 'stripPrefix', 'remap'])

class ConfigError extends Error {}

Expand Down Expand Up @@ -105,6 +105,31 @@ function parseStripPrefix(raw) {
return prefix
}

/**
* Parse remap entries. Each entry must have `from` and `to` strings.
* Both values are validated against traversal and absoluteness.
* Order matters: first match wins at pack time.
*/
function parseRemap(raw) {
if (raw === undefined || raw === null) return []
if (!Array.isArray(raw)) fail('"remap" must be a list of {from, to} mappings')
return raw.map((entry, i) => {
if (typeof entry !== 'object' || entry === null) {
fail(`remap[${i}] must be a mapping with "from" and "to"`)
}
if (typeof entry.from !== 'string' || !entry.from.trim()) {
fail(`remap[${i}].from must be a non-empty string`)
}
if (typeof entry.to !== 'string') {
fail(`remap[${i}].to must be a string (use "" to strip the prefix entirely)`)
}
const from = validatePattern(entry.from.trim(), `remap[${i}].from`)
const to = entry.to.trim()
// `to` may be empty (strip), but if present it must be safe
if (to) validatePattern(to, `remap[${i}].to`)
return { from, to }
})
}
/** Parse config text. Separated from disk access so tests need no fixtures on disk. */
export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
let raw
Expand All @@ -128,6 +153,10 @@ export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
fail(`unknown key(s) in ${source}: ${unknown.join(', ')} (prefix a key with "_" for notes)`)
}

if (raw.stripPrefix !== undefined && raw.remap !== undefined) {
fail('"stripPrefix" and "remap" cannot both be set. Use "remap" only — stripPrefix is sugar for a single {from, to: ""} mapping.')
}

if (typeof raw.task !== 'string' || !raw.task.trim()) {
fail('"task" is required: one line saying what this pack is for. The worker reads it first.')
}
Expand All @@ -136,6 +165,7 @@ export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
task: raw.task.trim(),
out: typeof raw.out === 'string' && raw.out.trim() ? raw.out.trim() : 'sparepack-out',
stripPrefix: parseStripPrefix(raw.stripPrefix),
remap: parseRemap(raw.remap),
include: asArray(raw.include, 'include').map((p) => validatePattern(p, 'include')),
interfaces: asArray(raw.interfaces, 'interfaces').map((p) => validatePattern(p, 'interfaces')),
tests: asArray(raw.tests, 'tests').map((p) => validatePattern(p, 'tests')),
Expand All @@ -150,6 +180,11 @@ export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
}),
}

// Backward compatibility: convert stripPrefix to remap internally if remap is empty
if (config.stripPrefix && config.remap.length === 0) {
config.remap = [{ from: config.stripPrefix, to: '' }]
}

validatePattern(config.out, 'out')

const total = FILE_KEYS.reduce((n, key) => n + config[key].length, 0)
Expand Down
57 changes: 36 additions & 21 deletions src/pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join, normalize, relative, resolve } from 'node:path'

import { ConfigError, expand } from './config.mjs'
import { assertInsideRoot } from './config.mjs'
import { generateFixture } from './fixtures.mjs'
import { stripFile, UnsupportedLanguageError } from './interfaces.mjs'
import { countBySeverity, hasBlockingFindings, scanText, SEVERITY_ORDER } from './scan.mjs'
Expand All @@ -17,51 +18,64 @@ export const STRIPPED = 'stripped'
export const FIXTURE = 'fixture'

/**
* Remap file destination paths by stripping the configured prefix.
* Remap file destination paths using ordered {from, to} mappings.
* First match wins. Validates traversal on both configured values and results.
* Reports collisions with both source paths.
*/
function applyStripPrefix(files, prefix) {
if (!prefix) return files
function applyRemap(files, remapRules, root) {
if (!remapRules || remapRules.length === 0) return files

// Normalize prefix to forward slashes without leading/trailing slashes for uniform matching
const cleanPrefix = prefix.replace(/^[\\/]+|[\\/]+$/g, '')
if (!cleanPrefix) return files

let matchedAny = false
const destMap = new Map()
let matchedAny = false

for (const file of files) {
const origPath = file.path
const normalized = origPath.replace(/\\/g, '/')
let destPath = origPath
let destPath = null

for (const rule of remapRules) {
const cleanFrom = rule.from.replace(/^[\\/]+|[\\/]+$/g, '')
if (!cleanFrom) continue

if (normalized === cleanFrom || normalized.startsWith(cleanFrom + '/')) {
matchedAny = true
const remainder = normalized === cleanFrom ? '' : normalized.slice(cleanFrom.length + 1)
const cleanTo = rule.to.replace(/^[\\/]+|[\\/]+$/g, '')
destPath = cleanTo ? (remainder ? `${cleanTo}/${remainder}` : cleanTo) : remainder
break // first match wins
}
}

if (normalized === cleanPrefix || normalized.startsWith(cleanPrefix + '/')) {
matchedAny = true
destPath = normalized === cleanPrefix ? '' : normalized.slice(cleanPrefix.length + 1)
if (destPath !== null) {
if (destPath === '') {
throw new ConfigError(
`stripping prefix "${prefix}" from "${origPath}" produces an empty destination path`,
`remapping "${origPath}" produces an empty destination path`,
)
}
// Traversal check on result
if (destPath.startsWith('/') || destPath.split('/').includes('..')) {
throw new ConfigError(
`stripping prefix "${prefix}" from "${origPath}" produces an invalid path "${destPath}" escaping pack root`,
`remapping "${origPath}" produces invalid path "${destPath}" escaping pack root`,
)
}
// Verify result stays inside root
assertInsideRoot(root, destPath, `remap result for "${origPath}"`)
}

if (destMap.has(destPath)) {
const finalPath = destPath !== null ? destPath : origPath
if (destMap.has(finalPath)) {
const prior = destMap.get(destPath)
throw new ConfigError(
`destination path collision after stripPrefix: "${prior}" and "${origPath}" both map to "${destPath}"`,
`destination path collision after remap: "${prior}" and "${origPath}" both map to "${finalPath}"`,
)
}
destMap.set(destPath, origPath)
file.path = destPath
destMap.set(finalPath, origPath)
file.path = finalPath
}

if (!matchedAny) {
throw new ConfigError(
`"stripPrefix" pattern "${prefix}" matched no files. A prefix that matches nothing is an error.`,
`"remap" pattern "${remapRules[0]?.from ?? 'remap'}" matched no files. A remap rule that matches nothing is an error.`,
)
}

Expand Down Expand Up @@ -194,8 +208,8 @@ export async function buildPack(root, config) {
}

// Remap destination paths inside the pack if stripPrefix is set.
if (config.stripPrefix) {
applyStripPrefix(files, config.stripPrefix)
if (config.remap && config.remap.length > 0) {
applyRemap(files, config.remap, root)
}

const { active, suppressed } = partitionFindings(findings, config.allowFindings)
Expand Down Expand Up @@ -396,3 +410,4 @@ export async function writePack(outDir, manifest, files) {
}

export { hasBlockingFindings }
export { applyRemap }
64 changes: 64 additions & 0 deletions test/config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,67 @@ test('out defaults to sparepack-out and must stay inside the repo', () => {
assert.equal(parseConfig(`${base}out: dist/pack\n`).out, 'dist/pack')
bad(`${base}out: /tmp/anywhere\n`, /must be relative/)
})

// --- remap ----------------------------------------------------------------

test('remap: multiple mappings with first-match-wins and second rule hit', () => {
const config = parseConfig(`${base}remap:\n - from: src/a\n to: lib/x\n - from: src/b\n to: lib/y\n`)
assert.equal(config.remap.length, 2)
assert.equal(config.remap[0].from, 'src/a')
assert.equal(config.remap[1].from, 'src/b')
})

test('remap: collision error includes both source paths', async (t) => {
const pack = await import('../src/pack.mjs')
const { applyRemap } = pack
const files = [
{ path: 'src/a/file.ts' },
{ path: 'src/b/file.ts' },
]
const rules = [
{ from: 'src/a', to: 'out' },
{ from: 'src/b', to: 'out' },
]
assert.throws(
() => applyRemap(files, rules, '/tmp'),
(err) => {
assert.ok(err instanceof Error, `expected Error, got ${err.constructor.name}`)
assert.match(err.message, /after remap/)
assert.match(err.message, /src\/a\/file\.ts/)
assert.match(err.message, /src\/b\/file\.ts/)
return true
},
)
})

test('remap: traversal in from is rejected at parse time', () => {
bad(`${base}remap:\n - from: ../escape\n to: safe\n`, /must not contain "\.\."/)
})

test('remap: traversal in to is rejected at parse time', () => {
bad(`${base}remap:\n - from: src\n to: ../escape\n`, /must not contain "\.\."/)
})

test('remap: absolute path in from is rejected', () => {
bad(`${base}remap:\n - from: /absolute/path\n to: out\n`, /must be relative/)
})

test('remap: no-match error references remap not stripPrefix', async (t) => {
const pack = await import('../src/pack.mjs')
const { applyRemap } = pack
const files = [{ path: 'unrelated/file.ts' }]
const rules = [{ from: 'src/nope', to: 'out' }]
assert.throws(
() => applyRemap(files, rules, '/tmp'),
(err) => {
assert.ok(err instanceof Error, `expected Error, got ${err.constructor.name}`)
assert.match(err.message, /"remap" pattern/)
assert.doesNotMatch(err.message, /"stripPrefix" pattern/)
return true
},
)
})

test('stripPrefix and remap mutual exclusion', () => {
bad(`${base}stripPrefix: packages/api/\nremap:\n - from: src\n to: lib\n`, /cannot both be set/)
})
4 changes: 2 additions & 2 deletions test/e2e.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ out: pack

const result = await cli(root, ['pack', '--yes'])
assert.equal(result.code, 2)
assert.match(result.stderr, /"stripPrefix" pattern ".*" matched no files/)
assert.match(result.stderr, /"remap" pattern ".*" matched no files/)
})

test('stripPrefix collision is an error naming both paths', async (t) => {
Expand Down Expand Up @@ -434,7 +434,7 @@ out: pack

const result = await cli(root, ['pack', '--yes'])
assert.equal(result.code, 2)
assert.match(result.stderr, /destination path collision after stripPrefix/)
assert.match(result.stderr, /destination path collision after remap/)
assert.match(result.stderr, /foo\.ts/)
assert.match(result.stderr, /src\/billing\/foo\.ts/)
})