Skip to content

Commit

Permalink
clean up code
Browse files Browse the repository at this point in the history
  • Loading branch information
jprusik committed Sep 12, 2024
1 parent 29ddfef commit d2d13d8
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 101 deletions.
2 changes: 1 addition & 1 deletion libs/common/src/autofill/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,4 @@ export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof Extens

export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute

export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];
export * from "./match-patterns";
26 changes: 26 additions & 0 deletions libs/common/src/autofill/constants/match-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];

// `CardExpiryDateDelimiters` is not intended solely for regex consumption,
// so we need to format it here
export const ExpiryDateDelimitersPattern =
"\\" +
CardExpiryDateDelimiters.join("\\")
// replace space character with the regex whitespace character class
.replace(" ", "s");

export const MonthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))";

// Because we're dealing with expiry dates, we assume the year will be in current or next century (as of 2024)
export const ExpiryFullYearPattern = "2[0-1]{1}\\d{2}";

export const DelimiterPatternExpression = new RegExp(`[${ExpiryDateDelimitersPattern}]`, "g");

export const IrrelevantExpiryCharactersPatternExpression = new RegExp(
// "nor digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
`[^\\d${ExpiryDateDelimitersPattern}]`,
"g",
);

export const MonthPatternExpression = new RegExp(`^${MonthPattern}$`);

export const ExpiryFullYearPatternExpression = new RegExp(`^${ExpiryFullYearPattern}$`);
215 changes: 115 additions & 100 deletions libs/common/src/vault/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { CardExpiryDateDelimiters } from "@bitwarden/common/autofill/constants";
import {
DelimiterPatternExpression,
ExpiryFullYearPattern,
ExpiryFullYearPatternExpression,
IrrelevantExpiryCharactersPatternExpression,
MonthPatternExpression,
} from "@bitwarden/common/autofill/constants";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";

type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
Expand Down Expand Up @@ -82,171 +88,180 @@ export function isCardExpired(cipherCard: CardView): boolean {
}

/**
* Attempt to parse year and month parts of a combined expiry date value. Used when no
* other information about the format is available.
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
*
* @param {string} combinedExpiryValue
* @return {*} {([string | null, string | null])}
* @return {*} {string[]}
*/
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
let parsedYear = null;
let parsedMonth = null;

const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\");
const delimiterPatternExpression = new RegExp(`[${expiryDateDelimitersPattern}]`, "g");
const irrelevantExpiryCharactersPattern = new RegExp(
// "or digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
`[^\\d${expiryDateDelimitersPattern}]`,
"g",
);
const monthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))";
const monthPatternExpression = new RegExp(`^${monthPattern}$`);
// Because we're dealing with expiry dates, we assume the year will be in current or next century
const fullYearPattern = "2[0-1]{1}[0-9]{2}";

