Skip to content

Commit 5183fed

Browse files
committed
fix: correctness improvements across url/versions/specs/fs/process-lock
- url.urlSearchParamAsBoolean: accept envAsBoolean's vocabulary ('1'|'true'|'yes'|'on' case-insensitive) and honor `defaultValue` for empty string input - versions.maxVersion/minVersion: pass `includePrerelease: true` so all-prerelease inputs resolve instead of returning undefined under semver's default which filters prereleases out against '*'. Also updates the vendored semver .d.ts to expose the third argument. - packages.getRepoUrlDetails: anchor to the literal `github.com` host with a proper regex so non-GitHub URLs, lookalikes (`githubXcom`, `fake-github.com`, `github.com.attacker.tld`), and scp-style `git@github.com:…` return clean empty fields instead of garbage - fs.findUp / findUpSync: traverse up to and INCLUDING the filesystem root so matches at `/.foo` are actually found; the loop used to exit before visiting the root - process-lock.isStale: compare in milliseconds rather than truncating both sides to seconds. Sub-second `staleMs` values (e.g. 500) were silently rounded up to ~1s. - process-lock.acquire: drop the redundant `existsSync` before `mkdirSync(lockPath)` — mkdir's EEXIST is atomic on its own, the pre-check only opened a TOCTOU window. Tests updated where they pinned the old (incorrect) behavior.
1 parent 10a5d11 commit 5183fed

8 files changed

Lines changed: 114 additions & 33 deletions

File tree

src/external/semver.d.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,20 @@ export function gte(version1: string, version2: string): boolean
2222
export function lt(version1: string, version2: string): boolean
2323
export function lte(version1: string, version2: string): boolean
2424
export function valid(version: string): string | null
25-
export function maxSatisfying(versions: string[], range: string): string | null
26-
export function minSatisfying(versions: string[], range: string): string | null
25+
export interface RangeOptions {
26+
includePrerelease?: boolean
27+
loose?: boolean
28+
}
29+
export function maxSatisfying(
30+
versions: string[],
31+
range: string,
32+
options?: RangeOptions | boolean,
33+
): string | null
34+
export function minSatisfying(
35+
versions: string[],
36+
range: string,
37+
options?: RangeOptions | boolean,
38+
): string | null
2739
export function sort(versions: string[]): string[]
2840
export function rsort(versions: string[]): string[]
2941
export function diff(

src/fs.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,10 @@ export async function findUp(
517517
let dir = path.resolve(cwd)
518518
const { root } = path.parse(dir)
519519
const names = isArray(name) ? name : [name as string]
520-
while (dir && dir !== root) {
520+
// Traverse up to and INCLUDING the filesystem root. Previously the
521+
// loop exited when `dir === root`, so a match at e.g. `/.foo` was
522+
// never visited.
523+
while (dir) {
521524
for (const n of names) {
522525
if (signal?.aborted) {
523526
return undefined
@@ -534,6 +537,9 @@ export async function findUp(
534537
}
535538
} catch {}
536539
}
540+
if (dir === root) {
541+
break
542+
}
537543
dir = path.dirname(dir)
538544
}
539545
return undefined
@@ -588,7 +594,10 @@ export function findUpSync(
588594
const { root } = path.parse(dir)
589595
const stopDir = stopAt ? path.resolve(stopAt) : undefined
590596
const names = isArray(name) ? name : [name as string]
591-
while (dir && dir !== root) {
597+
// Traverse up to and INCLUDING the filesystem root (or stopAt). The
598+
// old `while (dir && dir !== root)` exited before visiting `root`
599+
// itself, so a match at `/.foo` was never found.
600+
while (dir) {
592601
// Check if we should stop at this directory.
593602
if (stopDir && dir === stopDir) {
594603
// Check current directory but don't go up.
@@ -618,6 +627,9 @@ export function findUpSync(
618627
}
619628
} catch {}
620629
}
630+
if (dir === root) {
631+
break
632+
}
621633
dir = path.dirname(dir)
622634
}
623635
return undefined

src/packages/specs.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,23 @@ export function getRepoUrlDetails(repoUrl: string = ''): {
2121
user: string
2222
project: string
2323
} {
24-
const userAndRepo = repoUrl.replace(/^.+github.com\//, '').split('/')
24+
// Anchor the host to exactly `github.com` (optionally preceded by
25+
// userinfo like `user@`). Escaping the `.` blocks lookalikes like
26+
// `githubXcom`; pinning the host to the full final label blocks
27+
// `fake-github.com` or `github.com.attacker.tld` shenanigans. Callers
28+
// passing scp-style git@github.com:… need to normalize upstream; we
29+
// require `://` here so the host component is unambiguous.
30+
const match =
31+
/^(?:[a-z]+:\/\/)(?:[^/@]+@)?github\.com\/([^?#]+)(?:[?#]|$)/i.exec(repoUrl)
32+
if (!match || !match[1]) {
33+
return { user: '', project: '' }
34+
}
35+
const userAndRepo = match[1].split('/')
2536
const user = userAndRepo[0] || ''
26-
const project =
27-
userAndRepo.length > 1
28-
? (userAndRepo[1]?.endsWith('.git')
29-
? userAndRepo[1].slice(0, -4)
30-
: userAndRepo[1]) || ''
31-
: ''
37+
const rawProject = userAndRepo[1] ?? ''
38+
const project = rawProject.endsWith('.git')
39+
? rawProject.slice(0, -4)
40+
: rawProject
3241
return { user, project }
3342
}
3443

src/process-lock.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,14 @@ class ProcessLockManager {
227227
if (!stats) {
228228
return false
229229
}
230-
// Use second-level granularity to avoid APFS issues.
231-
const ageSeconds = Math.floor((Date.now() - stats.mtime.getTime()) / 1000)
232-
const staleSeconds = Math.floor(staleMs / 1000)
233-
return ageSeconds > staleSeconds
230+
// Compare in milliseconds. APFS's mtime precision is second-level
231+
// but truncating the age to seconds was silently rounding sub-
232+
// second `staleMs` values up to ~1s (staleMs=500 → staleSeconds=0,
233+
// so the lock had to be 1s+ past mtime before it was considered
234+
// stale). Floor only the mtime side to absorb APFS quirks, keep
235+
// the threshold at full precision.
236+
const ageMs = Date.now() - Math.floor(stats.mtime.getTime())
237+
return ageMs > staleMs
234238
} catch {
235239
return false
236240
}
@@ -288,24 +292,21 @@ class ProcessLockManager {
288292
}
289293
}
290294

291-
// Check if lock already exists before creating.
292-
const fs = getFs()
293-
if (fs.existsSync(lockPath)) {
294-
throw new Error(`Lock already exists: ${lockPath}`)
295-
}
296-
297295
// Ensure parent directory exists. Use path.dirname() so that both
298296
// POSIX and Windows separators (and mixed-separator inputs) are
299297
// handled correctly — the previous Math.max(lastIndexOf('/'), '\\')
300298
// approach failed on relative paths and mixed-separator inputs.
299+
const fs = getFs()
301300
const parent = getPath().dirname(lockPath)
302301
if (parent && parent !== '.' && parent !== lockPath) {
303302
fs.mkdirSync(parent, { recursive: true })
304303
}
305304

306305
// Atomic lock acquisition via mkdir (without recursive).
307306
// Without recursive, mkdirSync throws EEXIST if another process
308-
// created the directory between the existsSync check and here.
307+
// already owns the directory. No pre-check: an existsSync +
308+
// mkdirSync pair opens a TOCTOU window without adding safety,
309+
// because mkdirSync is atomic on its own.
309310
fs.mkdirSync(lockPath)
310311

311312
// Track lock for cleanup.

src/url.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,21 @@ export function urlSearchParamAsBoolean(
137137
} as UrlSearchParamAsBooleanOptions
138138
if (typeof value === 'string') {
139139
const trimmed = value.trim()
140-
return trimmed === '1' || trimmed.toLowerCase() === 'true'
140+
// Empty string → use defaultValue, same as null/undefined. Previously
141+
// fell through to the final truthy check and returned false, silently
142+
// bypassing `defaultValue: true`.
143+
if (trimmed === '') {
144+
return !!defaultValue
145+
}
146+
const lowered = trimmed.toLowerCase()
147+
// Accept the same truthy vocabulary as `envAsBoolean` so query-string
148+
// flags behave predictably cross-context: '1', 'true', 'yes', 'on'.
149+
return (
150+
lowered === '1' ||
151+
lowered === 'true' ||
152+
lowered === 'yes' ||
153+
lowered === 'on'
154+
)
141155
}
142156
if (value === null || value === undefined) {
143157
return !!defaultValue

src/versions.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,14 @@ export function isValidVersion(version: string): boolean {
242242
export function maxVersion(versions: string[]): string | undefined {
243243
/* c8 ignore next - External semver call */
244244
const semver = getSemver()
245-
return semver.maxSatisfying(versions, '*') || undefined
245+
// includePrerelease: true so an all-prerelease input like
246+
// ['1.0.0-alpha', '1.0.0-beta'] resolves to the latest prerelease
247+
// instead of returning undefined under semver's default (which filters
248+
// prereleases out against '*').
249+
return (
250+
semver.maxSatisfying(versions, '*', { includePrerelease: true }) ||
251+
undefined
252+
)
246253
}
247254

248255
/**
@@ -256,7 +263,10 @@ export function maxVersion(versions: string[]): string | undefined {
256263
export function minVersion(versions: string[]): string | undefined {
257264
/* c8 ignore next - External semver call */
258265
const semver = getSemver()
259-
return semver.minSatisfying(versions, '*') || undefined
266+
return (
267+
semver.minSatisfying(versions, '*', { includePrerelease: true }) ||
268+
undefined
269+
)
260270
}
261271

262272
/**

test/unit/packages/specs.test.mts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ describe('packages/specs', () => {
3636
expect(result.project).toBe('node')
3737
})
3838

39-
it('should handle git@ protocol URLs', () => {
39+
it('rejects git@ protocol URLs (scp-style is not supported)', () => {
40+
// scp-style `git@github.com:npm/cli.git` has no `://` separator;
41+
// the matcher requires one so the host is unambiguously `github.com`
42+
// rather than the whole string before `:`. Callers that need scp
43+
// parsing should normalize to https upstream.
4044
const result = getRepoUrlDetails('git@github.com:npm/cli.git')
41-
// Note: the function doesn't handle git@ URLs with : separator correctly
42-
expect(result.user).toBe('git@github.com:npm')
43-
expect(result.project).toBe('cli')
45+
expect(result.user).toBe('')
46+
expect(result.project).toBe('')
4447
})
4548

4649
it('should handle git:// protocol URLs', () => {
@@ -49,12 +52,28 @@ describe('packages/specs', () => {
4952
expect(result.project).toBe('berry')
5053
})
5154

52-
it('should return empty strings for invalid URL', () => {
55+
it('returns empty strings for invalid URL', () => {
56+
// Previously the loose `/^.+github.com\//` replace left garbage in
57+
// user/project for non-GitHub or malformed inputs. Now returns a
58+
// clean empty result so callers can branch on it.
5359
const result = getRepoUrlDetails('not-a-valid-url')
54-
expect(result.user).toBe('not-a-valid-url')
60+
expect(result.user).toBe('')
5561
expect(result.project).toBe('')
5662
})
5763

64+
it('rejects github.com lookalike hosts', () => {
65+
// Previously `/^.+github.com\//` matched any host that happened to
66+
// end with `github.com` literally because `.` was unescaped.
67+
expect(getRepoUrlDetails('https://githubXcom/a/b')).toEqual({
68+
user: '',
69+
project: '',
70+
})
71+
expect(getRepoUrlDetails('https://fake-github.com.attacker.tld/a/b')).toEqual({
72+
user: '',
73+
project: '',
74+
})
75+
})
76+
5877
it('should handle empty string', () => {
5978
const result = getRepoUrlDetails('')
6079
expect(result.user).toBe('')

test/unit/url.test.mts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,13 @@ describe('url', () => {
221221
expect(urlSearchParamAsBoolean('0')).toBe(false)
222222
})
223223

224-
it('should return false for other strings', () => {
224+
it('accepts the same truthy vocabulary as envAsBoolean', () => {
225+
// Expanded set so callers get consistent behavior across env vars
226+
// and query strings: '1' | 'true' | 'yes' | 'on' (case-insensitive).
227+
expect(urlSearchParamAsBoolean('yes')).toBe(true)
228+
expect(urlSearchParamAsBoolean('Yes')).toBe(true)
229+
expect(urlSearchParamAsBoolean('on')).toBe(true)
225230
expect(urlSearchParamAsBoolean('hello')).toBe(false)
226-
expect(urlSearchParamAsBoolean('yes')).toBe(false)
227231
expect(urlSearchParamAsBoolean('no')).toBe(false)
228232
})
229233

0 commit comments

Comments
 (0)