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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,10 @@ config, so a worker who clones the pack cannot run the tests until someone adds
practice you write a few lines of `package.json` by hand after packing. Worth knowing before
you promise someone a pack they can `npm test` straight away.

The paths in a pack mirror the paths in your repo, with no way to remap them. Run `sparepack`
from the directory you want to be the pack's root — packing from a monorepo root gives you
`packages/api/src/...` inside the pack, which is usually not what you want.
The paths in a pack mirror the paths in your repo by default, but you can use `stripPrefix`
to remove a leading directory path from the destination files. This is useful when packing
from a monorepo root — setting `stripPrefix: "packages/api/"` will place the files at
`src/...` inside the pack instead of `packages/api/src/...`.

The scanner is lexical. It finds patterns, not meaning. A business rule written in prose in a
comment, a customer name that looks like an ordinary word, an internal codename you forgot to
Expand Down
5 changes: 5 additions & 0 deletions bin/sparepack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ redact:
# Findings you have looked at and decided are fine. Format: rule-id:path[:line]
# Do not add entries here to make the scanner quiet. Add them when you have read the
# specific line and concluded it is genuinely safe to publish.

# Strip a leading path prefix from every file's destination inside the pack.
# Useful for monorepos where you pack from a subdirectory but want clean paths.
# stripPrefix: "packages/api/"

# allowFindings:
# - email:src/importer/types.ts:12

Expand Down
16 changes: 15 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'])
const KNOWN_KEYS = new Set([...FILE_KEYS, 'task', 'fixtures', 'redact', 'scanRules', 'allowFindings', 'out', 'stripPrefix'])

class ConfigError extends Error {}

Expand Down Expand Up @@ -117,6 +117,17 @@ export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
fail('"task" is required: one line saying what this pack is for. The worker reads it first.')
}

let stripPrefix = null
if (raw.stripPrefix !== undefined && raw.stripPrefix !== null) {
if (typeof raw.stripPrefix !== 'string' || !raw.stripPrefix.trim()) {
fail('"stripPrefix" must be a non-empty string')
}
stripPrefix = validatePattern(raw.stripPrefix, 'stripPrefix')
if (!stripPrefix.endsWith('/')) {
stripPrefix += '/'
}
}