let sanitizedValue = combinedExpiryValue.replace(irrelevantExpiryCharactersPattern, "").trim();
function splitCombinedDateValues(combinedExpiryValue: string): string[] {
let sanitizedValue = combinedExpiryValue
.replace(IrrelevantExpiryCharactersPatternExpression, "")
.trim();

// Do this after initial value replace to avoid identifying leading whitespace as delimiter
const delimiter = sanitizedValue.match(delimiterPatternExpression)?.[0] || null;
const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;

if (parsedDelimiter) {
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;

if (delimiter) {
const delimiterPattern = delimiter === " " ? "s" : delimiter;
sanitizedValue = sanitizedValue
// Remove all other delimiter characters not identified as the delimiter
.replace(new RegExp(`[^\\d\\${delimiterPattern}]`, "g"), "")
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
// Also de-dupe the delimiter character
.replace(new RegExp(`[\\${delimiterPattern}]{2,}`, "g"), delimiter);
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
}

let dateParts = [sanitizedValue];

if (delimiter?.length) {
dateParts = sanitizedValue.split(delimiter);
if (parsedDelimiter?.length) {
dateParts = sanitizedValue.split(parsedDelimiter);
}

return dateParts;
}

/**
* Attempt to parse year and month parts of a combined expiry date value. Used when no
* other information about the format is available.
*
* @param {string} combinedExpiryValue
* @return {*} {([string | null, string | null])}
*/
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
let parsedYear = null;
let parsedMonth = null;

const dateParts = splitCombinedDateValues(combinedExpiryValue);

if (dateParts.length < 1) {
return [null, null];
}

const sanitizedFirstPart = dateParts[0]?.replace(irrelevantExpiryCharactersPattern, "") || "";
const sanitizedSecondPart = dateParts[1]?.replace(irrelevantExpiryCharactersPattern, "") || "";
const sanitizedFirstPart =
dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
const sanitizedSecondPart =
dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";

// If there is only one date part, no delimiter was found in the passed value
if (dateParts.length === 1) {
// If the value is over 5-characters long, it likely has a full year format in it
if (sanitizedFirstPart.length > 4) {
// If the value is over 5-characters long, it likely has a full year format in it
// e.g.
// "052024",
// "202405",
// "20245",
// "52024",
// "052024"
// "202405"
// "20245"
// "52024"
const [year, month] = dateParts[0]
.split(new RegExp(`(?=${fullYearPattern})|(?<=${fullYearPattern})`, "g"))
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
.sort((current, next) => (current.length > next.length ? -1 : 1));
parsedYear = year;
parsedMonth = month;
} else if (sanitizedFirstPart.length === 4) {
// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
// neither a year or month will be represented with three characters
// e.g.
// "0524"
// "2405"

const splitFirstPartFirstHalf = sanitizedFirstPart.slice(0, 2);
const splitFirstPartSecondHalf = sanitizedFirstPart.slice(-2);

parsedYear = splitFirstPartSecondHalf;
parsedMonth = splitFirstPartFirstHalf;

// If the first part doesn't match a month pattern, assume it's a year
if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) {
parsedYear = splitFirstPartFirstHalf;
parsedMonth = splitFirstPartSecondHalf;
}
} else {
// The `sanitizedFirstPart` value here can't be a full year format (without a missing
// month), and representing both year and month requires at least three characters, so
// assume a year representation is two characters and try to find it first.
// A valid year representation here must be two characters so try to find it first.
// e.g.
// "0524",
// "2405",
// "245",
// "245"
// "202"
// "212"
// "022"
// "111"

// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
// neither a year or month will be represented with three characters
if (sanitizedFirstPart.length === 4) {
const splitFirstPartFirstHalf = sanitizedFirstPart.slice(0, 2);
const splitFirstPartSecondHalf = sanitizedFirstPart.slice(-2);
// split if there is a digit with a leading zero
const splitFirstPartOnLeadingZero = sanitizedFirstPart.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/);

parsedYear = splitFirstPartSecondHalf;
parsedMonth = splitFirstPartFirstHalf;
// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
if (splitFirstPartOnLeadingZero.length > 1) {
parsedYear = splitFirstPartOnLeadingZero[0];
parsedMonth = splitFirstPartOnLeadingZero[1];

if (!monthPatternExpression.test(splitFirstPartFirstHalf)) {
parsedYear = splitFirstPartFirstHalf;
parsedMonth = splitFirstPartSecondHalf;
if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
parsedMonth = splitFirstPartOnLeadingZero[0];
parsedYear = splitFirstPartOnLeadingZero[1];
}
} else {
// split on first part if there is a digit with a leading zero
const splitFirstPartOnLeadingZero = sanitizedFirstPart.split(
/(?<=0[1-9]{1})|(?=0[1-9]{1})/,
);

// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
if (splitFirstPartOnLeadingZero.length > 1) {
parsedYear = splitFirstPartOnLeadingZero[0];
parsedMonth = splitFirstPartOnLeadingZero[1];

if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
parsedMonth = splitFirstPartOnLeadingZero[0];
parsedYear = splitFirstPartOnLeadingZero[1];
}
} else {
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
parsedYear = sanitizedFirstPart.slice(0, 2);
parsedMonth = sanitizedFirstPart.slice(-1);

const currentYear = new Date().getFullYear();
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
const normalizedParsedYearAlternative = parseInt(sanitizedFirstPart.slice(-2), 10);

if (
normalizedParsedYear < currentYear &&
normalizedParsedYearAlternative >= currentYear
) {
parsedYear = sanitizedFirstPart.slice(-1);
parsedMonth = sanitizedFirstPart.slice(0, 2);
}
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
parsedYear = sanitizedFirstPart.slice(0, 2);
parsedMonth = sanitizedFirstPart.slice(-1);

const currentYear = new Date().getFullYear();
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
const normalizedParsedYearAlternative = parseInt(sanitizedFirstPart.slice(-2), 10);

if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
parsedYear = sanitizedFirstPart.slice(-1);
parsedMonth = sanitizedFirstPart.slice(0, 2);
}
}
}
} else {
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (/^[1-9]{1}\d{3}$/g.test(sanitizedFirstPart)) {
}
// There are multiple date parts
else {
// Conditionals here are structured to avoid unnecessary evaluations and
// are ordered from more authoritative checks to checks yielding inferred conclusions
if (
// If a 4-digit value is found (when there are multiple parts), it can't be month
ExpiryFullYearPatternExpression.test(sanitizedFirstPart)
) {
parsedYear = sanitizedFirstPart;
parsedMonth = sanitizedSecondPart;
} else if (/^[1-9]{1}\d{3}$/g.test(sanitizedSecondPart)) {
} else if (
// If a 4-digit value is found (when there are multiple parts), it can't be month
ExpiryFullYearPatternExpression.test(sanitizedSecondPart)
) {
parsedYear = sanitizedSecondPart;
parsedMonth = sanitizedFirstPart;
} else if (
// If it's a two digit value that doesn't match against month pattern, assume it's a year
/\d{2}/.test(sanitizedFirstPart) &&
!monthPatternExpression.test(sanitizedFirstPart)
!MonthPatternExpression.test(sanitizedFirstPart)
) {
// If it's a two digit value that doesn't match against month pattern, assume it's a year
parsedYear = sanitizedFirstPart;
parsedMonth = sanitizedSecondPart;
} else if (
// If it's a two digit value that doesn't match against month pattern, assume it's a year
/\d{2}/.test(sanitizedSecondPart) &&
!monthPatternExpression.test(sanitizedSecondPart)
!MonthPatternExpression.test(sanitizedSecondPart)
) {
// If it's a two digit value that doesn't match against month pattern, assume it's a year
parsedYear = sanitizedSecondPart;
parsedMonth = sanitizedFirstPart;
} else {
// values are too ambiguous (e.g. "12/09"); for the most part, a month-looking value
// likely is, at the time of writing (year 2024)
parsedMonth = sanitizedSecondPart;
// Values are too ambiguous (e.g. "12/09"). For the most part,
// a month-looking value likely is, at the time of writing (year 2024).
parsedYear = sanitizedFirstPart;
parsedMonth = sanitizedSecondPart;

if (monthPatternExpression.test(sanitizedFirstPart)) {
parsedMonth = sanitizedFirstPart;
if (MonthPatternExpression.test(sanitizedFirstPart)) {
parsedYear = sanitizedSecondPart;
parsedMonth = sanitizedFirstPart;
}
}
}

const nomalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
const nomalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);

// set "empty" values to null
parsedYear = nomalizedParsedYear?.length ? nomalizedParsedYear : null;
parsedMonth = nomalizedParsedMonth?.length ? nomalizedParsedMonth : null;
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;

return [parsedYear, parsedMonth];
}

0 comments on commit d2d13d8

Please sign in to comment.