Compare commits

...

5 Commits

Author SHA1 Message Date
Michal Dorner
ceb8a2b8f2 Update CHANGELOG.md for v4.0.3 and v3.0.4 (#327) 2026-08-05 14:05:15 +02:00
Michal Dorner
ef09b88f3e Document safe handling of file list outputs in workflows (#326)
Recommend passing *_files values through env: instead of interpolating them directly into run: scripts, and update README examples and CI workflows to follow that pattern.

Credits: https://github.com/tjswlsgg
2026-08-05 13:45:25 +02:00
Michal Dorner
44adc5b06d Merge commit from fork
fix: escape multi-line filenames in list-files shell and csv output
2026-08-05 12:02:19 +02:00
Pavel Kutáč
4711b7a31b feat: add 'some-with-excludes' predicate quantifier (#322) 2026-07-31 17:44:02 -04:00
Ken Matsui
93c889f9e5 fix: escape multi-line filenames in list-files shell and csv output
Fixes GHSA-7hc6-8hq5-9q2m
2026-07-30 23:48:19 -04:00
11 changed files with 423 additions and 50 deletions

View File

@@ -167,6 +167,43 @@ jobs:
if: steps.filter.outputs.local_count != 1
run: exit 1
test-predicate-quantifier-some-with-excludes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: modify working tree
run: |
mkdir -p mobile/.config
echo "TEST" > mobile/main.kt
echo "TEST" > mobile/README.md
echo "TEST" > mobile/.config/lint.json
echo "TEST" > backend.go
git add -A
- uses: ./
id: filter
with:
base: HEAD
list-files: shell
predicate-quantifier: 'some-with-excludes'
filters: |
mobile:
- 'mobile/**'
- '!mobile/**/*.md'
- '!mobile/.config/**'
excludesOnly:
- '!**/*.md'
- name: Print 'mobile_files'
env:
MOBILE_FILES: ${{ steps.filter.outputs.mobile_files }}
run: echo "$MOBILE_FILES"
- name: filter-test
if: |
steps.filter.outputs.mobile != 'true'
|| steps.filter.outputs.mobile_count != 1
|| steps.filter.outputs.mobile_files != 'mobile/main.kt'
|| steps.filter.outputs.excludesOnly != 'false'
run: exit 1
test-change-type:
runs-on: ubuntu-latest
steps:
@@ -192,11 +229,17 @@ jobs:
any:
- added|deleted|modified: "*"
- name: Print 'added_files'
run: echo ${{steps.filter.outputs.added_files}}
env:
ADDED_FILES: ${{ steps.filter.outputs.added_files }}
run: echo "$ADDED_FILES"
- name: Print 'modified_files'
run: echo ${{steps.filter.outputs.modified_files}}
env:
MODIFIED_FILES: ${{ steps.filter.outputs.modified_files }}
run: echo "$MODIFIED_FILES"
- name: Print 'deleted_files'
run: echo ${{steps.filter.outputs.deleted_files}}
env:
DELETED_FILES: ${{ steps.filter.outputs.deleted_files }}
run: echo "$DELETED_FILES"
- name: filter-test
if: |
steps.filter.outputs.added != 'true'

View File

@@ -1,5 +1,13 @@
# Changelog
## v4.0.3
- [Document safe handling of file list outputs in workflows](https://github.com/dorny/paths-filter/pull/326)
- [Escape multi-line filenames in list-files shell and csv output](https://github.com/advisories/GHSA-7hc6-8hq5-9q2m)
- [Add 'some-with-excludes' predicate quantifier](https://github.com/dorny/paths-filter/pull/322)
- [Add contents permission to PR example](https://github.com/dorny/paths-filter/pull/248)
- [Scope base-ignored warning to API path](https://github.com/dorny/paths-filter/pull/319)
- [Update outputs in readme to account for the 'every' predicate-quantifier](https://github.com/dorny/paths-filter/pull/247)
## v4.0.2
- [Work around git dubious ownership errors in container jobs](https://github.com/dorny/paths-filter/pull/317)
- [Use rev-parse instead of branch --show-current for older git compat](https://github.com/dorny/paths-filter/pull/303)
@@ -11,6 +19,9 @@
## v4.0.0
- [Update action runtime to node24](https://github.com/dorny/paths-filter/pull/294)
## v3.0.4
- [Escape multi-line filenames in list-files shell and csv output](https://github.com/advisories/GHSA-7hc6-8hq5-9q2m)
## v3.0.3
- [Add missing predicate-quantifier](https://github.com/dorny/paths-filter/pull/279)

View File

@@ -67,6 +67,10 @@ For more scenarios see [examples](#examples) section.
## Notes
- **Security:** `${FILTER_NAME}_files` outputs contain filenames that may be attacker-influenced on pull requests.
Do not interpolate them directly into a `run:` script with `${{ ... }}`.
Pass the value through `env:` and reference the variable from the shell instead.
See [Custom processing of changed files](#custom-processing-of-changed-files).
- Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library.
Documentation for path expression format can be found on the project GitHub page.
- Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true.
@@ -81,6 +85,7 @@ For more scenarios see [examples](#examples) section.
## What's New
- Add `some-with-excludes` value of the `predicate-quantifier` input parameter
- Automatic workaround for git `dubious ownership` errors in container jobs
- New major release `v4` after update to Node 24 [Breaking change]
- Add `ref` input parameter
@@ -172,14 +177,17 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
token: ''
# Optional parameter to override the default behavior of file matching algorithm.
# By default files that match at least one pattern defined by the filters will be included.
# This parameter allows to override the "at least one pattern" behavior to make it so that
# all of the patterns have to match or otherwise the file is excluded.
# Supported values:
# 'some' - File is included if it matches at least one pattern (default).
# 'every' - File is included only if it matches all of the patterns.
# 'some-with-excludes' - File is included if it matches at least one pattern
# and no negated pattern (the ones prefixed with '!').
#
# An example scenario where this is useful if you would like to match all
# .ts files in a sub-directory but not .md files.
# The filters below will match markdown files despite the exclusion syntax UNLESS
# you specify 'every' as the predicate-quantifier parameter. When you do that,
# it will only match the .ts files in the subdirectory as expected.
# you specify 'every' or 'some-with-excludes' as the predicate-quantifier parameter.
# When you do that, it will only match the .ts files in the subdirectory as expected.
#
# backend:
# - 'pkg/a/b/c/**'
@@ -197,8 +205,11 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
- With `predicate-quantifier: 'every'`:
- `'true'` - if **any** changed file matches **all** of the filter's rules
- `'false'` - if **no** changed file matches **all** of the filter's rules
- With `predicate-quantifier: 'some-with-excludes'`:
- `'true'` - if **any** changed file matches **at least one** of the filter's rules and **none** of its negated rules
- `'false'` - if **no** changed file matches **at least one** of the filter's rules and **none** of its negated rules
- Each filter sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter.
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter. Treat these values as untrusted when filenames can come from pull requests.
- `changes` - JSON array with names of all filters matching any of the changed files.
## Examples
@@ -533,6 +544,32 @@ jobs:
</details>
<details>
<summary>Detect changes in multiple unrelated paths and exclude some file extensions</summary>
```yaml
- uses: dorny/paths-filter@v4
id: filter
with:
# With 'some-with-excludes' a file is matched when it matches at least one pattern
# and none of the negated ones. The filter below therefore matches all the files
# in the 'mobile' folder and the workflow file, but never a markdown file or
# anything in 'mobile/.config'.
#
# An exclusion is final - a file excluded by one pattern can't be included back
# by another one. Consequently, a filter consisting of negated patterns only
# never matches anything.
predicate-quantifier: 'some-with-excludes'
filters: |
mobile:
- 'mobile/**'
- '!mobile/**/*.md'
- '!mobile/.config/**'
- '.github/workflows/test_mobile.yml'
```
</details>
### Custom processing of changed files
<details>
@@ -556,9 +593,13 @@ jobs:
- added|modified: '*.md'
- name: Lint Markdown
if: ${{ steps.filter.outputs.markdown == 'true' }}
run: npx textlint ${{ steps.filter.outputs.markdown_files }}
env:
MARKDOWN_FILES: ${{ steps.filter.outputs.markdown_files }}
run: npx textlint $MARKDOWN_FILES
```
When passing file lists to shell commands, use `env:` as shown above. Do not write `${{ steps.filter.outputs.markdown_files }}` directly inside the `run:` script.
</details>
<details>
@@ -584,6 +625,8 @@ jobs:
files: ${{ steps.filter.outputs.changed_files }}
```
The `json` and `csv` formats are intended as structured data for scripts, programs, or other actions. Passing them to an action input as above is fine. Do not interpolate `json` or `csv` outputs directly into a `run:` script.
</details>
## See also

View File

@@ -20,4 +20,12 @@ describe('csvEscape() backslash escapes every character except subset of definit
test('Double quote should be escaped by another double quote', () => {
expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"')
})
test('filename with LF should be quoted per RFC 4180', () => {
expect(csvEscape('a\nb')).toBe('"a\nb"')
})
test('filename with CRLF should be quoted per RFC 4180', () => {
expect(csvEscape('a\r\nb')).toBe('"a\r\nb"')
})
})

View File

@@ -148,6 +148,99 @@ describe('matching tests', () => {
expect(otherPkgJpegMatch.backend).toEqual([])
})
test('ignores exclusions when using the default predicate quantifier', () => {
const yaml = `
src:
- 'src/**'
- '!**/*.md'
`
const filter = new Filter(yaml)
// A negated pattern is just another pattern for the 'some' quantifier - a markdown file
// inside 'src' still matches 'src/**' and any other file matches the negated pattern.
const files = modified(['src/README.md', 'other/file.txt'])
expect(filter.match(files).src).toEqual(files)
})
test('matches files of every pattern when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
mobile:
- 'mobile/**'
- '!mobile/**/*.md'
- '!mobile/.config/**'
- '.github/workflows/test_mobile.yml'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const sourceFiles = modified(['mobile/main.kt', 'mobile/src/some/Activity.kt'])
const workflowFiles = modified(['.github/workflows/test_mobile.yml'])
const docsFiles = modified(['mobile/README.md', 'mobile/docs/some/page.md'])
const configFiles = modified(['mobile/.config/lint.json', 'mobile/.config/nested/lint.json'])
const otherFiles = modified(['backend/main.go', '.github/workflows/test_backend.yml'])
expect(filter.match(sourceFiles).mobile).toEqual(sourceFiles)
expect(filter.match(workflowFiles).mobile).toEqual(workflowFiles)
expect(filter.match(docsFiles).mobile).toEqual([])
expect(filter.match(configFiles).mobile).toEqual([])
expect(filter.match(otherFiles).mobile).toEqual([])
})
test('excludes file with PredicateQuantifier.SOME_WITH_EXCLUDES regardless of the pattern order', () => {
const yaml = `
excludeFirst:
- '!**/*.md'
- 'src/**'
excludeLast:
- 'src/**'
- '!**/*.md'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const match = filter.match(modified(['src/index.ts', 'src/README.md']))
expect(match.excludeFirst).toEqual(modified(['src/index.ts']))
expect(match.excludeLast).toEqual(modified(['src/index.ts']))
})
test('keeps file excluded with PredicateQuantifier.SOME_WITH_EXCLUDES even if a later pattern includes it', () => {
const yaml = `
src:
- 'src/**'
- '!**/*.md'
- 'src/docs/**'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const match = filter.match(modified(['src/docs/guide.md', 'src/docs/logo.png']))
expect(match.src).toEqual(modified(['src/docs/logo.png']))
})
test('matches nothing with PredicateQuantifier.SOME_WITH_EXCLUDES when there is no include pattern', () => {
const yaml = `
src:
- '!**/*.md'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const match = filter.match(modified(['src/index.ts', 'src/README.md']))
expect(match.src).toEqual([])
})
test('treats negated extglob as an include pattern with PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
backend:
- '!(**/*.tsx|**/*.less)'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
expect(filter.match(modified(['src/server.py'])).backend).toEqual(modified(['src/server.py']))
expect(filter.match(modified(['src/ui.tsx'])).backend).toEqual([])
})
test('matches path based on rules included using YAML anchor', () => {
const yaml = `
shared: &shared
@@ -197,6 +290,61 @@ describe('matching specific change status', () => {
expect(match.addOrModify).toEqual(files)
})
test('respects change status of exclude patterns when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
src:
- 'src/**'
- deleted: '!src/generated/**'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const files = [
{status: ChangeStatus.Deleted, filename: 'src/generated/api.ts'},
{status: ChangeStatus.Modified, filename: 'src/generated/api.ts'}
]
const match = filter.match(files)
expect(match.src).toEqual([files[1]])
})
test('matches multiple patterns of single change status when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
docs: &docs
- '!**/*.md'
src:
- added|modified: 'src/**'
- added|modified: *docs
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const files = [
{status: ChangeStatus.Added, filename: 'src/index.ts'},
{status: ChangeStatus.Added, filename: 'src/README.md'},
{status: ChangeStatus.Deleted, filename: 'src/legacy.ts'}
]
const match = filter.match(files)
expect(match.src).toEqual([files[0]])
})
test('or-es patterns of single change status when using the default predicate quantifier', () => {
const yaml = `
src:
- added|modified: ['src/**', '!**/*.md']
`
const filter = new Filter(yaml)
// Both patterns are OR-ed into a single rule, therefore a markdown file inside 'src'
// matches through 'src/**' and any other file matches through the negated pattern.
const files = [
{status: ChangeStatus.Added, filename: 'src/README.md'},
{status: ChangeStatus.Added, filename: 'other/file.txt'},
{status: ChangeStatus.Deleted, filename: 'src/index.ts'}
]
const match = filter.match(files)
expect(match.src).toEqual([files[0], files[1]])
})
test('matches when using an anchor', () => {
const yaml = `
shared: &shared

View File

@@ -54,4 +54,16 @@ describe('shellEscape() returns human readable filenames with as few escaping ap
test('filename with single quote and special characters is split and quoted/escaped as needed', () => {
expect(shellEscape("file'with $quote")).toBe("file\\''with $quote'")
})
test('filename with LF should be single-quoted', () => {
expect(shellEscape('x\ntouch pwned.md')).toBe("'x\ntouch pwned.md'")
})
test('filename with CRLF should be single-quoted', () => {
expect(shellEscape('x\r\ntouch pwned.md')).toBe("'x\r\ntouch pwned.md'")
})
test('filename with CR should be single-quoted', () => {
expect(shellEscape('a\rb')).toBe("'a\rb'")
})
})

View File

@@ -46,7 +46,11 @@ inputs:
default: '100'
predicate-quantifier:
description: |
allows to override the "at least one pattern" behavior to make it so that all of the patterns have to match or otherwise the file is excluded.
allows to override the "at least one pattern" behavior:
'some' - file is included if it matches at least one pattern (default).
'every' - file is included only if it matches all of the patterns.
'some-with-excludes' - file is included if it matches at least one pattern
and no negated pattern (the ones prefixed with '!').
required: false
default: 'some'
outputs:

86
dist/index.js vendored
View File

@@ -86,6 +86,16 @@ var PredicateQuantifier;
* specify anything as a predicate quantifier.
*/
PredicateQuantifier["SOME"] = "some";
/**
* When choosing 'some-with-excludes' in the config it means that files will get matched if
* at least one of the patterns matches them and none of the negated patterns (the ones
* prefixed with '!') matches them. An exclusion is final - a file excluded by one pattern
* can't be included back by another one.
*
* A filter which consists of negated patterns only never matches anything,
* because there is no pattern which could include a file in the first place.
*/
PredicateQuantifier["SOME_WITH_EXCLUDES"] = "some-with-excludes";
})(PredicateQuantifier || (exports.PredicateQuantifier = PredicateQuantifier = {}));
/**
* An array of strings (at runtime) that contains the valid/accepted values for
@@ -126,15 +136,35 @@ class Filter {
return result;
}
isMatch(file, patterns) {
var _a;
const aPredicate = (rule) => {
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename);
var _a, _b, _c;
const isStatusMatch = (rule) => {
return rule.status === undefined || rule.status.includes(file.status);
};
if (((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) === 'every') {
return patterns.every(aPredicate);
}
else {
return patterns.some(aPredicate);
const aPredicate = (rule) => {
return isStatusMatch(rule) && rule.isMatch(file.filename);
};
switch ((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) {
case PredicateQuantifier.EVERY:
return patterns.every(aPredicate);
case PredicateQuantifier.SOME_WITH_EXCLUDES: {
let isIncluded = false;
for (const rule of patterns) {
if (!isStatusMatch(rule)) {
continue;
}
// Once a file is excluded it stays excluded - no other pattern can include it back.
// Therefore all the patterns have to be evaluated even if the file is already included.
if ((_b = rule.isExclude) === null || _b === void 0 ? void 0 : _b.call(rule, file.filename)) {
return false;
}
if (!isIncluded && ((_c = rule.isInclude) === null || _c === void 0 ? void 0 : _c.call(rule, file.filename))) {
isIncluded = true;
}
}
return isIncluded;
}
default:
return patterns.some(aPredicate);
}
}
parseFilterItemYaml(item) {
@@ -142,21 +172,19 @@ class Filter {
return flat(item.map(i => this.parseFilterItemYaml(i)));
}
if (typeof item === 'string') {
return [{ status: undefined, isMatch: (0, picomatch_1.default)(item, MatchOptions) }];
return [createRuleItem(item)];
}
if (typeof item === 'object') {
return Object.entries(item).map(([key, pattern]) => {
if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) {
this.throwInvalidFormatError(`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`);
}
return {
status: key
.split('|')
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase()),
isMatch: (0, picomatch_1.default)(pattern, MatchOptions)
};
const status = key
.split('|')
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase());
return createRuleItem(pattern, status);
});
}
this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`);
@@ -171,6 +199,24 @@ exports.Filter = Filter;
function flat(arr) {
return arr.reduce((acc, val) => acc.concat(val), []);
}
// Compiles filename pattern(s) of a single filter rule item into matchers.
// Multiple patterns are OR-ed together, which is how picomatch treats an array of globs.
// Patterns are also split by their polarity, so PredicateQuantifier.SOME_WITH_EXCLUDES
// can tell inclusions from exclusions. Note that only a leading '!' negates the whole
// pattern - the '!(...)' extglob is a regular pattern matching everything it doesn't enumerate.
function createRuleItem(patterns, status) {
const matchers = (Array.isArray(patterns) ? patterns : [patterns]).map(pattern => (0, picomatch_1.default)(pattern, MatchOptions, true));
// picomatch inverts the result of a matcher created from a negated pattern.
// Inverting it back gives a matcher of the filenames such pattern excludes.
const includes = matchers.filter(matcher => !matcher.state.negated);
const excludes = matchers.filter(matcher => matcher.state.negated);
return {
status,
isMatch: str => matchers.some(matcher => matcher(str)),
isInclude: includes.length > 0 ? str => includes.some(matcher => matcher(str)) : undefined,
isExclude: excludes.length > 0 ? str => excludes.some(matcher => !matcher(str)) : undefined
};
}
/***/ }),
@@ -501,7 +547,7 @@ function csvEscape(value) {
if (value === '')
return value;
// Only safe characters
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
if (/^[a-zA-Z0-9._+:@%/-]+$/.test(value)) {
return value;
}
// https://tools.ietf.org/html/rfc4180
@@ -533,12 +579,12 @@ function shellEscape(value) {
if (value === '')
return value;
// Only safe characters
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
if (/^[a-zA-Z0-9,._+:@%/-]+$/.test(value)) {
return value;
}
if (value.includes("'")) {
// Only safe characters, single quotes and white-spaces
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/.test(value)) {
return `"${value}"`;
}
// Split by single quote and apply escaping recursively

View File

@@ -21,6 +21,12 @@ const MatchOptions = {
interface FilterRuleItem {
status?: ChangeStatus[] // Required change status of the matched files
isMatch: (str: string) => boolean // Matches the filename
// Matchers for the individual polarities of the patterns this item was created from.
// They are used only by the PredicateQuantifier.SOME_WITH_EXCLUDES quantifier,
// which has to tell inclusions and exclusions apart. The other quantifiers keep
// evaluating the item as a whole via 'isMatch'.
isInclude?: (str: string) => boolean // Matches any of the patterns which are not negated
isExclude?: (str: string) => boolean // Matches any of the patterns which are negated (e.g. '!**/*.md')
}
/**
@@ -47,7 +53,17 @@ export enum PredicateQuantifier {
* at least one pattern that matches them. This is the default behavior if you don't
* specify anything as a predicate quantifier.
*/
SOME = 'some'
SOME = 'some',
/**
* When choosing 'some-with-excludes' in the config it means that files will get matched if
* at least one of the patterns matches them and none of the negated patterns (the ones
* prefixed with '!') matches them. An exclusion is final - a file excluded by one pattern
* can't be included back by another one.
*
* A filter which consists of negated patterns only never matches anything,
* because there is no pattern which could include a file in the first place.
*/
SOME_WITH_EXCLUDES = 'some-with-excludes'
}
/**
@@ -104,13 +120,35 @@ export class Filter {
}
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
const aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
const isStatusMatch = (rule: Readonly<FilterRuleItem>): boolean => {
return rule.status === undefined || rule.status.includes(file.status)
}
if (this.filterConfig?.predicateQuantifier === 'every') {
return patterns.every(aPredicate)
} else {
return patterns.some(aPredicate)
const aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
return isStatusMatch(rule) && rule.isMatch(file.filename)
}
switch (this.filterConfig?.predicateQuantifier) {
case PredicateQuantifier.EVERY:
return patterns.every(aPredicate)
case PredicateQuantifier.SOME_WITH_EXCLUDES: {
let isIncluded = false
for (const rule of patterns) {
if (!isStatusMatch(rule)) {
continue
}
// Once a file is excluded it stays excluded - no other pattern can include it back.
// Therefore all the patterns have to be evaluated even if the file is already included.
if (rule.isExclude?.(file.filename)) {
return false
}
if (!isIncluded && rule.isInclude?.(file.filename)) {
isIncluded = true
}
}
return isIncluded
}
default:
return patterns.some(aPredicate)
}
}
@@ -120,7 +158,7 @@ export class Filter {
}
if (typeof item === 'string') {
return [{status: undefined, isMatch: picomatch(item, MatchOptions)}]
return [createRuleItem(item)]
}
if (typeof item === 'object') {
@@ -130,14 +168,12 @@ export class Filter {
`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
)
}
return {
status: key
.split('|')
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase()) as ChangeStatus[],
isMatch: picomatch(pattern, MatchOptions)
}
const status = key
.split('|')
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase()) as ChangeStatus[]
return createRuleItem(pattern, status)
})
}
@@ -154,3 +190,25 @@ export class Filter {
function flat<T>(arr: T[][]): T[] {
return arr.reduce((acc, val) => acc.concat(val), [])
}
// Compiles filename pattern(s) of a single filter rule item into matchers.
// Multiple patterns are OR-ed together, which is how picomatch treats an array of globs.
// Patterns are also split by their polarity, so PredicateQuantifier.SOME_WITH_EXCLUDES
// can tell inclusions from exclusions. Note that only a leading '!' negates the whole
// pattern - the '!(...)' extglob is a regular pattern matching everything it doesn't enumerate.
function createRuleItem(patterns: string | string[], status?: ChangeStatus[]): FilterRuleItem {
const matchers = (Array.isArray(patterns) ? patterns : [patterns]).map(pattern =>
picomatch(pattern, MatchOptions, true)
)
// picomatch inverts the result of a matcher created from a negated pattern.
// Inverting it back gives a matcher of the filenames such pattern excludes.
const includes = matchers.filter(matcher => !matcher.state.negated)
const excludes = matchers.filter(matcher => matcher.state.negated)
return {
status,
isMatch: str => matchers.some(matcher => matcher(str)),
isInclude: includes.length > 0 ? str => includes.some(matcher => matcher(str)) : undefined,
isExclude: excludes.length > 0 ? str => excludes.some(matcher => !matcher(str)) : undefined
}
}

View File

@@ -4,7 +4,7 @@ export function csvEscape(value: string): string {
if (value === '') return value
// Only safe characters
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
if (/^[a-zA-Z0-9._+:@%/-]+$/.test(value)) {
return value
}

View File

@@ -9,13 +9,13 @@ export function shellEscape(value: string): string {
if (value === '') return value
// Only safe characters
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
if (/^[a-zA-Z0-9,._+:@%/-]+$/.test(value)) {
return value
}
if (value.includes("'")) {
// Only safe characters, single quotes and white-spaces
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/.test(value)) {
return `"${value}"`
}