ldapts
Version:
LDAP client
394 lines (334 loc) • 11.5 kB
text/typescript
import type { BerReader } from 'asn1';
import type { ExtensibleFilterOptions } from './filters/ExtensibleFilter.js';
import type { Filter } from './filters/Filter.js';
import {
AndFilter,
ApproximateFilter,
EqualityFilter,
ExtensibleFilter,
GreaterThanEqualsFilter,
LessThanEqualsFilter,
NotFilter,
OrFilter,
PresenceFilter,
SubstringFilter,
} from './filters/index.js';
import { SearchFilter } from './SearchFilter.js';
interface ParseStringResult {
end: number;
filter: Filter;
}
interface Substring {
initial: string;
any: string[];
final: string;
}
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class FilterParser {
public static parseString(filterString: string): Filter {
if (!filterString) {
throw new Error('Filter cannot be empty');
}
// Wrap input in parens if it wasn't already
if (!filterString.startsWith('(')) {
filterString = `(${filterString})`;
}
const parseResult = FilterParser._parseString(filterString, 0, filterString);
const end = filterString.length - 1;
if (parseResult.end < end) {
throw new Error(`Unbalanced parens in filter string: ${filterString}`);
}
return parseResult.filter;
}
/*
* A filter looks like this coming in:
* Filter ::= CHOICE {
* and [0] SET OF Filter,
* or [1] SET OF Filter,
* not [2] Filter,
* equalityMatch [3] AttributeValueAssertion,
* substrings [4] SubstringFilter,
* greaterOrEqual [5] AttributeValueAssertion,
* lessOrEqual [6] AttributeValueAssertion,
* present [7] AttributeType,
* approxMatch [8] AttributeValueAssertion,
* extensibleMatch [9] MatchingRuleAssertion --v3 only
* }
*
* SubstringFilter ::= SEQUENCE {
* type AttributeType,
* SEQUENCE OF CHOICE {
* initial [0] IA5String,
* any [1] IA5String,
* final [2] IA5String
* }
* }
*
* The extensibleMatch was added in LDAPv3:
*
* MatchingRuleAssertion ::= SEQUENCE {
* matchingRule [1] MatchingRuleID OPTIONAL,
* type [2] AttributeDescription OPTIONAL,
* matchValue [3] AssertionValue,
* dnAttributes [4] BOOLEAN DEFAULT FALSE
* }
*/
public static parse(reader: BerReader): Filter {
const type: number | null = reader.readSequence();
let filter: Filter;
switch (type) {
case SearchFilter.and: {
const andFilters = FilterParser._parseSet(reader);
filter = new AndFilter({
filters: andFilters,
});
break;
}
case SearchFilter.approxMatch:
filter = new ApproximateFilter();
filter.parse(reader);
break;
case SearchFilter.equalityMatch:
filter = new EqualityFilter();
filter.parse(reader);
break;
case SearchFilter.extensibleMatch:
filter = new ExtensibleFilter();
filter.parse(reader);
break;
case SearchFilter.greaterOrEqual:
filter = new GreaterThanEqualsFilter();
filter.parse(reader);
break;
case SearchFilter.lessOrEqual:
filter = new LessThanEqualsFilter();
filter.parse(reader);
break;
case SearchFilter.not: {
const innerFilter = FilterParser.parse(reader);
filter = new NotFilter({
filter: innerFilter,
});
break;
}
case SearchFilter.or: {
const orFilters = FilterParser._parseSet(reader);
filter = new OrFilter({
filters: orFilters,
});
break;
}
case SearchFilter.present:
filter = new PresenceFilter();
filter.parse(reader);
break;
case SearchFilter.substrings:
filter = new SubstringFilter();
filter.parse(reader);
break;
default:
throw new Error(`Invalid search filter type: 0x${type ?? '<null>'}`);
}
return filter;
}
private static _parseString(filterString: string, start: number, fullString: string): ParseStringResult {
let cursor = start;
const { length } = filterString;
let filter: Filter;
if (filterString[cursor] !== '(') {
throw new Error(`Missing paren: ${filterString}. Full string: ${fullString}`);
}
cursor += 1;
switch (filterString[cursor]) {
case '&': {
cursor += 1;
const children: Filter[] = [];
do {
const childResult = FilterParser._parseString(filterString, cursor, fullString);
children.push(childResult.filter);
cursor = childResult.end + 1;
} while (cursor < length && filterString[cursor] !== ')');
filter = new AndFilter({
filters: children,
});
break;
}
case '|': {
cursor += 1;
const children: Filter[] = [];
do {
const childResult = FilterParser._parseString(filterString, cursor, fullString);
children.push(childResult.filter);
cursor = childResult.end + 1;
} while (cursor < length && filterString[cursor] !== ')');
filter = new OrFilter({
filters: children,
});
break;
}
case '!': {
const childResult = FilterParser._parseString(filterString, cursor + 1, fullString);
filter = new NotFilter({
filter: childResult.filter,
});
cursor = childResult.end + 1;
break;
}
default: {
const end = filterString.indexOf(')', cursor);
if (end === -1) {
throw new Error(`Unbalanced parens: ${filterString}. Full string: ${fullString}`);
}
filter = FilterParser._parseExpressionFilterFromString(filterString.substring(cursor, end));
cursor = end;
}
}
return {
end: cursor,
filter,
};
}
private static _parseExpressionFilterFromString(filterString: string): Filter {
let attribute: string;
let remainingExpression: string;
if (filterString.startsWith(':')) {
// An extensible filter can have no attribute name (Only valid when using dn and * matching-rule evaluation)
attribute = '';
remainingExpression = filterString;
} else {
const matches = /^[\w-]+/.exec(filterString);
if (matches?.length) {
[attribute] = matches;
remainingExpression = filterString.slice(attribute.length);
} else {
throw new Error(`Invalid attribute name: ${filterString}`);
}
}
if (remainingExpression === '=*') {
return new PresenceFilter({
attribute,
});
}
if (remainingExpression.startsWith('=')) {
remainingExpression = remainingExpression.slice(1);
if (remainingExpression.includes('*')) {
const escapedExpression = FilterParser._unescapeSubstring(remainingExpression);
return new SubstringFilter({
attribute,
initial: escapedExpression.initial,
any: escapedExpression.any,
final: escapedExpression.final,
});
}
return new EqualityFilter({
attribute,
value: FilterParser._unescapeHexValues(remainingExpression),
});
}
if (remainingExpression.startsWith('>') && remainingExpression[1] === '=') {
return new GreaterThanEqualsFilter({
attribute,
value: FilterParser._unescapeHexValues(remainingExpression.slice(2)),
});
}
if (remainingExpression.startsWith('<') && remainingExpression[1] === '=') {
return new LessThanEqualsFilter({
attribute,
value: FilterParser._unescapeHexValues(remainingExpression.slice(2)),
});
}
if (remainingExpression.startsWith('~') && remainingExpression[1] === '=') {
return new ApproximateFilter({
attribute,
value: FilterParser._unescapeHexValues(remainingExpression.slice(2)),
});
}
if (remainingExpression.startsWith(':')) {
return FilterParser._parseExtensibleFilterFromString(attribute, remainingExpression);
}
throw new Error(`Invalid expression: ${filterString}`);
}
private static _parseExtensibleFilterFromString(attribute: string, filterString: string): ExtensibleFilter {
let dnAttributes = false;
let rule: string | undefined;
const fields = filterString.split(':');
if (fields.length <= 1) {
throw new Error(`Invalid extensible filter: ${filterString}`);
}
// Remove first entry, since it should be empty
fields.shift();
if (fields[0]?.toLowerCase() === 'dn') {
dnAttributes = true;
fields.shift();
}
if (fields.length && !fields[0]?.startsWith('=')) {
rule = fields.shift();
}
if (fields.length && !fields[0]?.startsWith('=')) {
throw new Error(`Missing := in extensible filter: ${filterString}`);
}
// Trim the leading = (from the :=) and reinsert any extra ':' characters
const remainingExpression = fields.join(':').slice(1);
const options: ExtensibleFilterOptions = {
matchType: attribute,
dnAttributes,
rule,
value: FilterParser._unescapeHexValues(remainingExpression),
};
// TODO: Enable this if it's useful
// if (remainingExpression.indexOf('*') !== -1) {
// const substring = FilterParser._escapeSubstring(remainingExpression);
// options.initial = substring.initial;
// options.any = substring.any;
// options.final = substring.final;
// }
return new ExtensibleFilter(options);
}
private static _unescapeHexValues(input: string): string {
let index = 0;
const end = input.length;
let result = '';
while (index < end) {
const char = input[index];
switch (char) {
case '(':
throw new Error(`Illegal unescaped character: ${char} in value: ${input}`);
case '\\': {
const value = input.slice(index + 1, index + 3);
if (/^[\dA-Fa-f]{2}$/.exec(value) === null) {
throw new Error(`Invalid escaped hex character: ${value} in value: ${input}`);
}
result += String.fromCharCode(Number.parseInt(value, 16));
index += 3;
break;
}
default:
if (char) {
result += char;
}
index += 1;
break;
}
}
return result;
}
private static _unescapeSubstring(input: string): Substring {
const fields = input.split('*');
if (fields.length < 2) {
throw new Error(`Wildcard missing: ${input}`);
}
return {
initial: FilterParser._unescapeHexValues(fields.shift() ?? ''),
final: FilterParser._unescapeHexValues(fields.pop() ?? ''),
any: fields.map((field) => FilterParser._unescapeHexValues(field)),
};
}
private static _parseSet(reader: BerReader): Filter[] {
const filters: Filter[] = [];
const end = reader.offset + reader.length;
while (reader.offset < end) {
filters.push(FilterParser.parse(reader));
}
return filters;
}
}