Skip to content

Commit 713df6f

Browse files
committed
fix(pnpm): preserve underscores in package names when stripping peer suffix
Fixes SMO-632. `stripPnpmPeerSuffix` scanned the whole dep path for the first `(` or `_`, which truncated `http_ece@1.0.0` to `http` and caused `socket pnpm install` to produce a blocking false-positive malware alert (`http@0.0.1-security: Known malware (critical; blocked)`) on any project that (transitively) depends on `http_ece` — e.g. `web-push@3.6.7`. Customer Rio Transfer was blocked by this; reproducible with: mkdir /tmp/repro && cd /tmp/repro echo '{"name":"t","private":true,"dependencies":{"web-push":"3.6.7"}}' > package.json pnpm install socket pnpm install --frozen-lockfile ## Fix `(` and `_` peer separators only appear *after* the version-separating `@` — semver forbids both characters inside a version, prerelease tag, or build metadata. Narrow the search to the version-and-beyond portion of the dep path so package-name underscores are preserved. Walk-through for the regression case: `http_ece@1.0.0_web-push@3.6.7` * scopeOffset=0 (no leading `@`) * versionAt = indexOf('@', 0) = 8 * tail = `@1.0.0_web-push@3.6.7`, underscore at tail[6] * slice at 8+6 = 14 → `http_ece@1.0.0` ✓ And the scoped variant: `@foo/bar_baz@1.0.0_peer@2.0.0` * scopeOffset=1 (skip leading `@`) * versionAt = indexOf('@', 1) = 12 * tail = `@1.0.0_peer@2.0.0`, underscore at tail[6] * slice at 12+6 = 18 → `@foo/bar_baz@1.0.0` ✓ ## Tests Added two `it` blocks with six assertions: * `preserves underscores inside package names (SMO-632 regression)` — `http_ece` with no suffix / underscore peer / parenthesized peer. * `handles scoped packages with underscore names` — `@foo/bar_baz` variants, proving the scope leader `@` doesn't confuse the version-separator search. Also rewrote the "prefers parentheses over underscore" case as "prefers the earlier separator when both appear" — the new algorithm picks the earlier separator rather than categorically preferring one, and the only real-world shape where both appear is `(peer)_legacy` where `(` is earlier, so the observable outcome is unchanged.
1 parent 9fae51f commit 713df6f

2 files changed

Lines changed: 68 additions & 16 deletions

File tree

packages/cli/src/utils/pnpm/lockfile.mts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,28 +98,51 @@ export function stripLeadingPnpmDepPathSlash(depPath: string): string {
9898
}
9999

