161 lines
6.3 KiB
Plaintext
161 lines
6.3 KiB
Plaintext
import MatchL33t from '../dictionary/variants/matching/l33t.mjs';
|
|
import MatchReverse from '../dictionary/variants/matching/reverse.mjs';
|
|
import MatchDictionary from '../dictionary/matching.mjs';
|
|
import { MatcherBaseClass } from '../../types.mjs';
|
|
|
|
/*
|
|
*-------------------------------------------------------------------------------
|
|
* word sequences (oneTwoThree, fourFiveSix) ------------------------------
|
|
*-------------------------------------------------------------------------------
|
|
*/
|
|
class MatchWordSequence extends MatcherBaseClass {
|
|
constructor(options) {
|
|
super(options);
|
|
this.dictionary = new MatchDictionary(this.options, true);
|
|
this.dictionaryL33t = new MatchL33t(this.options, true);
|
|
this.dictionaryReverse = new MatchReverse(this.options, true);
|
|
}
|
|
match(matchOptions) {
|
|
const {
|
|
password
|
|
} = matchOptions;
|
|
// Get all dictionary matches first
|
|
const dictionaryMatches = this.dictionary.match(matchOptions);
|
|
const dictionaryL33tMatches = this.dictionaryL33t.match(matchOptions);
|
|
const dictionaryReverseMatches = this.dictionaryReverse.match(matchOptions);
|
|
const filteredDictionaryMatches = this.filterDictionaryMatches([...dictionaryMatches, ...dictionaryL33tMatches, ...dictionaryReverseMatches]);
|
|
// Convert dictionary matches to our internal format
|
|
const wordMatches = this.convertToWordMatches(filteredDictionaryMatches);
|
|
// Find sequences of consecutive words
|
|
return this.findWordSequences(wordMatches, password);
|
|
}
|
|
filterDictionaryMatches(matches) {
|
|
// sort by start index, then by end index
|
|
return [...matches].sort((a, b) => {
|
|
if (a.i !== b.i) return a.i - b.i;
|
|
if (a.j !== b.j) return a.j - b.j;
|
|
// prefer forward over reversed
|
|
if (a.reversed !== b.reversed) return a.reversed ? 1 : -1;
|
|
// prefer non-l33t
|
|
if (a.l33t !== b.l33t) return a.l33t ? 1 : -1;
|
|
// prefer better rank
|
|
return a.rank - b.rank;
|
|
})
|
|
// Keep only non-overlapping matches, favoring earlier ones
|
|
.reduce((acc, match) => {
|
|
const last = acc[acc.length - 1];
|
|
if (!last || match.i > last.j) {
|
|
acc.push(match);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
}
|
|
convertToWordMatches(dictionaryMatches) {
|
|
return dictionaryMatches.map(match => ({
|
|
word: match.matchedWord,
|
|
i: match.i,
|
|
j: match.j,
|
|
rank: match.rank,
|
|
dictionaryName: match.dictionaryName
|
|
}));
|
|
}
|
|
findWordSequences(wordMatches, password) {
|
|
const sequences = [];
|
|
if (wordMatches.length === 0) {
|
|
return sequences;
|
|
}
|
|
// Sort matches by start position
|
|
const sortedMatches = [...wordMatches].sort((a, b) => a.i - b.i);
|
|
// Find all possible sequences
|
|
for (let startIdx = 0; startIdx < sortedMatches.length; startIdx += 1) {
|
|
const sequencesFromStart = this.findSequencesFromStart(sortedMatches, startIdx, password);
|
|
sequences.push(...sequencesFromStart);
|
|
}
|
|
return sequences;
|
|
}
|
|
// eslint-disable-next-line max-statements
|
|
findSequencesFromStart(sortedMatches, startIdx, password) {
|
|
const sequences = [];
|
|
const startMatch = sortedMatches[startIdx];
|
|
// Start with single word sequence
|
|
let currentSequence = [startMatch];
|
|
let currentEnd = startMatch.j;
|
|
// Try to extend the sequence
|
|
for (let nextIdx = startIdx + 1; nextIdx < sortedMatches.length; nextIdx += 1) {
|
|
const nextMatch = sortedMatches[nextIdx];
|
|
// Only extend if the next word can form a valid sequence
|
|
if (this.isValidWordSequence(currentSequence, nextMatch, password)) {
|
|
currentSequence.push(nextMatch);
|
|
currentEnd = nextMatch.j;
|
|
} else if (nextMatch.i > currentEnd) {
|
|
// Gap found, save current sequence and start new one
|
|
if (currentSequence.length > 1) {
|
|
sequences.push(this.createWordSequenceMatch(currentSequence, password));
|
|
}
|
|
currentSequence = [nextMatch];
|
|
currentEnd = nextMatch.j;
|
|
}
|
|
// If nextMatch.i <= currentEnd, it overlaps, so we skip it
|
|
}
|
|
// Don't forget the last sequence
|
|
if (currentSequence.length > 1) {
|
|
sequences.push(this.createWordSequenceMatch(currentSequence, password));
|
|
}
|
|
return sequences;
|
|
}
|
|
isValidWordSequence(currentSequence, nextMatch, password) {
|
|
// Get the text between the last word and the next word
|
|
const lastWord = currentSequence[currentSequence.length - 1];
|
|
const textBetween = password.slice(lastWord.j + 1, nextMatch.i);
|
|
// Check for common word separators
|
|
const separators = ['', ' ', '-', '_', '.', ''];
|
|
// For simple concatenation (no separator)
|
|
const isSimpleConcat = textBetween === '';
|
|
// For snake_case or kebab-case, check for separators
|
|
const hasValidSeparator = separators.some(sep => textBetween === sep);
|
|
// For camelCase, we need to check if the next word starts with uppercase
|
|
// and there's no separator (or just a single character that could be uppercase)
|
|
const isCamelCase = textBetween.length === 1 && textBetween === textBetween.toUpperCase() && textBetween !== textBetween.toLowerCase();
|
|
return hasValidSeparator || isSimpleConcat || isCamelCase;
|
|
}
|
|
createWordSequenceMatch(sequence, password) {
|
|
const firstMatch = sequence[0];
|
|
const lastMatch = sequence[sequence.length - 1];
|
|
const words = sequence.map(match => match.word);
|
|
// Determine if sequence is ascending (by rank)
|
|
const ranks = sequence.map(match => match.rank);
|
|
const ascending = ranks.every((rank, i) => i === 0 || rank >= ranks[i - 1]);
|
|
// Use the most common dictionary name, or the first one
|
|
const dictionaryNames = sequence.map(match => match.dictionaryName);
|
|
const dictionaryName = this.getMostCommon(dictionaryNames) || firstMatch.dictionaryName;
|
|
return {
|
|
pattern: 'wordSequence',
|
|
i: firstMatch.i,
|
|
j: lastMatch.j,
|
|
token: password.slice(firstMatch.i, lastMatch.j + 1),
|
|
words,
|
|
wordCount: words.length,
|
|
dictionaryName,
|
|
ascending
|
|
};
|
|
}
|
|
getMostCommon(array) {
|
|
if (array.length === 0) return null;
|
|
const counts = new Map();
|
|
let maxCount = 0;
|
|
let mostCommon = null;
|
|
array.forEach(item => {
|
|
const count = (counts.get(item) || 0) + 1;
|
|
counts.set(item, count);
|
|
if (count > maxCount) {
|
|
maxCount = count;
|
|
mostCommon = item;
|
|
}
|
|
});
|
|
return mostCommon;
|
|
}
|
|
}
|
|
|
|
export { MatchWordSequence as default };
|
|
//# sourceMappingURL=matching.mjs.map
|