Skip to content

(High Severity Cross-Platform Bug): Windows Test Discovery Failure via Case-Sensitive Prefix Matching in normalizeFileForMatching #3484

Description

@codeCraft-Ritik

Issue :- (High Severity Cross-Platform Bug): Windows Test Discovery Failure via Case-Sensitive Prefix Matching in normalizeFileForMatching

Metadata

  • Title: [Bug] Windows test discovery fails due to drive letter case sensitivity and root drive slicing in normalizeFileForMatching
  • Severity: High (Cross-Platform / Windows Compatibility)
  • Component: Path Normalization & Glob Matching (lib/glob-helpers.js)
  • Affected File: lib/glob-helpers.js (normalizeFileForMatching, lines 79–94)

Problem Summary

normalizeFileForMatching strips the current working directory from absolute file paths so that picomatch can match them against relative test patterns (e.g. test/**/*.js).

On Windows, this implementation suffers from two major bugs:

  1. Drive Letter Case Sensitivity: Windows file paths are case-insensitive, and Node.js frequently mixes lowercase (c:/...) and uppercase (C:/...) drive letters (e.g., from process.cwd() vs pathToFileURL vs resolve). Because file.startsWith(cwd) performs a case-sensitive string check, c:/project/test.js does NOT start with C:/project. The path is not stripped and is returned as a full absolute path, causing matches(file, filePatterns) to fail and test files to be silently ignored.
  2. Drive Root Path Corruption: The function assumes cwd does not end in a slash and computes file.slice(cwd.length + 1). When running from a drive root (e.g. C:/), cwd.length + 1 is 4, which slices off the first character of the filename (C:/test.js becomes est.js).

Root Cause Analysis

In lib/glob-helpers.js lines 79–94:

export function normalizeFileForMatching(cwd, file) {
    if (process.platform === 'win32') {
        cwd = slash(cwd);
        file = slash(file);
    }

    // Note that if `file` is outside `cwd` we can't normalize it. If this turns
    // out to be a real-world scenario we may have to make changes in calling code
    // to make sure the file isn't even selected for matching.
    if (!file.startsWith(cwd)) {
        return file;
    }

    // Assume `cwd` does *not* end in a slash.
    return file.slice(cwd.length + 1);
}
  • Scenario 1: cwd = "C:/Users/dev/project", file = "c:/Users/dev/project/test/app.test.js".
    file.startsWith(cwd) is false. Returns "c:/Users/dev/project/test/app.test.js".
    classify() passes this to picomatch('test/**/*.js'), which returns false. The test file is discarded.
  • Scenario 2: cwd = "C:/", file = "C:/test.js".
    file.slice(3 + 1) returns "est.js". The test file is corrupted.

Proposed Fix

Use standard path.relative (which natively handles Windows case-insensitivity, drive boundaries, and root slashes) combined with slash():

--- a/lib/glob-helpers.js
+++ b/lib/glob-helpers.js
@@ -79,16 +79,12 @@ export function matches(file, patterns) {
 export function normalizeFileForMatching(cwd, file) {
-	if (process.platform === 'win32') {
-		cwd = slash(cwd);
-		file = slash(file);
-	}
-
-	// Note that if `file` is outside `cwd` we can't normalize it. If this turns
-	// out to be a real-world scenario we may have to make changes in calling code
-	// to make sure the file isn't even selected for matching.
-	if (!file.startsWith(cwd)) {
-		return file;
+	const rel = path.relative(cwd, file);
+	if (rel.startsWith('..') || path.isAbsolute(rel)) {
+		return slash(file);
 	}
 
-	// Assume `cwd` does *not* end in a slash.
-	return file.slice(cwd.length + 1);
+	return slash(rel);
 }

Bonus Finding: isLikeSelector Rejects Object.create(null) & Mutates Prototype via __proto__

Metadata

  • Component: Assertion Library (lib/like-selector.js)
  • Affected File: lib/like-selector.js (lines 3–15, 24–41)

Problem Summary

  1. isLikeSelector checks:
    if (isPrimitive(selector) || (!Array.isArray(selector) && Reflect.getPrototypeOf(selector) !== Object.prototype)) {
        return false;
    }
    Null-prototype objects (Object.create(null)) have a prototype of null (null !== Object.prototype), causing isLikeSelector to reject clean dictionaries as invalid selectors.
  2. In selectComparable:
    comparable[key] = Reflect.get(actual, key);
    If selector contains the key '__proto__', assigning comparable['__proto__'] = ... modifies the prototype of comparable instead of setting an own property, leading to prototype mutation during comparison. Use Reflect.defineProperty to safely preserve own properties.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions