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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const ConfigKeyEntrySchema = z.object({
destination: z.string().optional(),
anchor: z.string().optional(),
groupBy: z.string().optional(),
preserveFilePaths: z.boolean().default(false),
})

const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, StaticEntrySchema, ConfigKeyEntrySchema])
Expand Down Expand Up @@ -130,6 +131,10 @@ export async function executeIncludeAssetsStep(
// true conflicts (different sources, same basename) while allowing overwrites
// from previous builds.
const usedBasenames = new Set<string>()
// Output-relative paths of every file written by configKey entries in this
// build. Used by `preserveFilePaths` entries to detect cross-entry collisions
// (e.g. two different directory sources producing the same file).
const usedFiles = new Set<string>()

// configKey entries run sequentially to avoid filesystem race conditions
// when multiple entries target the same output directory.
Expand All @@ -147,6 +152,8 @@ export async function executeIncludeAssetsStep(
context,
destination: sanitizedDest,
usedBasenames,
usedFiles,
preserveFilePaths: entry.preserveFilePaths,
})
result.pathMap.forEach((val, key) => aggregatedPathMap.set(key, val))
configKeyCount += result.filesCopied
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,93 @@ describe('copyConfigKeyEntry', () => {
})
})

describe('preserveFilePaths', () => {
test('throws when two directory sources produce the same output-relative file', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const dirA = joinPath(tmpDir, 'a-assets')
const dirB = joinPath(tmpDir, 'b-assets')
await mkdir(dirA)
await mkdir(dirB)
await writeFile(joinPath(dirA, 'logo.png'), 'a')
await writeFile(joinPath(dirB, 'logo.png'), 'b')

const outDir = joinPath(tmpDir, 'out')
await mkdir(outDir)

const usedFiles = new Set<string>()
const contextA = makeContext({dir: 'a-assets'}, mockStdout)
await copyConfigKeyEntry({
key: 'dir',
baseDir: tmpDir,
outputDir: outDir,
context: contextA,
usedFiles,
preserveFilePaths: true,
})

const contextB = makeContext({dir: 'b-assets'}, mockStdout)
await expect(
copyConfigKeyEntry({
key: 'dir',
baseDir: tmpDir,
outputDir: outDir,
context: contextB,
usedFiles,
preserveFilePaths: true,
}),
).rejects.toThrow(/File collision with preserveFilePaths enabled: 'logo\.png'/)
})
})

test('throws when two single-file sources share a basename (no renaming)', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const dirA = joinPath(tmpDir, 'a')
const dirB = joinPath(tmpDir, 'b')
await mkdir(dirA)
await mkdir(dirB)
await writeFile(joinPath(dirA, 'schema.json'), '{"a": true}')
await writeFile(joinPath(dirB, 'schema.json'), '{"b": true}')

const outDir = joinPath(tmpDir, 'out')
await mkdir(outDir)

const context = makeContext({files: ['a/schema.json', 'b/schema.json']}, mockStdout)
await expect(
copyConfigKeyEntry({
key: 'files',
baseDir: tmpDir,
outputDir: outDir,
context,
preserveFilePaths: true,
}),
).rejects.toThrow(/File collision with preserveFilePaths enabled: 'schema\.json'/)
})
})

test('does not throw when the same directory source is referenced twice (deduped within one call)', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const srcDir = joinPath(tmpDir, 'assets')
await mkdir(srcDir)
await writeFile(joinPath(srcDir, 'foo.json'), '{}')

const outDir = joinPath(tmpDir, 'out')
await mkdir(outDir)

const context = makeContext({extensions: [{targeting: [{assets: 'assets'}, {assets: 'assets'}]}]}, mockStdout)
const result = await copyConfigKeyEntry({
key: 'extensions[].targeting[].assets',
baseDir: tmpDir,
outputDir: outDir,
context,
preserveFilePaths: true,
})

expect(result.filesCopied).toBe(1)
await expect(fileExists(joinPath(outDir, 'foo.json'))).resolves.toBe(true)
})
})
})

test('deduplicates repeated source paths — copies each unique path only once', async () => {
await inTemporaryDirectory(async (tmpDir) => {
await writeFile(joinPath(tmpDir, 'tools.json'), '{}')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,19 @@ export async function copyConfigKeyEntry(config: {
context: BuildContext
destination?: string
usedBasenames?: Set<string>
usedFiles?: Set<string>
preserveFilePaths?: boolean
}): Promise<{filesCopied: number; pathMap: Map<string, string | string[]>}> {
const {key, baseDir, outputDir, context, destination, usedBasenames = new Set()} = config
const {
key,
baseDir,
outputDir,
context,
destination,
usedBasenames = new Set<string>(),
usedFiles = new Set<string>(),
preserveFilePaths = false,
} = config
const {stdout} = context.options
const value = getNestedValue(context.extension.configuration, key)
let paths: string[]
Expand Down Expand Up @@ -75,19 +86,35 @@ export async function copyConfigKeyEntry(config: {
// that may no longer exist in the source, inflating the file count and producing
// stale entries in the manifest's pathMap.
const sourceFiles = await glob(['**/*'], {cwd: fullPath, absolute: false})
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
if (preserveFilePaths) {
for (const relFile of relFiles) assertNoCollision(relFile, sourcePath, usedFiles)
}
await copyDirectoryContents(fullPath, destDir)
stdout.write(`Included '${sourcePath}'\n`)
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
for (const relFile of relFiles) usedFiles.add(relFile)
pathMap.set(sourcePath, relFiles)
filesCopied += sourceFiles.length
} else {
await mkdir(destDir)
const filename = basename(fullPath)
const destFilename = uniqueBasename(filename, usedBasenames)
let destFilename: string
let outputRelative: string
if (preserveFilePaths) {
// Strict mode checks output-relative paths so single-file sources can
// detect collisions with files from any prior entry (including directory sources).
outputRelative = relativePath(outputDir, joinPath(destDir, filename))
assertNoCollision(outputRelative, sourcePath, usedFiles)
destFilename = filename
} else {
// Default mode: flat basename uniqueness across all destinations.
destFilename = uniqueBasename(filename, usedBasenames)
outputRelative = relativePath(outputDir, joinPath(destDir, destFilename))
}
usedBasenames.add(destFilename)
const destPath = joinPath(destDir, destFilename)
await copyFile(fullPath, destPath)
const outputRelative = relativePath(outputDir, destPath)
usedFiles.add(outputRelative)
stdout.write(`Included '${sourcePath}'\n`)
pathMap.set(sourcePath, outputRelative)
filesCopied += 1
Expand All @@ -98,6 +125,18 @@ export async function copyConfigKeyEntry(config: {
return {filesCopied, pathMap}
}

/**
* Throws if `path` is already present in `used`. Shared between directory and
* single-file branches so the error message and check shape stay in one place.
*/
function assertNoCollision(path: string, sourcePath: string, used: Set<string>): void {
if (used.has(path)) {
throw new Error(
`File collision with preserveFilePaths enabled: '${path}' from '${sourcePath}' would overwrite a file copied from a different source. Rename or relocate the conflicting file.`,
)
}
}

/**
* Returns a unique filename given the set of basenames already used in this
* build. If `filename` hasn't been used, returns it as-is. Otherwise appends
Expand Down
Loading