diff --git a/README.md b/README.md index 780574a8..7902f3c7 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/ # Enables listing of files matching the filter: # 'none' - Disables listing of matching files (default). + # 'csv' - Coma separated list of filenames. + # If needed it uses double quotes to wrap filename with unsafe characters. # 'json' - Matching files paths are formatted as JSON array. # 'shell' - Space delimited list usable as command line argument list in Linux shell. # If needed it uses single or double quotes to wrap filename with unsafe characters. diff --git a/__tests__/csv-escape.test.ts b/__tests__/csv-escape.test.ts new file mode 100644 index 00000000..f6dc6df4 --- /dev/null +++ b/__tests__/csv-escape.test.ts @@ -0,0 +1,23 @@ +import {csvEscape} from '../src/list-format/csv-escape' + +describe('csvEscape() backslash escapes every character except subset of definitely safe characters', () => { + test('simple filename should not be modified', () => { + expect(csvEscape('file.txt')).toBe('file.txt') + }) + + test('directory separator should be preserved and not escaped', () => { + expect(csvEscape('path/to/file.txt')).toBe('path/to/file.txt') + }) + + test('filename with spaces should be quoted', () => { + expect(csvEscape('file with space')).toBe('"file with space"') + }) + + test('filename with "," should be quoted', () => { + expect(csvEscape('file, with coma')).toBe('"file, with coma"') + }) + + test('Double quote should be escaped by another double quote', () => { + expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"') + }) +}) diff --git a/__tests__/shell-escape.test.ts b/__tests__/shell-escape.test.ts index e706dfbb..ece4c37f 100644 --- a/__tests__/shell-escape.test.ts +++ b/__tests__/shell-escape.test.ts @@ -1,24 +1,24 @@ -import {escape, shellEscape} from '../src/shell-escape' +import {backslashEscape, shellEscape} from '../src/list-format/shell-escape' describe('escape() backslash escapes every character except subset of definitely safe characters', () => { test('simple filename should not be modified', () => { - expect(escape('file.txt')).toBe('file.txt') + expect(backslashEscape('file.txt')).toBe('file.txt') }) test('directory separator should be preserved and not escaped', () => { - expect(escape('path/to/file.txt')).toBe('path/to/file.txt') + expect(backslashEscape('path/to/file.txt')).toBe('path/to/file.txt') }) test('spaces should be escaped with backslash', () => { - expect(escape('file with space')).toBe('file\\ with\\ space') + expect(backslashEscape('file with space')).toBe('file\\ with\\ space') }) test('quotes should be escaped with backslash', () => { - expect(escape('file\'with quote"')).toBe('file\\\'with\\ quote\\"') + expect(backslashEscape('file\'with quote"')).toBe('file\\\'with\\ quote\\"') }) test('$variables should be escaped', () => { - expect(escape('$var')).toBe('\\$var') + expect(backslashEscape('$var')).toBe('\\$var') }) }) diff --git a/action.yml b/action.yml index 41c15ba9..894b1149 100644 --- a/action.yml +++ b/action.yml @@ -22,6 +22,8 @@ inputs: description: | Enables listing of files matching the filter: 'none' - Disables listing of matching files (default). + 'csv' - Coma separated list of filenames. + If needed it uses double quotes to wrap filename with unsafe characters. 'json' - Serialized as JSON array. 'shell' - Space delimited list usable as command line argument list in linux shell. If needed it uses single or double quotes to wrap filename with unsafe characters. diff --git a/dist/index.js b/dist/index.js index 35a3f9e5..fcf0faae 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4648,7 +4648,8 @@ const github = __importStar(__webpack_require__(469)); const filter_1 = __webpack_require__(235); const file_1 = __webpack_require__(258); const git = __importStar(__webpack_require__(136)); -const shell_escape_1 = __webpack_require__(751); +const shell_escape_1 = __webpack_require__(206); +const csv_escape_1 = __webpack_require__(410); async function run() { try { const workingDirectory = core.getInput('working-directory', { required: false }); @@ -4825,10 +4826,12 @@ function exportResults(results, format) { function serializeExport(files, format) { const fileNames = files.map(file => file.filename); switch (format) { + case 'csv': + return fileNames.map(csv_escape_1.csvEscape).join(','); case 'json': return JSON.stringify(fileNames); case 'escape': - return fileNames.map(shell_escape_1.escape).join(' '); + return fileNames.map(shell_escape_1.backslashEscape).join(' '); case 'shell': return fileNames.map(shell_escape_1.shellEscape).join(' '); default: @@ -4836,7 +4839,7 @@ function serializeExport(files, format) { } } function isExportFormat(value) { - return value === 'none' || value === 'shell' || value === 'json' || value === 'escape'; + return ['none', 'csv', 'shell', 'json', 'escape'].includes(value); } run(); @@ -5028,6 +5031,43 @@ module.exports = { }; +/***/ }), + +/***/ 206: +/***/ (function(__unusedmodule, exports) { + +"use strict"; + +Object.defineProperty(exports, "__esModule", { value: true }); +exports.shellEscape = exports.backslashEscape = void 0; +// Backslash escape every character except small subset of definitely safe characters +function backslashEscape(value) { + return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1'); +} +exports.backslashEscape = backslashEscape; +// Returns filename escaped for usage as shell argument. +// Applies "human readable" approach with as few escaping applied as possible +function shellEscape(value) { + if (value === '') + return value; + // Only safe characters + if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) { + return value; + } + if (value.includes("'")) { + // Only safe characters, single quotes and white-spaces + if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) { + return `"${value}"`; + } + // Split by single quote and apply escaping recursively + return value.split("'").map(shellEscape).join("\\'"); + } + // Contains some unsafe characters but no single quote + return `'${value}'`; +} +exports.shellEscape = shellEscape; + + /***/ }), /***/ 211: @@ -8813,6 +8853,33 @@ function Octokit(plugins, options) { } +/***/ }), + +/***/ 410: +/***/ (function(__unusedmodule, exports) { + +"use strict"; + +Object.defineProperty(exports, "__esModule", { value: true }); +exports.csvEscape = void 0; +// Returns filename escaped for CSV +// Wraps file name into "..." only when it contains some potentially unsafe character +function csvEscape(value) { + if (value === '') + return value; + // Only safe characters + if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) { + return value; + } + // https://tools.ietf.org/html/rfc4180 + // If double-quotes are used to enclose fields, then a double-quote + // appearing inside a field must be escaped by preceding it with + // another double quote + return `"${value.replace(/"/g, '""')}"`; +} +exports.csvEscape = csvEscape; + + /***/ }), /***/ 413: @@ -15225,43 +15292,6 @@ function sync (path, options) { module.exports = require("fs"); -/***/ }), - -/***/ 751: -/***/ (function(__unusedmodule, exports) { - -"use strict"; - -Object.defineProperty(exports, "__esModule", { value: true }); -exports.shellEscape = exports.escape = void 0; -// Backslash escape every character except small subset of definitely safe characters -function escape(value) { - return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1'); -} -exports.escape = escape; -// Returns filename escaped for usage as shell argument. -// Applies "human readable" approach with as few escaping applied as possible -function shellEscape(value) { - if (value === '') - return value; - // Only safe characters - if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) { - return value; - } - if (value.includes("'")) { - // Only safe characters, single quotes and white-spaces - if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) { - return `"${value}"`; - } - // Split by single quote and apply escaping recursively - return value.split("'").map(shellEscape).join("\\'"); - } - // Contains some unsafe characters but no single quote - return `'${value}'`; -} -exports.shellEscape = shellEscape; - - /***/ }), /***/ 753: diff --git a/src/list-format/csv-escape.ts b/src/list-format/csv-escape.ts new file mode 100644 index 00000000..262cf2d9 --- /dev/null +++ b/src/list-format/csv-escape.ts @@ -0,0 +1,16 @@ +// Returns filename escaped for CSV +// Wraps file name into "..." only when it contains some potentially unsafe character +export function csvEscape(value: string): string { + if (value === '') return value + + // Only safe characters + if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) { + return value + } + + // https://tools.ietf.org/html/rfc4180 + // If double-quotes are used to enclose fields, then a double-quote + // appearing inside a field must be escaped by preceding it with + // another double quote + return `"${value.replace(/"/g, '""')}"` +} diff --git a/src/shell-escape.ts b/src/list-format/shell-escape.ts similarity index 93% rename from src/shell-escape.ts rename to src/list-format/shell-escape.ts index 25bb940e..1a3e3c95 100644 --- a/src/shell-escape.ts +++ b/src/list-format/shell-escape.ts @@ -1,5 +1,5 @@ // Backslash escape every character except small subset of definitely safe characters -export function escape(value: string): string { +export function backslashEscape(value: string): string { return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1') } diff --git a/src/main.ts b/src/main.ts index 3e9522fe..a54776f1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,9 +6,10 @@ import {Webhooks} from '@octokit/webhooks' import {Filter, FilterResults} from './filter' import {File, ChangeStatus} from './file' import * as git from './git' -import {escape, shellEscape} from './shell-escape' +import {backslashEscape, shellEscape} from './list-format/shell-escape' +import {csvEscape} from './list-format/csv-escape' -type ExportFormat = 'none' | 'json' | 'shell' | 'escape' +type ExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape' async function run(): Promise { try { @@ -210,10 +211,12 @@ function exportResults(results: FilterResults, format: ExportFormat): void { function serializeExport(files: File[], format: ExportFormat): string { const fileNames = files.map(file => file.filename) switch (format) { + case 'csv': + return fileNames.map(csvEscape).join(',') case 'json': return JSON.stringify(fileNames) case 'escape': - return fileNames.map(escape).join(' ') + return fileNames.map(backslashEscape).join(' ') case 'shell': return fileNames.map(shellEscape).join(' ') default: @@ -222,7 +225,7 @@ function serializeExport(files: File[], format: ExportFormat): string { } function isExportFormat(value: string): value is ExportFormat { - return value === 'none' || value === 'shell' || value === 'json' || value === 'escape' + return ['none', 'csv', 'shell', 'json', 'escape'].includes(value) } run()