Skip to content

Commit

Permalink
Add support for globbed input and ignores
Browse files Browse the repository at this point in the history
  • Loading branch information
Pelle Wessman committed Dec 12, 2022
1 parent 11bf8d0 commit c9b03ea
Show file tree
Hide file tree
Showing 9 changed files with 489 additions and 97 deletions.
9 changes: 7 additions & 2 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"root": true,
"plugins": ["jsdoc"],
"plugins": [
"jsdoc",
"unicorn"
],
"extends": [
"@socketsecurity",
"plugin:jsdoc/recommended"
Expand All @@ -24,6 +27,8 @@
"jsdoc/require-property-description": "off",
"jsdoc/require-returns-description": "off",
"jsdoc/require-yields": "off",
"jsdoc/valid-types": "off"
"jsdoc/valid-types": "off",

"unicorn/expiring-todo-comments": "warn"
}
}
13 changes: 10 additions & 3 deletions lib/commands/report/create.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/* eslint-disable no-console */

import path from 'node:path'

import meow from 'meow'
import ora from 'ora'

import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api-helpers.js'
import { ChalkOrMarkdown, logSymbols } from '../../utils/chalk-markdown.js'
import { printFlagList } from '../../utils/formatting.js'
import { createDebugLogger } from '../../utils/misc.js'
import { resolvePackagePaths } from '../../utils/path-resolve.js'
import { getPackageFiles } from '../../utils/path-resolve.js'
import { setupSdk } from '../../utils/sdk.js'
import { readSocketConfig } from '../../utils/socket-config.js'
import { fetchReportData, formatReportDataOutput } from './view.js'

/** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
Expand Down Expand Up @@ -147,8 +150,12 @@ async function setupCommand (name, description, argv, importMeta) {

const debugLog = createDebugLogger(dryRun || cli.flags.debug)

// TODO: Allow setting a custom cwd and/or configFile path?
const cwd = process.cwd()
const packagePaths = await resolvePackagePaths(cwd, cli.input)
const absoluteConfigPath = path.join(cwd, 'socket.yml')

const config = await readSocketConfig(absoluteConfigPath)
const packagePaths = await getPackageFiles(cwd, cli.input, config, debugLog)

return {
cwd,
Expand All @@ -169,7 +176,7 @@ async function setupCommand (name, description, argv, importMeta) {
* @returns {Promise<void|import('@socketsecurity/sdk').SocketSdkReturnType<'createReport'>>}
*/
async function createReport (packagePaths, { cwd, debugLog, dryRun }) {
debugLog(`${logSymbols.info} Uploading:`, packagePaths.join(`\n${logSymbols.info} Uploading:`))
debugLog('Uploading:', packagePaths.join(`\n${logSymbols.info} Uploading: `))

if (dryRun) {
return
Expand Down
9 changes: 5 additions & 4 deletions lib/utils/misc.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { logSymbols } from './chalk-markdown.js'

/**
* @param {boolean|undefined} printDebugLogs
* @returns {typeof console.error}
*/
export function createDebugLogger (printDebugLogs) {
if (printDebugLogs) {
return printDebugLogs
// eslint-disable-next-line no-console
return console.error.bind(console)
}
return () => {}
? (...params) => console.error(logSymbols.info, ...params)
: () => {}
}

/**
Expand Down
198 changes: 123 additions & 75 deletions lib/utils/path-resolve.js
Original file line number Diff line number Diff line change
@@ -1,117 +1,165 @@
import { stat } from 'node:fs/promises'
import path from 'node:path'

import { globby } from 'globby'
import ignore from 'ignore'
// @ts-ignore This package provides no types
import { directories } from 'ignore-by-default'
import { ErrorWithCause } from 'pony-cause'

import { InputError } from './errors.js'
import { isErrnoException } from './type-helpers.js'

// TODO: Add globbing support with support for ignoring, as a "./**/package.json" in a project also traverses eg. node_modules
/** @type {readonly string[]} */
const SUPPORTED_LOCKFILES = [
'package-lock.json',
'yarn.lock',
]

/**
* Takes paths to folders and/or package.json / package-lock.json files and resolves to package.json + package-lock.json pairs (where feasible)
* There are a lot of possible folders that we should not be looking in and "ignore-by-default" helps us with defining those
*
* @param {string} cwd
* @param {string[]} inputPaths
* @type {readonly string[]}
*/
const ignoreByDefault = directories()

/** @type {readonly string[]} */
const GLOB_IGNORE = [
...ignoreByDefault.map(item => '**/' + item)
]

/**
* Resolves package.json and lockfiles from (globbed) input paths, applying relevant ignores
*
* @param {string} cwd The working directory to use when resolving paths
* @param {string[]} inputPaths A list of paths to folders, package.json files and/or recognized lockfiles. Supports globs.
* @param {import('./socket-config.js').SocketYml|undefined} config
* @param {typeof console.error} debugLog
* @returns {Promise<string[]>}
* @throws {InputError}
*/
export async function resolvePackagePaths (cwd, inputPaths) {
const packagePathLookups = inputPaths.map(async (filePath) => {
const packagePath = await resolvePackagePath(cwd, filePath)
return findComplementaryPackageFile(packagePath)
export async function getPackageFiles (cwd, inputPaths, config, debugLog) {
let hasPlainDot = false

// TODO [globby@>13.1.2]: The bug that requires this workaround has probably been fixed now: https://github.com/sindresorhus/globby/pull/242
const filteredInputPaths = inputPaths.filter(item => {
if (item === '.') {
hasPlainDot = true
return false
}
return true
})

const packagePaths = await Promise.all(packagePathLookups)
const entries = [
...(hasPlainDot ? [cwd + '/'] : []),
...(await globby(filteredInputPaths, {
absolute: true,
cwd,
expandDirectories: false,
gitignore: true,
ignore: [...GLOB_IGNORE],
markDirectories: true,
onlyFiles: false,
unique: true,
}))
]

debugLog(`Globbed resolved ${inputPaths.length} paths to ${entries.length} paths:`, entries)

const packageFiles = await mapGlobResultToFiles(entries)

debugLog(`Mapped ${entries.length} entries to ${packageFiles.length} files:`, packageFiles)

const includedPackageFiles = config?.projectIgnorePaths
? ignore()
.add(config.projectIgnorePaths)
.filter(packageFiles)
: packageFiles

return includedPackageFiles
}

/**
* Takes paths to folders, package.json and/or recognized lock files and resolves them to package.json + lockfile pairs (where possible)
*
* @param {string[]} entries
* @returns {Promise<string[]>}
* @throws {InputError}
*/
export async function mapGlobResultToFiles (entries) {
const packageFiles = await Promise.all(entries.map(mapGlobEntryToFiles))

const uniquePackagePaths = new Set(packagePaths.flat())
const uniquePackageFiles = [...new Set(packageFiles.flat())]

return [...uniquePackagePaths]
return uniquePackageFiles
}

/**
* Resolves a package.json / package-lock.json path from a relative folder / file path
* Takes a single path to a folder, package.json or a recognized lock file and resolves to a package.json + lockfile pair (where possible)
*
* @param {string} cwd
* @param {string} inputPath
* @returns {Promise<string>}
* @param {string} entry
* @returns {Promise<string[]>}
* @throws {InputError}
*/
async function resolvePackagePath (cwd, inputPath) {
const filePath = path.resolve(cwd, inputPath)
export async function mapGlobEntryToFiles (entry) {
/** @type {string|undefined} */
let filePathAppended

try {
const fileStat = await stat(filePath)

if (fileStat.isDirectory()) {
filePathAppended = path.resolve(filePath, 'package.json')
}
} catch (err) {
if (isErrnoException(err) && err.code === 'ENOENT') {
throw new InputError(`Expected '${inputPath}' to point to an existing file or directory`)
}
throw new ErrorWithCause('Failed to resolve path to package.json', { cause: err })
let pkgFile
/** @type {string|undefined} */
let lockFile

if (entry.endsWith('/')) {
// If the match is a folder and that folder contains a package.json file, then include it
const filePath = path.resolve(entry, 'package.json')
pkgFile = await fileExists(filePath) ? filePath : undefined
} else if (path.basename(entry) === 'package.json') {
// If the match is a package.json file, then include it
pkgFile = entry
} else if (SUPPORTED_LOCKFILES.includes(path.basename(entry))) {
// If the match is a lock file, include both it and the corresponding package.json file
lockFile = entry
pkgFile = path.resolve(path.dirname(entry), 'package.json')
}

if (filePathAppended) {
/** @type {import('node:fs').Stats} */
let filePathAppendedStat
// If we will include a package.json file but don't already have a corresponding lockfile, then look for one
if (!lockFile && pkgFile) {
const pkgDir = path.dirname(pkgFile)

try {
filePathAppendedStat = await stat(filePathAppended)
} catch (err) {
if (isErrnoException(err) && err.code === 'ENOENT') {
throw new InputError(`Expected directory '${inputPath}' to contain a package.json file`)
for (const name of SUPPORTED_LOCKFILES) {
const lockFileAlternative = path.resolve(pkgDir, name)
if (await fileExists(lockFileAlternative)) {
lockFile = lockFileAlternative
break
}
throw new ErrorWithCause('Failed to resolve package.json in directory', { cause: err })
}

if (!filePathAppendedStat.isFile()) {
throw new InputError(`Expected '${filePathAppended}' to be a file`)
}
}

return filePathAppended
if (pkgFile && lockFile) {
return [pkgFile, lockFile]
}

return filePath
return pkgFile ? [pkgFile] : []
}

/**
* Finds any complementary file to a package.json or package-lock.json
*
* @param {string} packagePath
* @returns {Promise<string[]>}
* @throws {InputError}
* @param {string} filePath
* @returns {Promise<boolean>}
*/
async function findComplementaryPackageFile (packagePath) {
const basename = path.basename(packagePath)
const dirname = path.dirname(packagePath)

if (basename === 'package-lock.json') {
// We need the package file as well
return [
packagePath,
path.resolve(dirname, 'package.json')
]
}
export async function fileExists (filePath) {
/** @type {import('node:fs').Stats} */
let pathStat

if (basename === 'package.json') {
const lockfilePath = path.resolve(dirname, 'package-lock.json')
try {
const lockfileStat = await stat(lockfilePath)
if (lockfileStat.isFile()) {
return [packagePath, lockfilePath]
}
} catch (err) {
if (isErrnoException(err) && err.code === 'ENOENT') {
return [packagePath]
}
throw new ErrorWithCause(`Unexpected error when finding a lockfile for '${packagePath}'`, { cause: err })
try {
pathStat = await stat(filePath)
} catch (err) {
if (isErrnoException(err) && err.code === 'ENOENT') {
return false
}
throw new ErrorWithCause('Error while checking if file exists', { cause: err })
}

throw new InputError(`Encountered a non-file at lockfile path '${lockfilePath}'`)
if (!pathStat.isFile()) {
throw new InputError(`Expected '${filePath}' to be a file`)
}

throw new InputError(`Expected '${packagePath}' to point to a package.json or package-lock.json or to a folder containing a package.json`)
return true
}
66 changes: 66 additions & 0 deletions lib/utils/socket-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { readFile } from 'node:fs/promises'

import Ajv from 'ajv'
import { ErrorWithCause } from 'pony-cause'
import { parse as yamlParse } from 'yaml'

import { isErrnoException } from './type-helpers.js'

/**
* @typedef SocketYml
* @property {2} version
* @property {string[]} [projectIgnorePaths]
* @property {{ [issueName: string]: boolean }} [issueRules]
*/

/** @type {import('ajv').JSONSchemaType<SocketYml>} */
const socketYmlSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
version: { type: 'integer' },
projectIgnorePaths: {
type: 'array',
items: { type: 'string' },
nullable: true,
},
issueRules: {
type: 'object',
additionalProperties: { type: 'boolean' },
nullable: true,
required: [],
},
},
required: ['version'],
additionalProperties: true,
}

/**
* @param {string} filePath
* @returns {Promise<SocketYml|undefined>}
*/
export async function readSocketConfig (filePath) {
/** @type {string} */
let fileContent

try {
fileContent = await readFile(filePath, 'utf8')
} catch (err) {
if (isErrnoException(err) && err.code === 'ENOENT') {
return
}
throw new ErrorWithCause('Error when reading socket.yml config file', { cause: err })
}

/** @type {unknown} */
let parsedContent

try {
parsedContent = yamlParse(fileContent)
} catch (err) {
throw new ErrorWithCause('Error when parsing socket.yml config', { cause: err })
}
if ((new Ajv()).validate(socketYmlSchema, parsedContent)) {
return parsedContent
}
}
Loading

0 comments on commit c9b03ea

Please sign in to comment.