Skip to content

Commit b9f2c8e

Browse files
committed
Add validateFiles utility for defensive file access validation
Adds validateFiles() function that filters file paths based on readability, preventing ENOENT errors common with Yarn Berry PnP virtual filesystem, pnpm content-addressable store symlinks, and filesystem race conditions. Returns ValidateFilesResult with validPaths and invalidPaths arrays. Includes comprehensive test coverage for all validation scenarios.
1 parent 7698b94 commit b9f2c8e

2 files changed

Lines changed: 183 additions & 0 deletions

File tree

src/fs.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,74 @@ export function isSymLinkSync(filepath: PathLike) {
674674
return false
675675
}
676676

677+
/**
678+
* Result of file readability validation.
679+
* Contains lists of valid and invalid file paths.
680+
*/
681+
export interface ValidateFilesResult {
682+
/**
683+
* File paths that passed validation and are readable.
684+
*/
685+
validPaths: string[]
686+
/**
687+
* File paths that failed validation (unreadable, permission denied, or non-existent).
688+
* Common with Yarn Berry PnP virtual filesystem, pnpm symlinks, or filesystem race conditions.
689+
*/
690+
invalidPaths: string[]
691+
}
692+
693+
/**
694+
* Validate that file paths are readable before processing.
695+
* Filters out files from glob results that cannot be accessed (common with
696+
* Yarn Berry PnP virtual filesystem, pnpm content-addressable store symlinks,
697+
* or filesystem race conditions in CI/CD environments).
698+
*
699+
* This defensive pattern prevents ENOENT errors when files exist in glob
700+
* results but are not accessible via standard filesystem operations.
701+
*
702+
* @param filepaths - Array of file paths to validate
703+
* @returns Object with `validPaths` (readable) and `invalidPaths` (unreadable)
704+
*
705+
* @example
706+
* ```ts
707+
* import { validateFiles } from '@socketsecurity/lib/fs'
708+
*
709+
* const files = ['package.json', '.pnp.cjs/virtual-file.json']
710+
* const { validPaths, invalidPaths } = validateFiles(files)
711+
*
712+
* console.log(`Valid: ${validPaths.length}`)
713+
* console.log(`Invalid: ${invalidPaths.length}`)
714+
* ```
715+
*
716+
* @example
717+
* ```ts
718+
* // Typical usage in Socket CLI commands
719+
* const packagePaths = await getPackageFilesForScan(targets)
720+
* const { validPaths } = validateFiles(packagePaths)
721+
* await sdk.uploadManifestFiles(orgSlug, validPaths)
722+
* ```
723+
*/
724+
/*@__NO_SIDE_EFFECTS__*/
725+
export function validateFiles(
726+
filepaths: string[] | readonly string[],
727+
): ValidateFilesResult {
728+
const fs = getFs()
729+
const validPaths: string[] = []
730+
const invalidPaths: string[] = []
731+
const { R_OK } = fs.constants
732+
733+
for (const filepath of filepaths) {
734+
try {
735+
fs.accessSync(filepath, R_OK)
736+
validPaths.push(filepath)
737+
} catch {
738+
invalidPaths.push(filepath)
739+
}
740+
}
741+
742+
return { __proto__: null, validPaths, invalidPaths } as ValidateFilesResult
743+
}
744+
677745
/**
678746
* Read directory names asynchronously with filtering and sorting.
679747
* Returns only directory names (not files), with optional filtering for empty directories

test/fs.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
safeStats,
2727
safeStatsSync,
2828
uniqueSync,
29+
validateFiles,
2930
writeJson,
3031
writeJsonSync,
3132
} from '@socketsecurity/lib/fs'
@@ -1198,4 +1199,118 @@ describe('fs', () => {
11981199
}, 'writeJsonSync-no-final-eol-')
11991200
})
12001201
})
1202+
1203+
describe('validateFiles', () => {
1204+
it('should return all files as valid when all exist and are readable', async () => {
1205+
await runWithTempDir(async tmpDir => {
1206+
const file1 = path.join(tmpDir, 'package.json')
1207+
const file2 = path.join(tmpDir, 'tsconfig.json')
1208+
await fs.writeFile(file1, '{}', 'utf8')
1209+
await fs.writeFile(file2, '{}', 'utf8')
1210+
1211+
const { validPaths, invalidPaths } = validateFiles([file1, file2])
1212+
1213+
expect(validPaths).toHaveLength(2)
1214+
expect(validPaths).toContain(file1)
1215+
expect(validPaths).toContain(file2)
1216+
expect(invalidPaths).toHaveLength(0)
1217+
}, 'validateFiles-all-valid-')
1218+
})
1219+
1220+
it('should return non-existent files as invalid', async () => {
1221+
await runWithTempDir(async tmpDir => {
1222+
const existingFile = path.join(tmpDir, 'exists.json')
1223+
const nonExistentFile = path.join(tmpDir, 'does-not-exist.json')
1224+
await fs.writeFile(existingFile, '{}', 'utf8')
1225+
1226+
const { validPaths, invalidPaths } = validateFiles([
1227+
existingFile,
1228+
nonExistentFile,
1229+
])
1230+
1231+
expect(validPaths).toHaveLength(1)
1232+
expect(validPaths).toContain(existingFile)
1233+
expect(invalidPaths).toHaveLength(1)
1234+
expect(invalidPaths).toContain(nonExistentFile)
1235+
}, 'validateFiles-non-existent-')
1236+
})
1237+
1238+
it('should return all files as invalid when none exist', async () => {
1239+
await runWithTempDir(async tmpDir => {
1240+
const file1 = path.join(tmpDir, 'missing1.json')
1241+
const file2 = path.join(tmpDir, 'missing2.json')
1242+
1243+
const { validPaths, invalidPaths } = validateFiles([file1, file2])
1244+
1245+
expect(validPaths).toHaveLength(0)
1246+
expect(invalidPaths).toHaveLength(2)
1247+
expect(invalidPaths).toContain(file1)
1248+
expect(invalidPaths).toContain(file2)
1249+
}, 'validateFiles-all-invalid-')
1250+
})
1251+
1252+
it('should handle empty file array', () => {
1253+
const { validPaths, invalidPaths } = validateFiles([])
1254+
1255+
expect(validPaths).toHaveLength(0)
1256+
expect(invalidPaths).toHaveLength(0)
1257+
})
1258+
1259+
it('should work with readonly arrays', async () => {
1260+
await runWithTempDir(async tmpDir => {
1261+
const file1 = path.join(tmpDir, 'test.json')
1262+
await fs.writeFile(file1, '{}', 'utf8')
1263+
1264+
const readonlyArray: readonly string[] = [file1] as const
1265+
const { validPaths, invalidPaths } = validateFiles(readonlyArray)
1266+
1267+
expect(validPaths).toHaveLength(1)
1268+
expect(validPaths).toContain(file1)
1269+
expect(invalidPaths).toHaveLength(0)
1270+
}, 'validateFiles-readonly-')
1271+
})
1272+
1273+
it('should handle mixed valid and invalid files', async () => {
1274+
await runWithTempDir(async tmpDir => {
1275+
const valid1 = path.join(tmpDir, 'valid1.json')
1276+
const valid2 = path.join(tmpDir, 'valid2.json')
1277+
const invalid1 = path.join(tmpDir, 'invalid1.json')
1278+
const invalid2 = path.join(tmpDir, 'invalid2.json')
1279+
1280+
await fs.writeFile(valid1, '{}', 'utf8')
1281+
await fs.writeFile(valid2, '{}', 'utf8')
1282+
1283+
const { validPaths, invalidPaths } = validateFiles([
1284+
valid1,
1285+
invalid1,
1286+
valid2,
1287+
invalid2,
1288+
])
1289+
1290+
expect(validPaths).toHaveLength(2)
1291+
expect(validPaths).toContain(valid1)
1292+
expect(validPaths).toContain(valid2)
1293+
expect(invalidPaths).toHaveLength(2)
1294+
expect(invalidPaths).toContain(invalid1)
1295+
expect(invalidPaths).toContain(invalid2)
1296+
}, 'validateFiles-mixed-')
1297+
})
1298+
1299+
it('should preserve file order in results', async () => {
1300+
await runWithTempDir(async tmpDir => {
1301+
const file1 = path.join(tmpDir, 'a.json')
1302+
const file2 = path.join(tmpDir, 'b.json')
1303+
const file3 = path.join(tmpDir, 'c.json')
1304+
await fs.writeFile(file1, '{}', 'utf8')
1305+
await fs.writeFile(file2, '{}', 'utf8')
1306+
await fs.writeFile(file3, '{}', 'utf8')
1307+
1308+
const { validPaths } = validateFiles([file3, file1, file2])
1309+
1310+
expect(validPaths[0]).toBe(file3)
1311+
expect(validPaths[1]).toBe(file1)
1312+
expect(validPaths[2]).toBe(file2)
1313+
}, 'validateFiles-order-')
1314+
})
1315+
})
12011316
})

0 commit comments

Comments
 (0)