Skip to content

Commit 94f203d

Browse files
committed
fix(cli): harden wizard package normalization
Preserve requested npm tags when the wizard strips source prefixes. Validate package names before probing node_modules so traversal inputs cannot escape the package directory.
1 parent 5536cb9 commit 94f203d

7 files changed

Lines changed: 96 additions & 36 deletions

File tree

src/cache/internal/version.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const VALID_PKG_NAME = /^(?:@[a-z0-9][-a-z0-9._]*\/)?[a-z0-9][-a-z0-9._]*$/
1111
/** Validate version string (semver-ish, no path separators) */
1212
const VALID_VERSION = /^[a-z0-9][-\w.+]*$/i
1313

14+
export function isValidCachePackageName(name: string): boolean {
15+
return VALID_PKG_NAME.test(name)
16+
}
17+
18+
export function isValidCacheVersion(version: string): boolean {
19+
return VALID_VERSION.test(version)
20+
}
21+
1422
/**
1523
* Get exact version key for cache keying
1624
*/
@@ -30,9 +38,9 @@ export function getCacheKey(name: string, version: string): string {
3038
* Validates name/version to prevent path traversal.
3139
*/
3240
export function getCacheDir(name: string, version: string): string {
33-
if (!VALID_PKG_NAME.test(name))
41+
if (!isValidCachePackageName(name))
3442
throw new Error(`Invalid package name: ${name}`)
35-
if (!VALID_VERSION.test(version))
43+
if (!isValidCacheVersion(version))
3644
throw new Error(`Invalid version: ${version}`)
3745

3846
const dir = resolve(REFERENCES_DIR, getCacheKey(name, version))

src/cli.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { runWizard } from './commands/wizard.ts'
2121
import { timedSpinner } from './core/formatting.ts'
2222
import { getProjectState, hasCompletedWizard, isOutdated, readConfig, semverGt } from './core/index.ts'
2323
import { readPackageJsonSafe } from './core/package-json.ts'
24-
import { parseSkillInput } from './core/prefix.ts'
24+
import { parseNpmPackageInputs } from './core/prefix.ts'
2525
import { COMMA_OR_WHITESPACE_RE, VERSION_RANGE_PREFIX_RE } from './core/regex.ts'
2626
import { iterateSkills } from './core/skills.ts'
2727
import { fetchLatestVersion, fetchNpmRegistryMeta } from './sources/index.ts'
@@ -60,19 +60,6 @@ function deprecatedForwarder(
6060

6161
// ── Subcommands (lazy-loaded) ──
6262

63-
function toPackageNames(tokens: string[]): string[] | null {
64-
const names: string[] = []
65-
for (const token of tokens) {
66-
const source = parseSkillInput(token)
67-
if (source.type !== 'npm' && source.type !== 'bare') {
68-
p.log.error(`${token} is not an npm package. Install it with \`skilld add ${token}\`.`)
69-
return null
70-
}
71-
names.push(source.package)
72-
}
73-
return names
74-
}
75-
7663
const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload', 'login', 'logout', 'whoami', 'pull']
7764

7865
// ── Main command ──
@@ -304,10 +291,12 @@ const main = defineCommand({
304291
p.log.warn('No packages entered')
305292
continue
306293
}
307-
const names = toPackageNames(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean))
308-
if (!names)
294+
const parsed = parseNpmPackageInputs(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean))
295+
if (parsed._tag === 'Err') {
296+
p.log.error(`${parsed.input} is not an npm package. Install it with \`skilld add ${parsed.input}\`.`)
309297
continue
310-
selected = names
298+
}
299+
selected = parsed.packageSpecs
311300
if (selected.length === 0) {
312301
p.log.warn('No valid packages entered')
313302
continue
@@ -555,10 +544,12 @@ const main = defineCommand({
555544
}))
556545
if (!input)
557546
return
558-
const names = toPackageNames(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean))
559-
if (!names)
547+
const parsed = parseNpmPackageInputs(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean))
548+
if (parsed._tag === 'Err') {
549+
p.log.error(`${parsed.input} is not an npm package. Install it with \`skilld add ${parsed.input}\`.`)
560550
return
561-
selected = names
551+
}
552+
selected = parsed.packageSpecs
562553
if (selected.length === 0)
563554
return
564555
}

src/core/prefix.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { GitSkillSource } from '../sources/git-skills.ts'
1616
import { parseGitSkillInput } from '../sources/git-skills.ts'
1717

1818
const STATIC_REGEX_1 = /^[\w.-]+\/[\w.-]+/
19+
const EXPLICIT_NON_NPM_PREFIX_RE = /^(?:crate|gh|github):/
1920

2021
export type SkillSource
2122
= | { type: 'npm', package: string, tag?: string }
@@ -25,6 +26,25 @@ export type SkillSource
2526
| { type: 'collection', handle: string, name: string }
2627
| { type: 'bare', package: string, tag?: string }
2728

29+
export type NpmPackageInputResult
30+
= | { _tag: 'Ok', packageSpecs: string[] }
31+
| { _tag: 'Err', input: string }
32+
33+
export function parseNpmPackageInputs(inputs: string[]): NpmPackageInputResult {
34+
const packageSpecs: string[] = []
35+
36+
for (const input of inputs) {
37+
const source = parseSkillInput(input)
38+
const isMalformedExplicitSource = source.type === 'bare' && EXPLICIT_NON_NPM_PREFIX_RE.test(input)
39+
if ((source.type !== 'npm' && source.type !== 'bare') || isMalformedExplicitSource || !source.package)
40+
return { _tag: 'Err', input }
41+
42+
packageSpecs.push(source.tag ? `${source.package}@${source.tag}` : source.package)
43+
}
44+
45+
return { _tag: 'Ok', packageSpecs }
46+
}
47+
2848
/**
2949
* Parse a single CLI input token into a typed SkillSource.
3050
*

src/core/prepare.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import type { SkillInfo } from './lockfile.ts'
1010
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs'
1111
import { basename, join } from 'pathe'
12-
import { getCacheDir } from '../cache/internal/version.ts'
12+
import { getCacheDir, isValidCachePackageName, isValidCacheVersion } from '../cache/internal/version.ts'
1313
import { readPackageJsonSafe } from './package-json.ts'
1414
import { README_FILENAME_RE } from './regex.ts'
1515

@@ -24,21 +24,17 @@ function toStorageName(name: string): string {
2424

2525
/** Resolve package directory: node_modules first, then global cache */
2626
export function resolvePkgDir(name: string, cwd: string, version?: string): string | null {
27-
if (!name)
27+
if (!isValidCachePackageName(name))
2828
return null
2929

3030
const nodeModulesPath = join(cwd, 'node_modules', name)
3131
if (existsSync(nodeModulesPath))
3232
return nodeModulesPath
3333

3434
if (version) {
35-
let cachedPkgDir: string
36-
try {
37-
cachedPkgDir = join(getCacheDir(name, version), 'pkg')
38-
}
39-
catch {
35+
if (!isValidCacheVersion(version))
4036
return null
41-
}
37+
const cachedPkgDir = join(getCacheDir(name, version), 'pkg')
4238
if (existsSync(join(cachedPkgDir, 'package.json')))
4339
return cachedPkgDir
4440
}

test/unit/pkg-dir-probe.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,39 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'pathe'
4+
import { afterEach, describe, expect, it } from 'vitest'
25
import { getShippedSkills, resolvePkgDir } from '../../src/core/prepare.ts'
36

47
describe('package dir probing', () => {
5-
it.each(['npm:vue', 'gh:owner/repo', '../escape', ''])('returns null for %j', (name) => {
8+
const fixtureDirs: string[] = []
9+
10+
afterEach(() => {
11+
for (const dir of fixtureDirs)
12+
rmSync(dir, { recursive: true, force: true })
13+
fixtureDirs.length = 0
14+
})
15+
16+
it.each(['npm:vue', 'gh:owner/repo', ''])('returns null for %j', (name) => {
617
expect(resolvePkgDir(name, process.cwd(), '1.0.0')).toBeNull()
718
})
819

20+
it('rejects traversal when the escaped directory exists', () => {
21+
const cwd = mkdtempSync(join(tmpdir(), 'skilld-pkg-probe-'))
22+
fixtureDirs.push(cwd)
23+
mkdirSync(join(cwd, 'escape'))
24+
25+
expect(resolvePkgDir('../escape', cwd, '1.0.0')).toBeNull()
26+
})
27+
28+
it('returns an installed package before validating the cache version', () => {
29+
const cwd = mkdtempSync(join(tmpdir(), 'skilld-pkg-probe-'))
30+
fixtureDirs.push(cwd)
31+
const packageDir = join(cwd, 'node_modules', 'vue')
32+
mkdirSync(packageDir, { recursive: true })
33+
34+
expect(resolvePkgDir('vue', cwd, '../invalid')).toBe(packageDir)
35+
})
36+
937
it.each(['npm:vue', '../escape'])('reports no shipped skills for %j', (name) => {
1038
expect(getShippedSkills(name, process.cwd(), '1.0.0')).toEqual([])
1139
})

test/unit/prefix.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parseSkillInput, resolveSkillName } from '../../src/core/prefix'
2+
import { parseNpmPackageInputs, parseSkillInput, resolveSkillName } from '../../src/core/prefix'
33

44
describe('prefix parser', () => {
5+
describe('wizard npm inputs', () => {
6+
it('normalizes prefixes without dropping npm tags', () => {
7+
expect(parseNpmPackageInputs(['npm:vue@beta', '@nuxt/ui@3.0.0', 'pinia'])).toEqual({
8+
_tag: 'Ok',
9+
packageSpecs: ['vue@beta', '@nuxt/ui@3.0.0', 'pinia'],
10+
})
11+
})
12+
13+
it.each(['gh:owner/repo', 'gh:not-a-repo', 'crate:serde', '@curator'])('rejects non-npm input %s', (input) => {
14+
expect(parseNpmPackageInputs([input])).toEqual({ _tag: 'Err', input })
15+
})
16+
})
17+
518
describe('npm: prefix', () => {
619
it('parses simple package name', () => {
720
expect(parseSkillInput('npm:vue')).toEqual({

test/unit/prepare-restore.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ vi.mock('node:fs', async () => {
1212
}
1313
})
1414

15-
vi.mock('../../src/cache/internal/version', () => ({
16-
getCacheDir: (name: string, version: string) => `/home/.skilld/references/${name}@${version}`,
17-
}))
15+
vi.mock('../../src/cache/internal/version', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('../../src/cache/internal/version')>()
17+
return {
18+
...actual,
19+
getCacheDir: (name: string, version: string) => `/home/.skilld/references/${name}@${version}`,
20+
}
21+
})
1822

1923
describe('restorePkgSymlink', () => {
2024
beforeEach(() => vi.resetAllMocks())

0 commit comments

Comments
 (0)