100100
/**
101-
* Strip pnpm peer dependency suffix from dep path.
101+
* Strip pnpm peer dependency suffix from a dep path.
102102
*
103-
* PNPM appends peer dependency info in format:
104-
* - `package@1.0.0(peer@2.0.0)` - parentheses for peer deps.
105-
* - `package@1.0.0_peer@2.0.0` - underscore for peer deps.
103+
* pnpm appends peer dependency info in one of two formats:
104+
* - `package@1.0.0(peer@2.0.0)` parentheses (pnpm v7+).
105+
* - `package@1.0.0_peer@2.0.0` underscore (pnpm v6 / older lockfiles).
106106
*
107-
* Edge cases to consider:
108-
* - Package names with `(` or `_` in legitimate names (rare but valid).
109-
* - Scoped packages: `@scope/package` should work correctly.
110-
* - Empty or malformed dep paths should return unchanged.
107+
* Any `(` or `_` that signals a peer suffix lives *after* the
108+
* version-separating `@`. Package names themselves may legitimately
109+
* contain underscores (e.g. `http_ece`) but semver forbids `_` / `(`
110+
* in a version string, so the first separator we see after the
111+
* version-`@` is always the peer suffix.
111112
*
112-
* Current implementation: Find first `(` or `_` and strip everything after.
113-
* Limitation: May incorrectly strip if package name contains these chars.
114-
* Mitigation: Most package names don't contain `(` or `_`, pnpm format is predictable.
113+
* This previously scanned the whole string for `_` / `(`, which
114+
* truncated `http_ece@1.0.0` to `http` and caused a false-positive
115+
* malware alert for the unrelated `http@0.0.1-security` placeholder.
115116
*/
116117
export function stripPnpmPeerSuffix(depPath: string): string {
117-
// Defensive: Handle empty or invalid inputs.
118+
// Defensive: handle empty or invalid inputs.
118119
if (!depPath || typeof depPath !== 'string') {
119120
return depPath
120121
}
121122

122-
const parenIndex = depPath.indexOf('(')
123-
const index = parenIndex === -1 ? depPath.indexOf('_') : parenIndex
124-
return index === -1 ? depPath : depPath.slice(0, index)
123+
// Locate the version-separating `@`. Skip a leading `@` on scoped
124+
// packages so we don't stop at the scope delimiter.
125+
const scopeOffset = depPath.startsWith('@') ? 1 : 0
126+
const versionAt = depPath.indexOf('@', scopeOffset)
127+
if (versionAt === -1) {
128+
return depPath
129+
}
130+
131+
// Search for `(` or `_` in the version-and-beyond portion only.
132+
const tail = depPath.slice(versionAt)
133+
const parenInTail = tail.indexOf('(')
134+
const underInTail = tail.indexOf('_')
135+
136+
let cutInTail: number
137+
if (parenInTail === -1) {
138+
cutInTail = underInTail
139+
} else if (underInTail === -1) {
140+
cutInTail = parenInTail
141+
} else {
142+
// Both present — take whichever comes first (handles the pnpm
143+
// v7 `(peer)_legacyPeer` shape observed in mixed-format lockfiles).
144+
cutInTail = Math.min(parenInTail, underInTail)
145+
}
146+
147+
return cutInTail === -1 ? depPath : depPath.slice(0, versionAt + cutInTail)
125148
}

packages/cli/test/unit/utils/pnpm/lockfile.test.mts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,14 +226,43 @@ packages: {}`
226226
)
227227
})
228228

229-
it('prefers parentheses over underscore', () => {
229+
it('prefers the earlier separator when both appear', () => {
230+
// pnpm v7's `(peer)` wins because it comes before the legacy `_peer`.
230231
expect(stripPnpmPeerSuffix('pkg@1.0.0(peer)_other')).toBe('pkg@1.0.0')
231232
})
232233

233234
it('returns unchanged for paths without suffixes', () => {
234235
expect(stripPnpmPeerSuffix('lodash@4.17.21')).toBe('lodash@4.17.21')
235236
expect(stripPnpmPeerSuffix('@babel/core@7.0.0')).toBe('@babel/core@7.0.0')
236237
})
238+
239+
it('preserves underscores inside package names (SMO-632 regression)', () => {
240+
// `http_ece` is a legitimate package (encryption lib used by web-push).
241+
// Stripping at the first `_` truncated it to `http`, which resolved to
242+
// npm's `http@0.0.1-security` malware placeholder and produced a
243+
// blocking false-positive alert.
244+
expect(stripPnpmPeerSuffix('http_ece@1.0.0')).toBe('http_ece@1.0.0')
245+
expect(stripPnpmPeerSuffix('http_ece@1.2.0_web-push@3.6.7')).toBe(
246+
'http_ece@1.2.0',
247+
)
248+
expect(stripPnpmPeerSuffix('http_ece@1.2.0(web-push@3.6.7)')).toBe(
249+
'http_ece@1.2.0',
250+
)
251+
})
252+
253+
it('handles scoped packages with underscore names', () => {
254+
// Scoped equivalents of the SMO-632 pattern — the scope leader `@`
255+
// must not be mistaken for the version-separating `@`.
256+
expect(stripPnpmPeerSuffix('@foo/bar_baz@1.0.0')).toBe(
257+
'@foo/bar_baz@1.0.0',
258+
)
259+
expect(stripPnpmPeerSuffix('@foo/bar_baz@1.0.0_peer@2.0.0')).toBe(
260+
'@foo/bar_baz@1.0.0',
261+
)
262+
expect(stripPnpmPeerSuffix('@foo/bar_baz@1.0.0(peer@2.0.0)')).toBe(
263+
'@foo/bar_baz@1.0.0',
264+
)
265+
})
237266
})
238267

239268
describe('extractPurlsFromPnpmLockfile', () => {

0 commit comments

Comments
 (0)