1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Finish Basic Handling

This commit is contained in:
Justin Baur
2025-03-20 12:41:30 -04:00
parent ddcb0caac9
commit ed3990b89f
3 changed files with 354 additions and 38 deletions

View File

@@ -5,59 +5,185 @@ import { BasicFilter, BasicVaultFilterHandler } from "./basic-vault-filter.handl
describe("BasicVaultFilterHandler", () => {
const sut = new BasicVaultFilterHandler(mock());
describe("tryParse", () => {
it("success", () => {
const result = sut.tryParse(
'(in:collection:"My Collection" OR in:collection:"Other Collection")',
);
if (!result.success) {
fail("Result is expected to succeed");
}
expect(result.filter).toBe({});
});
});
describe("toFilter", () => {
const cases: { input: BasicFilter; output: string }[] = [
{
input: {
vaults: [null, "org_vault"],
collections: ["collection_one", "collection_two"],
fields: ["one", "two"],
types: ["Login", "Card"],
folders: ["folder_one", "folder_two"],
},
output:
'(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" AND in:collection:"collection_two") AND (type:"Login" OR type:"Card") AND (field:"one" AND field:"two")',
const successfulCases: { basicFilter: BasicFilter; rawFilter: string }[] = [
{
basicFilter: {
vaults: [null, "org_vault"],
collections: ["collection_one", "collection_two"],
fields: [],
types: ["Login", "Card"],
folders: ["folder_one", "folder_two"],
},
rawFilter:
'(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" AND in:collection:"collection_two") AND (type:"Login" OR type:"Card")',
},
{
basicFilter: {
vaults: [null, "org_one"],
collections: [],
fields: [],
types: [],
folders: [],
},
rawFilter: `(in:my_vault OR in:org:"org_one")`,
},
{
basicFilter: {
vaults: [],
collections: ["collection_one", "Collection two"],
fields: [],
types: [],
folders: [],
},
rawFilter: '(in:collection:"collection_one" AND in:collection:"Collection two")',
},
{
basicFilter: {
vaults: [],
collections: [],
fields: [],
types: ["Card", "Login"],
folders: [],
},
rawFilter: '(type:"Card" OR type:"Login")',
},
{
basicFilter: {
vaults: [],
collections: [],
fields: [],
types: [],
folders: ["folder_one", "Folder two"],
},
rawFilter: '(in:folder:"folder_one" OR in:folder:"Folder two")',
},
{
basicFilter: {
vaults: [],
collections: [],
fields: [],
types: ["Card", "Login"],
folders: ["folder_one", "Folder two"],
},
rawFilter:
'(in:folder:"folder_one" OR in:folder:"Folder two") AND (type:"Card" OR type:"Login")',
},
{
// Example of a filter that we could pretty
basicFilter: {
vaults: [null],
collections: [],
fields: [],
types: [],
folders: [],
},
rawFilter: "(in:my_vault)",
},
];
describe("tryParse", () => {
it.each(successfulCases)(
"successfully parses $rawFilter query into basic filter",
({ basicFilter, rawFilter }, done) => {
const result = sut.tryParse(rawFilter);
if (!result.success) {
done("Result is expected to succeed");
return;
}
expect(result.filter).toEqual(basicFilter);
done();
},
);
// These are cases where they are parsable but they would never be generated this way via the normal basic
const extraAllowedSyntax: { basicFilter: BasicFilter; rawFilter: string }[] = [
{
input: {
basicFilter: {
vaults: [null],
collections: [],
fields: [],
types: [],
folders: [],
},
output: "(in:my_vault)",
rawFilter: "in:my_vault",
},
{
input: {
basicFilter: {
vaults: [],
collections: [],
fields: ["Banking"],
collections: ["my_collection"],
fields: [],
types: [],
folders: [],
},
output: '(field:"Banking")',
rawFilter: 'in:collection:"my_collection"',
},
{
basicFilter: {
vaults: [],
collections: [],
fields: [],
types: ["Login"],
folders: [],
},
rawFilter: 'type:"Login"',
},
{
basicFilter: {
vaults: [],
collections: [],
fields: [],
types: [],
folders: ["my_folder"],
},
rawFilter: 'in:folder:"my_folder"',
},
];
it.each(cases)("translates basic filter to $output", ({ input, output }) => {
const actualOutput = sut.toFilter(input);
it.each(extraAllowedSyntax)(
"allows parsing of filter $rawFilter",
({ basicFilter, rawFilter }, done) => {
const result = sut.tryParse(rawFilter);
expect(actualOutput).toEqual(output);
if (!result.success) {
done("Result is expected to succeed");
return;
}
expect(result.filter).toEqual(basicFilter);
done();
},
);
const unrepresentableInBasic: string[] = [
// We use OR for folders
'(in:folder:"folder_one" AND in:folder:"Folder two")',
// We use OR for vaults
'(in:my_vault AND in:org:"Org one")',
// We use AND for collections but we could offer the selection and in the case this could be valid too
'(in:collection:"Collection one" OR in:collection:"Collection two")',
// We use OR for type
'(type:"Login" AND type:"Card")',
// We wouldn't put the same expression in multiple groups - This is a place where we could get smarter
'(type:"Login") AND (type:"Card")',
];
it.each(unrepresentableInBasic)("does not succeed when filter is %s", (filter) => {
const result = sut.tryParse(filter);
expect(result.success).toBe(false);
});
});
describe("toFilter", () => {
it.each(successfulCases)(
"translates basic filter to $rawFilter",
({ basicFilter, rawFilter }) => {
const actualOutput = sut.toFilter(basicFilter);
expect(actualOutput).toEqual(rawFilter);
},
);
});
});

View File

@@ -1,4 +1,17 @@
import { LogService } from "../../platform/abstractions/log.service";
import {
AstNode,
isAnd,
isBinary,
isFieldTerm,
isInCollection,
isInFolder,
isInMyVault,
isInOrg,
isOr,
isParentheses,
isTypeFilter,
} from "../search/ast";
import { parseQuery } from "../search/parse";
export type BasicFilter = {
@@ -13,14 +26,187 @@ export class BasicVaultFilterHandler {
constructor(private readonly logService: LogService) {}
tryParse(rawFilter: string): { success: true; filter: BasicFilter } | { success: false } {
// TODO: Handle vaults
const expectedBinaryOperator: Record<string, string> = {
vaults: "or",
folders: "or",
collections: "and",
types: "or",
fields: "and",
};
// Parse into AST
const ast = parseQuery(rawFilter, this.logService).ast;
if (ast.type !== "search") {
const basicFilter: Partial<BasicFilter> = {
vaults: null,
folders: null,
collections: null,
types: null,
fields: null,
};
type VisitData = {
operator: "or" | "and" | null;
type: keyof BasicFilter | null;
values: string[];
};
const isValidOperator = (data: VisitData) => {
if (data.type == null) {
return false;
}
if (data.values.length === 1) {
// If there aren't multiple values, a null operator is fine and implied
return data.operator === null;
}
return expectedBinaryOperator[data.type] === data.operator;
};
const visitNode = (node: AstNode, data: VisitData): boolean => {
if (isParentheses(node)) {
return visitNode(node.inner, data);
}
if (isBinary(node)) {
if (data.operator != null && data.operator !== node.type) {
// All inner operators must be the same
return false;
}
// Set the operator in case it is null
data.operator = node.type;
// Visit both left and right
return visitNode(node.left, data) && visitNode(node.right, data);
}
let expressionType: keyof BasicFilter = null;
let value: string | null | undefined = undefined;
if (isInMyVault(node)) {
expressionType = "vaults";
// null is used to indicate personal vault in basic filter
value = null;
} else if (isInOrg(node)) {
expressionType = "vaults";
value = node.org;
} else if (isInFolder(node)) {
expressionType = "folders";
value = node.folder;
} else if (isInCollection(node)) {
expressionType = "collections";
value = node.collection;
} else if (isTypeFilter(node)) {
expressionType = "types";
value = node.cipherType;
} else if (isFieldTerm(node)) {
expressionType = "fields";
value = node.field;
} else {
// There are various nodes we don't support in the basic filter, this is likely one of those.
return false;
}
if (data.type == null) {
data.type = expressionType;
} else if (data.type !== expressionType) {
// We've previously visited a node of a different type, unsupported
return false;
}
if (value === undefined) {
throw new Error("Unreachable");
}
// Is the string quoted?
if (value != null && value[0] === '"' && value[value.length - 1] === '"') {
// Unquote the string if it's quoted, ideally the ast offers this.
value = value.substring(1, value.length - 1);
}
data.values.push(value);
return true;
};
const visitTopLevel = (node: AstNode): boolean => {
if (isParentheses(node)) {
// Process top-level parentheses
// Process singular group
// TODO: Visit out "useless" parentheses
const parenthesesData: VisitData = {
operator: null,
type: null,
values: [],
};
if (!visitNode(node.inner, parenthesesData)) {
return false;
}
if (basicFilter[parenthesesData.type] != null) {
// We've already got data for this type
return false;
}
basicFilter[parenthesesData.type] = parenthesesData.values;
return isValidOperator(parenthesesData);
} else if (isAnd(node)) {
// Process top-level and
return visitTopLevel(node.left) && visitTopLevel(node.right);
} else if (isOr(node)) {
// We do not support top level or
return false;
} else {
// Process singular node
const singularData: VisitData = {
operator: null,
type: null,
values: [],
};
const visitResult = visitNode(node, singularData);
if (!visitResult) {
return false;
}
if (singularData.operator != null) {
// If one of these nodes came back with an operator, it was not the kind of node we were expecting
return false;
}
if (singularData.values.length !== 1) {
// If one of these nodes came back with multiple values, it was not the kind of node we were expecting
return false;
}
if (basicFilter[singularData.type] != null) {
// Can't have multiple groups about the same type
return false;
}
basicFilter[singularData.type] = singularData.values;
return true;
}
};
if (visitTopLevel(ast.contents)) {
// Normalize filter for return
return {
success: true,
filter: {
vaults: basicFilter.vaults ?? [],
collections: basicFilter.collections ?? [],
folders: basicFilter.folders ?? [],
fields: basicFilter.fields ?? [],
types: basicFilter.types ?? [],
},
};
} else {
return { success: false };
}
return { success: false };
}
toFilter(basicFilter: BasicFilter) {

View File

@@ -99,6 +99,10 @@ export function isOr(x: AstNode): x is Or {
return x.type === "or";
}
export function isBinary(x: AstNode): x is And | Or {
return x.type === "and" || x.type === "or";
}
export type Term = AstNodeBase & {
type: "term";
value: string;