Skip to content
Draft
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
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,21 @@ redact: # names the scanner cannot know about
- pattern: "acme-corp|ACME"
replace: "example-org"

stripPrefix: packages/api/ # strip this from every path inside the pack
remap: # rewrite destination paths inside the pack
- from: src/importer/
to: src/
- from: tests/importer/
to: tests/
```

**Destination path remapping (`stripPrefix`).** Packing from a monorepo root otherwise gives
you `packages/api/src/...` inside the pack. Setting `stripPrefix` strips that leading directory
prefix from destination paths inside the pack so the receiver gets clean paths like `src/...`.
**Destination path remapping (`remap`).** Mappings are evaluated in order and the first match
wins for each selected file. Every configured `from` must match at least one file; a stale or
shadowed rule is a hard error. Results must remain relative to the pack root, and two source
paths may not collide after remapping. `MANIFEST.json` records the post-remap paths.

For the common single-prefix case, `stripPrefix: packages/api/` remains backward-compatible
sugar for `remap: [{ from: packages/api/, to: "" }]`. Do not set `stripPrefix` and `remap`
together.

**Fixture generators.** `shape[:n]` reads the real JSON and rebuilds it with the same keys and
nesting but fake values, capping arrays at `n` elements. `rows:n` keeps a delimited file's
Expand Down
12 changes: 10 additions & 2 deletions bin/sparepack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,16 @@ redact:
# pattern: "\\\\b(billing|ledger)-internal\\\\b"
# severity: high

# Strip a leading path prefix from destination paths inside the pack.
# Useful when running from a monorepo root to avoid paths like "packages/api/src/...".
# Remap destination paths with ordered mappings. The first matching rule wins, and every
# "from" must match at least one file. Remapped paths are also written to MANIFEST.json.
# remap:
# - from: packages/api/
# to: ""
# - from: packages/web/
# to: apps/web/
#
# For one prefix, stripPrefix is backward-compatible sugar for one {from, to: ""} rule.
# Do not set stripPrefix and remap together.
# stripPrefix: packages/api/

# Findings you have looked at and decided are fine. Format: rule-id:path[:line]
Expand Down
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
94 changes: 59 additions & 35 deletions src/pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
// author rejected still exists in a directory they might later publish by accident.

import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join, normalize, relative, resolve } from 'node:path'
import { dirname, join, posix, 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,52 +18,74 @@ 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()
const matchedRules = new Set()

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

if (normalized === cleanPrefix || normalized.startsWith(cleanPrefix + '/')) {
matchedAny = true
destPath = normalized === cleanPrefix ? '' : normalized.slice(cleanPrefix.length + 1)
if (destPath === '') {
throw new ConfigError(
`stripping prefix "${prefix}" from "${origPath}" produces an empty destination path`,
)
}
if (destPath.startsWith('/') || destPath.split('/').includes('..')) {
throw new ConfigError(
`stripping prefix "${prefix}" from "${origPath}" produces an invalid path "${destPath}" escaping pack root`,
)
let destPath = null

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

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

if (destMap.has(destPath)) {
const prior = destMap.get(destPath)
const rawFinalPath = destPath !== null ? destPath : normalized
if (rawFinalPath === '') {
throw new ConfigError(
`destination path collision after stripPrefix: "${prior}" and "${origPath}" both map to "${destPath}"`,
`remapping "${origPath}" produces an empty destination path`,
)
}
destMap.set(destPath, origPath)
file.path = destPath
// Reject traversal before normalizing: collapsing an attempted escape would
// hide the evidence. Then canonicalize aliases before collision detection
// and writing so ./a.ts, a//b.ts, and a/./b.ts cannot name the same file.
if (rawFinalPath.startsWith('/') || rawFinalPath.split('/').includes('..')) {
throw new ConfigError(
`remapping "${origPath}" produces invalid path "${rawFinalPath}" escaping pack root`,
)
}
const finalPath = posix.normalize(rawFinalPath)
if (finalPath === '.' || posix.isAbsolute(finalPath)) {
throw new ConfigError(
`remapping "${origPath}" produces invalid path "${rawFinalPath}" escaping pack root`,
)
}
assertInsideRoot(root, finalPath, `remap result for "${origPath}"`)

if (destMap.has(finalPath)) {
const prior = destMap.get(finalPath)
throw new ConfigError(
`destination path collision after remap: "${prior}" and "${origPath}" both map to "${finalPath}"`,
)
}
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.`,
)
for (const [ruleIndex, rule] of remapRules.entries()) {
if (!matchedRules.has(ruleIndex)) {
throw new ConfigError(
`"remap" pattern "${rule.from}" matched no files. A remap rule that matches nothing is an error.`,
)
}
}

return files
Expand Down Expand Up @@ -193,9 +216,9 @@ export async function buildPack(root, config) {
findings.push(...scanText(text, { path: file.path, customRules: config.scanRules }))
}

// Remap destination paths inside the pack if stripPrefix is set.
if (config.stripPrefix) {
applyStripPrefix(files, config.stripPrefix)
// Remap destination paths inside the pack after content processing.
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 +419,4 @@ export async function writePack(outDir, manifest, files) {
}

export { hasBlockingFindings }
export { applyRemap }
Loading