1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +00:00
Files
browser/libs/common/src/autofill/utils.ts
Matt Gibson 9c1e2ebd67 Typescript-strict-plugin (#12235)
* Use typescript-strict-plugin to iteratively turn on strict

* Add strict testing to pipeline

Can be executed locally through either `npm run test:types` for full type checking including spec files, or `npx tsc-strict` for only tsconfig.json included files.

* turn on strict for scripts directory

* Use plugin for all tsconfigs in monorepo

vscode is capable of executing tsc with plugins, but uses the most relevant tsconfig to do so. If the plugin is not a part of that config, it is skipped and developers get no feedback of strict compile time issues. These updates remedy that at the cost of slightly more complex removal of the plugin when the time comes.

* remove plugin from configs that extend one that already has it

* Update workspace settings to honor strict plugin

* Apply strict-plugin to native message test runner

* Update vscode workspace to use root tsc version

* `./node_modules/.bin/update-strict-comments` 🤖

This is a one-time operation. All future files should adhere to strict type checking.

* Add fixme to `ts-strict-ignore` comments

* `update-strict-comments` 🤖

repeated for new merge files
2024-12-09 20:58:50 +01:00

310 lines
10 KiB
TypeScript

// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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;
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
/**
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
*
* @param {(string | number)} yearInput
* @return {*} {(Year | null)}
*/
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
// The input[type="number"] is returning a number, convert it to a string
// An empty field returns null, avoid casting `"null"` to a string
const yearInputIsEmpty = yearInput == null || yearInput === "";
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
// Exit early if year is already formatted correctly or empty
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
return expirationYear as Year;
}
expirationYear = expirationYear
// For safety, because even input[type="number"] will allow decimals
.replace(/[^\d]/g, "")
// remove any leading zero padding (leave the last leading zero if it ends the string)
.replace(/^[0]+(?=.)/, "");
if (expirationYear === "") {
expirationYear = null;
}
// given the context of payment card expiry, a year character length of 3, or over 4
// is more likely to be a mistake than an intentional value for the far past or far future.
if (expirationYear && expirationYear.length !== 4) {
const paddedYear = ("00" + expirationYear).slice(-2);
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
expirationYear = currentCentury + paddedYear;
}
return expirationYear as Year | null;
}
/**
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
* the card is expired.
*
* @param {CardView} cipherCard
* @return {*} {boolean}
*/
export function isCardExpired(cipherCard: CardView): boolean {
if (cipherCard) {
const { expMonth = null, expYear = null } = cipherCard;
const now = new Date();
const normalizedYear = normalizeExpiryYearFormat(expYear);
// If the card year is before the current year, don't bother checking the month
if (normalizedYear && parseInt(normalizedYear, 10) < now.getFullYear()) {
return true;
}
if (normalizedYear && expMonth) {
const parsedMonthInteger = parseInt(expMonth, 10);
const parsedMonth = isNaN(parsedMonthInteger)
? 0
: // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers
Math.max(
// `Date` months are zero-indexed
parsedMonthInteger - 1,
0,
);
const parsedYear = parseInt(normalizedYear, 10);
// First day of the next month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 1);
return cardExpiry <= now;
}
}
return false;
}
/**
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
*
* @param {string} combinedExpiryValue
* @return {*} {string[]}
*/
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 parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;
let dateParts = [sanitizedValue];
if (parsedDelimiter?.length) {
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;
sanitizedValue = sanitizedValue
// Remove all other delimiter characters not identified as the delimiter
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
// Also de-dupe the delimiter character
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
dateParts = sanitizedValue.split(parsedDelimiter);
}
return (
dateParts
// remove values that have no length
.filter((splitValue) => splitValue?.length)
);
}
/**
* Given an array of split card expiry date parts,
* returns an array of those values ordered by year then month
*
* @param {string[]} splitDateInput
* @return {*} {([string | null, string | null])}
*/
function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] {
// Conditionals here are structured to avoid unnecessary evaluations and are ordered
// from more authoritative checks to checks yielding increasingly inferred conclusions
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (ExpiryFullYearPatternExpression.test(firstPart)) {
return [firstPart, secondPart];
}
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (ExpiryFullYearPatternExpression.test(secondPart)) {
return [secondPart, firstPart];
}
// If it's a two digit value that doesn't match against month pattern, assume it's a year
if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) {
return [firstPart, secondPart];
}
// If it's a two digit value that doesn't match against month pattern, assume it's a year
if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) {
return [secondPart, firstPart];
}
// 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).
let parsedYear = firstPart;
let parsedMonth = secondPart;
if (MonthPatternExpression.test(firstPart)) {
parsedYear = secondPart;
parsedMonth = firstPart;
}
return [parsedYear, parsedMonth];
}
/**
* Given a single string of integers, attempts to identify card expiry date portions within
* and return values ordered by year then month
*
* @param {string} dateInput
* @return {*} {([string | null, string | null])}
*/
function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] {
if (dateInput.length > 4) {
// e.g.
// "052024"
// "202405"
// "20245"
// "52024"
// If the value is over 5-characters long, it likely has a full year format in it
const [parsedYear, parsedMonth] = dateInput
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
.sort((current: string, next: string) => (current.length > next.length ? -1 : 1));
return [parsedYear, parsedMonth];
}
if (dateInput.length === 4) {
// e.g.
// "0524"
// "2405"
// 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
const splitFirstPartFirstHalf = dateInput.slice(0, 2);
const splitFirstPartSecondHalf = dateInput.slice(-2);
let parsedYear = splitFirstPartSecondHalf;
let 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;
}
return [parsedYear, parsedMonth];
}
// e.g.
// "245"
// "202"
// "212"
// "022"
// "111"
// A valid year representation here must be two characters so try to find it first.
let parsedYear = null;
let parsedMonth = null;
// Split if there is a digit with a leading zero
const splitFirstPartOnLeadingZero = dateInput.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 = dateInput.slice(0, 2);
parsedMonth = dateInput.slice(-1);
const currentYear = new Date().getFullYear();
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
const normalizedParsedYearAlternative = parseInt(
normalizeExpiryYearFormat(dateInput.slice(-2)),
10,
);
if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
parsedYear = dateInput.slice(-2);
parsedMonth = dateInput.slice(0, 1);
}
}
return [parsedYear, parsedMonth];
}
/**
* Attempt to parse year and month parts of a combined expiry date value.
*
* @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(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) {
[parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
}
// There are multiple date parts
else {
[parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([
sanitizedFirstPart,
sanitizedSecondPart,
]);
}
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
// Set "empty" values to null
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;
return [parsedYear, parsedMonth];
}