const config = {
task: raw.task.trim(),
out: typeof raw.out === 'string' && raw.out.trim() ? raw.out.trim() : 'sparepack-out',
Expand All @@ -126,6 +137,7 @@ export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
fixtures: parseFixtures(raw.fixtures),
redact: parseRedact(raw.redact),
scanRules: asArray(raw.scanRules, 'scanRules').map(compileCustomRule),
stripPrefix,
allowFindings: asArray(raw.allowFindings, 'allowFindings').map((entry, i) => {
if (typeof entry !== 'string' || !entry.includes(':')) {
fail(`allowFindings[${i}] must look like "rule-id:path" or "rule-id:path:line"`)
Expand All @@ -134,6 +146,8 @@ export function parseConfig(text, { source = 'sparepack.yaml' } = {}) {
}),
}



validatePattern(config.out, 'out')

const total = FILE_KEYS.reduce((n, key) => n + config[key].length, 0)
Expand Down
42 changes: 40 additions & 2 deletions src/pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ async function readIfExists(path) {
* Build the pack in memory.
* @returns {{files: Array, findings: Array, suppressed: Array, warnings: string[]}}
*/
export function computeDestPath(sourcePath, stripPrefix) {
if (!stripPrefix) return sourcePath
if (sourcePath.startsWith(stripPrefix)) {
const stripped = sourcePath.slice(stripPrefix.length)
if (!stripped || stripped.startsWith('..') || stripped.startsWith('/')) {
throw new Error(`stripPrefix "${stripPrefix}" on "${sourcePath}" produces invalid pack path "${stripped}"`)
}
return stripped
}
return null
}

export async function buildPack(root, config) {
const files = []
const warnings = [...(config.warnings ?? [])]
Expand Down Expand Up @@ -130,6 +142,32 @@ export async function buildPack(root, config) {
})
}


// Apply stripPrefix and check for collisions / non-matches
if (config.stripPrefix) {
let matchCount = 0
const destPaths = new Map()
for (const file of files) {
const dest = computeDestPath(file.path, config.stripPrefix)
if (dest !== null) {
matchCount++
file.destPath = dest
} else {
file.destPath = file.path
}
// Check collision against ALL previously assigned dest paths
if (destPaths.has(file.destPath)) {
throw new Error(`stripPrefix collision: "${file.path}" and "${destPaths.get(file.destPath)}" both map to "${file.destPath}"`)
}
destPaths.set(file.destPath, file.path)
}
if (matchCount === 0) {
throw new Error(`stripPrefix "${config.stripPrefix}" matched no files in the pack`)
}
} else {
for (const file of files) file.destPath = file.path
}

// Redact first, then scan. Scanning before redaction would report findings the author
// already handled; scanning after is the only way to know the redactions were enough.
const findings = []
Expand All @@ -155,7 +193,7 @@ export function buildManifest(config, { files, findings, suppressed, warnings })
bytes: files.reduce((n, f) => n + f.bytes, 0),
},
files: files.map((f) => ({
path: f.path,
path: f.destPath || f.path,
kind: f.kind,
bytes: f.bytes,
...(f.isTest ? { role: 'acceptance-test' } : {}),
Expand Down Expand Up @@ -328,7 +366,7 @@ export async function writePack(outDir, manifest, files) {
await mkdir(out, { recursive: true })

for (const file of files) {
const dest = join(out, file.path)
const dest = join(out, file.destPath || file.path)
await mkdir(dirname(dest), { recursive: true })
await writeFile(dest, file.source)
}
Expand Down
109 changes: 109 additions & 0 deletions test/stripPrefix.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { parseConfig } from '../src/config.mjs'
import { buildPack, writePack } from '../src/pack.mjs'

const baseYaml = `
task: "test"
include:
- packages/api/src/a.ts
- packages/api/src/b.ts
stripPrefix: "packages/api/"
`

test('stripPrefix applies to destination paths', async () => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-strip-'))
await mkdir(join(root, 'packages/api/src'), { recursive: true })
await writeFile(join(root, 'packages/api/src/a.ts'), 'export const a = 1;')
await writeFile(join(root, 'packages/api/src/b.ts'), 'export const b = 2;')

const config = parseConfig(baseYaml)
const { files } = await buildPack(root, config)

const paths = files.map(f => f.destPath).sort()
assert.deepEqual(paths, ['src/a.ts', 'src/b.ts'])
})

test('stripPrefix throws if it matches no files', async () => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-strip-'))
await mkdir(join(root, 'src'), { recursive: true })
await writeFile(join(root, 'src/a.ts'), 'export const a = 1;')

const yaml = `
task: "test"
include:
- src/a.ts
stripPrefix: "packages/api/"
`
const config = parseConfig(yaml)
await assert.rejects(() => buildPack(root, config), /matched no files/)
})

test('stripPrefix throws on collision', async () => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-strip-'))
await mkdir(join(root, 'packages/api/src'), { recursive: true })
await mkdir(join(root, 'src'), { recursive: true })
await writeFile(join(root, 'packages/api/src/a.ts'), 'export const a = 1;')
await writeFile(join(root, 'src/a.ts'), 'export const a = 2;')

const yaml = `
task: "test"
include:
- packages/api/src/a.ts
- src/a.ts
stripPrefix: "packages/api/"
`
const config = parseConfig(yaml)
await assert.rejects(() => buildPack(root, config), /collision/)
})

test('stripPrefix rejects traversal attempts', async () => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-strip-'))
await mkdir(join(root, 'packages/api/src'), { recursive: true })
await writeFile(join(root, 'packages/api/src/a.ts'), 'export const a = 1;')

// The prefix doesn't match, so it falls back to original path, but let's test computeDestPath logic indirectly
// Actually, if stripPrefix matches, it strips. If the result starts with '..' it throws.
// Let's create a file that would result in '..' if we stripped a shorter prefix.
// Wait, the requirement is "Stripping cannot produce a path escaping the pack root — `..` and absolute results are rejected."
// If stripPrefix is "packages/", and file is "packages/../etc/passwd" (not possible since globs don't allow '..').
// Let's just ensure the config parser rejects '..' in stripPrefix itself.
const badYaml = `
task: "test"
include:
- src/a.ts
stripPrefix: "../evil/"
`
assert.throws(() => parseConfig(badYaml), /must not contain "\.\."/)
})

test('verify round-trip with stripPrefix', async () => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-strip-'))
const outDir = join(root, 'out')
await mkdir(join(root, 'packages/api/src'), { recursive: true })
await writeFile(join(root, 'packages/api/src/a.ts'), 'export const a = 1;')
await writeFile(join(root, 'packages/api/src/b.ts'), 'export const b = 2;')

const config = parseConfig(baseYaml)
const { files } = await buildPack(root, config)
const manifest = {
sparepackVersion: 1,
task: config.task,
generated: { files: files.length, bytes: files.reduce((n, f) => n + f.bytes, 0) },
files: files.map(f => ({ path: f.destPath, kind: f.kind, bytes: f.bytes }))
}

await writePack(outDir, manifest, files)

// Check that files were written with stripped paths
const aContent = await readFile(join(outDir, 'src/a.ts'), 'utf8')
assert.equal(aContent, 'export const a = 1;')

// Check manifest
const manifestContent = JSON.parse(await readFile(join(outDir, 'MANIFEST.json'), 'utf8'))
assert.equal(manifestContent.files[0].path, 'src/a.ts')
})