chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
@@ -0,0 +1,328 @@
import { expect, test, describe, beforeAll, afterAll, beforeEach } from 'vitest'
import { writeFile, mkdir, rm } from 'fs/promises'
import { join, basename } from 'path'
import { getAllFilesInPath } from '../../../src/template/utils'
describe('getAllFilesInPath', () => {
const testDir = join(__dirname, 'folder')
beforeAll(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
afterAll(async () => {
await rm(testDir, { recursive: true, force: true })
})
beforeEach(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
test('should return files matching a simple pattern', async () => {
// Create test files
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
await writeFile(join(testDir, 'file3.js'), 'content3')
const files = await getAllFilesInPath('*.txt', testDir, [])
expect(files).toHaveLength(2)
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file3.js'))).toBe(false)
})
test('should handle directory patterns recursively', async () => {
// Create nested directory structure
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(join(testDir, 'README.md'), 'readme content')
const files = await getAllFilesInPath('src', testDir, [])
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('README.md'))).toBe(false)
})
test('should respect ignore patterns', async () => {
// Create test files
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
await writeFile(join(testDir, 'temp.txt'), 'temp content')
await writeFile(join(testDir, 'backup.txt'), 'backup content')
const files = await getAllFilesInPath('*.txt', testDir, [
'temp*',
'backup*',
])
expect(files).toHaveLength(2)
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('temp.txt'))).toBe(false)
expect(files.some((f) => f.fullpath().endsWith('backup.txt'))).toBe(false)
})
test('should handle complex ignore patterns', async () => {
// Create nested structure with various file types
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await mkdir(join(testDir, 'tests'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(join(testDir, 'tests', 'test.spec.ts'), 'test content')
await writeFile(
join(testDir, 'src', 'components', 'Button.test.tsx'),
'test content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.spec.ts'),
'spec content'
)
const files = await getAllFilesInPath('src', testDir, [
'**/*.test.*',
'**/*.spec.*',
])
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.test.tsx'))).toBe(
false
)
expect(files.some((f) => f.fullpath().endsWith('helper.spec.ts'))).toBe(
false
)
})
test('should handle empty directories', async () => {
await mkdir(join(testDir, 'empty'), { recursive: true })
await writeFile(join(testDir, 'file.txt'), 'content')
const files = await getAllFilesInPath('empty', testDir, [])
expect(files).toHaveLength(1) // The empty directory itself
})
test('should handle mixed files and directories', async () => {
// Create a mix of files and directories
await writeFile(join(testDir, 'file1.txt'), 'content1')
await mkdir(join(testDir, 'dir1'), { recursive: true })
await writeFile(join(testDir, 'dir1', 'file2.txt'), 'content2')
await writeFile(join(testDir, 'file3.txt'), 'content3')
const files = await getAllFilesInPath('*', testDir, [])
expect(files).toHaveLength(4) // 3 files + 1 directory
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file3.txt'))).toBe(true)
})
test('should handle glob patterns with subdirectories', async () => {
// Create nested structure
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(
join(testDir, 'src', 'components', 'Button.css'),
'css content'
)
const files = await getAllFilesInPath('src/**/*', testDir, [])
expect(files).toHaveLength(6) // 4 files + 2 directories (components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.css'))).toBe(true)
})
test('should handle specific file extensions', async () => {
await writeFile(join(testDir, 'file1.ts'), 'ts content')
await writeFile(join(testDir, 'file2.js'), 'js content')
await writeFile(join(testDir, 'file3.tsx'), 'tsx content')
await writeFile(join(testDir, 'file4.css'), 'css content')
const files = await getAllFilesInPath('*.ts', testDir, [])
expect(files).toHaveLength(1)
expect(files.some((f) => f.fullpath().endsWith('file1.ts'))).toBe(true)
})
test('should return sorted files', async () => {
await writeFile(join(testDir, 'zebra.txt'), 'z content')
await writeFile(join(testDir, 'apple.txt'), 'a content')
await writeFile(join(testDir, 'banana.txt'), 'b content')
const files = await getAllFilesInPath('*.txt', testDir, [])
expect(files).toHaveLength(3)
// Files must be returned sorted by full path so the files hash is
// independent of filesystem traversal order
const fileNames = files.map((f) => basename(f.fullpath()))
expect(fileNames).toEqual(['apple.txt', 'banana.txt', 'zebra.txt'])
})
test('should return nested files sorted by full path', async () => {
await mkdir(join(testDir, 'b'), { recursive: true })
await mkdir(join(testDir, 'a'), { recursive: true })
await writeFile(join(testDir, 'zebra.txt'), 'z content')
await writeFile(join(testDir, 'b', 'file.txt'), 'b content')
await writeFile(join(testDir, 'a', 'file.txt'), 'a content')
const files = await getAllFilesInPath('*', testDir, [])
const paths = files.map((f) => f.fullpath())
expect(paths).toEqual([...paths].sort())
})
test('should handle no matching files', async () => {
await writeFile(join(testDir, 'file.txt'), 'content')
const files = await getAllFilesInPath('*.js', testDir, [])
expect(files).toHaveLength(0)
})
test('should include dotfiles', async () => {
// Create regular and dotfiles
await writeFile(join(testDir, 'file.txt'), 'content')
await writeFile(join(testDir, '.env'), 'SECRET=123')
await writeFile(join(testDir, '.gitignore'), 'node_modules')
const files = await getAllFilesInPath('*', testDir, [])
expect(files).toHaveLength(3)
expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true)
})
test('should include dotfiles in subdirectories', async () => {
await mkdir(join(testDir, 'src'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'content')
await writeFile(join(testDir, 'src', '.env.local'), 'SECRET=123')
const files = await getAllFilesInPath('src', testDir, [])
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('.env.local'))).toBe(true)
})
test('should include dotdirectories and their contents', async () => {
await mkdir(join(testDir, '.hidden'), { recursive: true })
await writeFile(join(testDir, '.hidden', 'config.json'), '{}')
await writeFile(join(testDir, 'visible.txt'), 'content')
const files = await getAllFilesInPath('*', testDir, [])
expect(files.some((f) => f.fullpath().endsWith('.hidden'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('config.json'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('visible.txt'))).toBe(true)
})
test('should respect ignore patterns for dotfiles', async () => {
await writeFile(join(testDir, '.env'), 'SECRET=123')
await writeFile(join(testDir, '.gitignore'), 'node_modules')
await writeFile(join(testDir, 'file.txt'), 'content')
const files = await getAllFilesInPath('*', testDir, ['.env'])
expect(files).toHaveLength(2)
expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(false)
expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true)
})
test('should handle listing all files in current directory with dot pattern', async () => {
// Create a small directory tree inside the test directory
await writeFile(join(testDir, 'root.txt'), 'root')
await mkdir(join(testDir, 'subdir'), { recursive: true })
await writeFile(join(testDir, 'subdir', 'nested.txt'), 'nested')
const files = await getAllFilesInPath('.', testDir, [])
// We should get at least the testDir itself (.) plus its children
expect(files.length).toBeGreaterThanOrEqual(3)
// All returned paths must stay within the testDir - we must not traverse the whole filesystem
const allWithinTestDir = files.every((f) =>
f.fullpath().startsWith(testDir)
)
expect(allWithinTestDir).toBe(true)
})
test('should handle complex ignore patterns with directories', async () => {
// Create a complex structure
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await mkdir(join(testDir, 'src', 'tests'), { recursive: true })
await mkdir(join(testDir, 'dist'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(
join(testDir, 'src', 'tests', 'test.spec.ts'),
'test content'
)
await writeFile(join(testDir, 'dist', 'bundle.js'), 'bundle content')
await writeFile(join(testDir, 'README.md'), 'readme content')
const files = await getAllFilesInPath('src', testDir, [
'**/tests/**',
'**/*.spec.*',
])
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('test.spec.ts'))).toBe(false)
})
})
@@ -0,0 +1,28 @@
import { expect, test } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { getCallerDirectory } from '../../../src/template/utils'
test('getCallerDirectory', () => {
// getCallerDirectory(1) should return the directory of the current file
// __dirname is the directory of the current file.
// Normalize before comparing: on Windows the stack-trace file name reported
// by vite/vitest uses forward slashes (e.g. "D:/a/...") whereas __dirname
// uses native backslashes — the directory is the same, only the separators
// differ.
expect(path.normalize(getCallerDirectory(1)!)).toBe(path.normalize(__dirname))
})
test('getCallerDirectory handles file:// URLs from ESM modules', () => {
// In ESM modules, CallSite.getFileName() returns file:// URLs
// This test verifies that the conversion works correctly
const testDirectoryPath = path.join(os.tmpdir(), 'test', 'project', 'src')
const testFilePath = path.join(testDirectoryPath, 'template.ts')
const fileUrl = pathToFileURL(testFilePath)
// Verify that fileURLToPath correctly converts the URL
// This is the same conversion used in getCallerDirectory
expect(fileURLToPath(fileUrl)).toBe(testFilePath)
expect(path.dirname(fileURLToPath(fileUrl))).toBe(testDirectoryPath)
})
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'vitest'
import { normalizeBuildArguments } from '../../../src/template/utils'
import { TemplateError } from '../../../src/errors'
describe('normalizeBuildArguments', () => {
test('handles string name', () => {
const result = normalizeBuildArguments('my-template:v1.0')
expect(result.name).toBe('my-template:v1.0')
})
test('handles string name with options', () => {
const result = normalizeBuildArguments('my-template:v1.0', { cpuCount: 4 })
expect(result.name).toBe('my-template:v1.0')
expect(result.buildOptions).toEqual({ cpuCount: 4 })
})
test('handles legacy options with alias', () => {
const result = normalizeBuildArguments({ alias: 'my-template' })
expect(result.name).toBe('my-template')
})
test('removes alias from build options', () => {
const result = normalizeBuildArguments({
alias: 'my-template',
cpuCount: 4,
})
expect(result.name).toBe('my-template')
expect(result.buildOptions).toEqual({ cpuCount: 4 })
expect(result.buildOptions).not.toHaveProperty('alias')
})
test('throws for empty name', () => {
expect(() => normalizeBuildArguments({ alias: '' })).toThrow(TemplateError)
})
test('throws for missing name', () => {
expect(() => normalizeBuildArguments({} as any)).toThrow(TemplateError)
})
})
@@ -0,0 +1,49 @@
import { expect, test, describe } from 'vitest'
import {
waitForFile,
waitForPort,
waitForProcess,
waitForTimeout,
waitForURL,
} from '../../../src/template/readycmd'
describe('readycmd', () => {
test('waitForPort matches the exact listening port', () => {
const cmd = waitForPort(80).getCmd()
expect(cmd).toBe('[ -n "$(ss -Htuln sport = :80)" ]')
})
test('waitForURL quotes the url', () => {
const cmd = waitForURL('http://localhost:3000/health?ready=1&x=y').getCmd()
expect(cmd).toBe(
'curl -s -o /dev/null -w "%{http_code}" \'http://localhost:3000/health?ready=1&x=y\' | grep -q "200"'
)
})
test('waitForURL keeps simple urls unquoted', () => {
const cmd = waitForURL('http://localhost:3000/health').getCmd()
expect(cmd).toBe(
'curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health | grep -q "200"'
)
})
test('waitForProcess quotes the process name', () => {
const cmd = waitForProcess('my daemon').getCmd()
expect(cmd).toBe("pgrep 'my daemon' > /dev/null")
})
test('waitForFile quotes the filename', () => {
const cmd = waitForFile('/tmp/ready file').getCmd()
expect(cmd).toBe("[ -f '/tmp/ready file' ]")
})
test('waitForFile keeps simple paths unquoted', () => {
const cmd = waitForFile('/tmp/ready').getCmd()
expect(cmd).toBe('[ -f /tmp/ready ]')
})
test('waitForTimeout converts milliseconds to seconds', () => {
expect(waitForTimeout(5000).getCmd()).toBe('sleep 5')
expect(waitForTimeout(100).getCmd()).toBe('sleep 1')
})
})
@@ -0,0 +1,314 @@
import {
expect,
test,
describe,
beforeAll,
afterAll,
beforeEach,
vi,
} from 'vitest'
import { writeFile, mkdir, rm, symlink, readFile, access } from 'fs/promises'
import fs from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { tarFileStream } from '../../../src/template/utils'
import * as tar from 'tar'
import { ReadEntry } from 'tar'
describe('tarFileStream', () => {
const testDir = join(__dirname, 'tar-test-folder')
beforeAll(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
afterAll(async () => {
await rm(testDir, { recursive: true, force: true })
})
beforeEach(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
/**
* Wait until a path no longer exists. Cleanup runs asynchronously from the
* stream's `close` handler, so it may not complete in the same tick.
*/
async function waitUntilGone(target: string): Promise<void> {
for (let i = 0; i < 100; i++) {
try {
await access(target)
} catch {
return
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`path was not removed: ${target}`)
}
/**
* Pipe an archive stream into tar's extractor and collect its entries.
*/
async function extractTarStream(
stream: Readable
): Promise<{ extractDir: string; members: ReadEntry[] }> {
const extractDir = join(
tmpdir(),
`tar-extract-${Date.now()}-${Math.random()}`
)
await mkdir(extractDir, { recursive: true })
const members: ReadEntry[] = []
await pipeline(
stream,
tar.extract({
cwd: extractDir,
gzip: true,
onentry: (entry: ReadEntry) => {
members.push(entry)
},
})
)
return { extractDir, members }
}
/**
* Extract archive contents into a map of path -> file contents.
*/
async function extractTarContents(
stream: Readable
): Promise<Map<string, Buffer | null>> {
const { extractDir, members } = await extractTarStream(stream)
const contents = new Map<string, Buffer | null>()
for (const member of members) {
if (member.type === 'File') {
try {
contents.set(
member.path,
await readFile(join(extractDir, member.path))
)
} catch {
// File might not exist
}
} else if (member.type === 'Directory') {
contents.set(member.path, null)
} else if (member.type === 'SymbolicLink') {
contents.set(member.path, Buffer.from(member.linkpath || ''))
}
}
await rm(extractDir, { recursive: true, force: true })
return contents
}
/**
* Extract archive members into a map of path -> entry.
*/
async function getTarMembers(
stream: Readable
): Promise<Map<string, ReadEntry>> {
const { extractDir, members } = await extractTarStream(stream)
const map = new Map<string, ReadEntry>()
for (const member of members) {
map.set(member.path, member)
}
await rm(extractDir, { recursive: true, force: true })
return map
}
test('should create tar with simple files', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
const { stream, size } = await tarFileStream(
'*.txt',
testDir,
[],
false,
true
)
expect(size).toBeGreaterThan(0)
const contents = await extractTarContents(stream)
expect(contents.size).toBe(2)
expect(contents.get('file1.txt')?.toString()).toBe('content1')
expect(contents.get('file2.txt')?.toString()).toBe('content2')
})
test('should create an uncompressed tar when gzip is disabled', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
const { stream } = await tarFileStream('*.txt', testDir, [], false, false)
// Buffer the archive so we can both assert it is not gzipped and extract it
const chunks: Buffer[] = []
for await (const chunk of stream as unknown as AsyncIterable<Buffer>) {
chunks.push(Buffer.from(chunk))
}
const archive = Buffer.concat(chunks)
// gzip streams start with the magic bytes 0x1f 0x8b — an uncompressed tar must not
expect(archive[0]).not.toBe(0x1f)
expect(archive.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))).toBe(false)
// And it should still extract to the original files
const tarFile = join(tmpdir(), `tar-uncompressed-${Date.now()}.tar`)
await writeFile(tarFile, archive)
const extractDir = join(tmpdir(), `tar-uncompressed-extract-${Date.now()}`)
await mkdir(extractDir, { recursive: true })
await tar.extract({ file: tarFile, cwd: extractDir })
expect((await readFile(join(extractDir, 'file1.txt'))).toString()).toBe(
'content1'
)
expect((await readFile(join(extractDir, 'file2.txt'))).toString()).toBe(
'content2'
)
await rm(tarFile, { force: true })
await rm(extractDir, { recursive: true, force: true })
})
test('should respect ignore patterns', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
await writeFile(join(testDir, 'temp.txt'), 'temp content')
await writeFile(join(testDir, 'backup.txt'), 'backup content')
const { stream } = await tarFileStream(
'*.txt',
testDir,
['temp*', 'backup*'],
false,
true
)
const contents = await extractTarContents(stream)
expect(contents.size).toBe(2)
expect(contents.get('file1.txt')?.toString()).toBe('content1')
expect(contents.get('file2.txt')?.toString()).toBe('content2')
expect(contents.has('temp.txt')).toBe(false)
expect(contents.has('backup.txt')).toBe(false)
})
test('should handle nested files', async () => {
const nestedDir = join(testDir, 'src', 'components')
await mkdir(nestedDir, { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(join(nestedDir, 'Button.tsx'), 'button content')
const { stream } = await tarFileStream('src', testDir, [], false, true)
const contents = await extractTarContents(stream)
const paths = Array.from(contents.keys())
expect(paths.some((p) => p.includes('src'))).toBe(true)
expect(paths.some((p) => p.includes('index.ts'))).toBe(true)
expect(paths.some((p) => p.includes('Button.tsx'))).toBe(true)
})
test('should resolve symlinks when enabled', async () => {
await writeFile(join(testDir, 'original.txt'), 'original content')
try {
await symlink('original.txt', join(testDir, 'link.txt'))
} catch (error: any) {
// Skip test if symlinks are not supported on this platform
if (error.code === 'ENOSYS' || error.code === 'EPERM') {
return
}
throw error
}
// Test with resolveSymlinks=true
const { stream } = await tarFileStream('*.txt', testDir, [], true, true)
const contents = await extractTarContents(stream)
expect(contents.get('original.txt')?.toString()).toBe('original content')
// Symlink should be resolved (contain actual content, not link)
expect(contents.get('link.txt')?.toString()).toBe('original content')
})
test('should preserve symlinks when disabled', async () => {
await writeFile(join(testDir, 'original.txt'), 'original content')
try {
await symlink('original.txt', join(testDir, 'link.txt'))
} catch (error: any) {
// Skip test if symlinks are not supported on this platform
if (error.code === 'ENOSYS' || error.code === 'EPERM') {
return
}
throw error
}
// Test with resolveSymlinks=false
const { stream } = await tarFileStream('*.txt', testDir, [], false, true)
const members = await getTarMembers(stream)
expect(members.get('original.txt')?.type).toBe('File')
const link = members.get('link.txt')!
expect(link.type).toBe('SymbolicLink')
expect(link.linkpath).toBe('original.txt')
})
test('removes the spooled archive once the stream is consumed', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
// Capture the temp dir the helper creates so we can assert it is gone.
let tmpDir: string | undefined
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises)
const spy = vi
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async (prefix: any, ...rest: any[]) => {
const dir = await mkdtemp(prefix, ...rest)
tmpDir = dir
return dir
})
try {
const { stream } = await tarFileStream('*.txt', testDir, [], false)
expect(tmpDir).toBeDefined()
await access(tmpDir!)
// Drain the stream to completion; the `close` handler then removes the
// spooled archive.
stream.resume()
await waitUntilGone(tmpDir!)
} finally {
spy.mockRestore()
}
})
test('cleans up the spooled archive if it is never consumed', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
let tmpDir: string | undefined
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises)
const spy = vi
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async (prefix: any, ...rest: any[]) => {
const dir = await mkdtemp(prefix, ...rest)
tmpDir = dir
return dir
})
try {
const { stream } = await tarFileStream('*.txt', testDir, [], false)
await access(tmpDir!)
// Destroying without reading still fires `close` and removes the file.
stream.destroy()
await waitUntilGone(tmpDir!)
} finally {
spy.mockRestore()
}
})
})
@@ -0,0 +1,168 @@
import { describe, expect, test } from 'vitest'
import { validateRelativePath } from '../../../src/template/utils'
import { TemplateError } from '../../../src/errors'
const isWindows = process.platform === 'win32'
describe('validateRelativePath', () => {
describe('valid paths', () => {
test('accepts simple relative path', () => {
expect(() => validateRelativePath('foo', undefined)).not.toThrow()
})
test('accepts nested relative path', () => {
expect(() => validateRelativePath('foo/bar', undefined)).not.toThrow()
})
test('accepts path with ./ prefix', () => {
expect(() => validateRelativePath('./foo', undefined)).not.toThrow()
})
test('accepts nested path with ./ prefix', () => {
expect(() => validateRelativePath('./foo/bar', undefined)).not.toThrow()
})
test('accepts path with internal parent ref that stays within context', () => {
expect(() => validateRelativePath('foo/../bar', undefined)).not.toThrow()
})
test('accepts current directory', () => {
expect(() => validateRelativePath('.', undefined)).not.toThrow()
})
test('accepts glob patterns', () => {
expect(() => validateRelativePath('*.txt', undefined)).not.toThrow()
expect(() => validateRelativePath('**/*.ts', undefined)).not.toThrow()
expect(() => validateRelativePath('src/**/*', undefined)).not.toThrow()
})
test('accepts hidden files and directories', () => {
expect(() => validateRelativePath('.hidden', undefined)).not.toThrow()
expect(() =>
validateRelativePath('.config/settings', undefined)
).not.toThrow()
})
test('accepts filenames starting with double dots', () => {
expect(() => validateRelativePath('..myconfig', undefined)).not.toThrow()
expect(() => validateRelativePath('..cache', undefined)).not.toThrow()
expect(() =>
validateRelativePath('...something', undefined)
).not.toThrow()
expect(() =>
validateRelativePath('foo/..myconfig', undefined)
).not.toThrow()
})
})
describe('invalid paths - absolute', () => {
test('rejects Unix absolute path', () => {
expect(() => validateRelativePath('/absolute/path', undefined)).toThrow(
TemplateError
)
expect(() => validateRelativePath('/absolute/path', undefined)).toThrow(
'absolute paths are not allowed'
)
})
test('rejects root path', () => {
expect(() => validateRelativePath('/', undefined)).toThrow(TemplateError)
})
// Windows path tests - only run on Windows where path.isAbsolute detects them
test.skipIf(!isWindows)('rejects Windows drive letter path', () => {
expect(() =>
validateRelativePath('C:\\Windows\\System32', undefined)
).toThrow(TemplateError)
expect(() =>
validateRelativePath('C:\\Windows\\System32', undefined)
).toThrow('absolute paths are not allowed')
})
test.skipIf(!isWindows)('rejects Windows UNC path', () => {
expect(() =>
validateRelativePath('\\\\server\\share', undefined)
).toThrow(TemplateError)
})
})
describe('invalid paths - parent directory escape', () => {
test('rejects simple parent directory escape', () => {
expect(() => validateRelativePath('../foo', undefined)).toThrow(
TemplateError
)
expect(() => validateRelativePath('../foo', undefined)).toThrow(
'path escapes the context directory'
)
})
test('rejects parent directory escape with forward slash', () => {
expect(() => validateRelativePath('../file.txt', undefined)).toThrow(
TemplateError
)
})
test.skipIf(!isWindows)(
'rejects parent directory escape with backslash',
() => {
expect(() => validateRelativePath('..\\file.txt', undefined)).toThrow(
TemplateError
)
}
)
test('rejects double parent directory escape', () => {
expect(() => validateRelativePath('../../foo', undefined)).toThrow(
TemplateError
)
})
test('rejects path that escapes via nested parent refs', () => {
expect(() => validateRelativePath('foo/../../bar', undefined)).toThrow(
TemplateError
)
})
test('rejects path with ./ prefix that escapes', () => {
expect(() =>
validateRelativePath('./foo/../../../bar', undefined)
).toThrow(TemplateError)
})
test('rejects just parent directory', () => {
expect(() => validateRelativePath('..', undefined)).toThrow(TemplateError)
})
test('rejects current directory followed by parent', () => {
expect(() => validateRelativePath('./..', undefined)).toThrow(
TemplateError
)
})
test('rejects deeply nested escape', () => {
expect(() =>
validateRelativePath('a/b/c/../../../../escape', undefined)
).toThrow(TemplateError)
})
})
describe('error messages include path', () => {
test('absolute path error includes the path', () => {
try {
validateRelativePath('/etc/passwd', undefined)
expect.fail('Should have thrown')
} catch (e) {
expect(e.message).toContain('/etc/passwd')
}
})
test('escape path error includes the path', () => {
try {
validateRelativePath('../secret', undefined)
expect.fail('Should have thrown')
} catch (e) {
expect(e.message).toContain('../secret')
}
})
})
})