diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7d3c611..c7a2b09 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,8 +14,6 @@ version = "2.5.2" dependencies = [ "open", "rand 0.10.1", - "serde", - "serde_json", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index df01db6..ee74417 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,8 +15,6 @@ rust-version = "1.95.0" tauri-build = { version = "2.5.6", features = [] } [dependencies] -serde_json = "1.0" -serde = { version = "1.0", features = ["derive"] } tauri = { version = "2.10.3", features = [] } open = "5.3.3" rand = "0.10.1" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 481e681..af68e60 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,14 +4,52 @@ )] use rand::RngExt; -use std::fs; +use std::{collections::HashSet, fmt::Display, fs}; use unicode_segmentation::UnicodeSegmentation; -fn main() { - #[cfg(target_os = "linux")] +type CommandResult = Result; + +#[cfg(target_os = "linux")] +fn configure_linux_environment() { + // SAFETY: This runs during application startup before any threads are spawned + // and only sets a fixed process-wide environment variable needed on Linux. unsafe { std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); } +} + +fn map_command_result(result: Result) -> CommandResult +where + E: Display, +{ + result.map_err(|error| error.to_string()) +} + +fn as_platform_size(value: u64, field_name: &str) -> CommandResult { + usize::try_from(value).map_err(|_| format!("{field_name} is too large for this platform.")) +} + +fn count_unique_passwords(min_length: usize, max_length: usize, grapheme_count: usize) -> usize { + let mut total = 0usize; + + for length in min_length..=max_length { + let combinations = u32::try_from(length) + .ok() + .and_then(|value| grapheme_count.checked_pow(value)) + .unwrap_or(usize::MAX); + + total = total.saturating_add(combinations); + if total == usize::MAX { + break; + } + } + + total +} + +fn main() { + #[cfg(target_os = "linux")] + configure_linux_environment(); tauri::Builder::default() .plugin(tauri_plugin_os::init()) @@ -34,76 +72,83 @@ fn exit_app() { } #[tauri::command] -fn open_website(website: &str) -> Result { - match open::that(website) { - Ok(_) => Ok(String::from("Success")), - Err(e) => Err(e.to_string()), - } +fn open_website(website: &str) -> CommandResult<()> { + map_command_result(open::that(website)) } #[tauri::command] -fn save_string_to_disk(content: &str, path: &str) -> Result { - match fs::write(path, content) { - Ok(_) => Ok(String::from("Success")), - Err(e) => Err(e.to_string()), - } +fn save_string_to_disk(content: &str, path: &str) -> CommandResult<()> { + map_command_result(fs::write(path, content)) } #[tauri::command] -async fn read_string_from_file(path: &str) -> Result { - match fs::read_to_string(path) { - Ok(s) => Ok(s), - Err(e) => Err(e.to_string()), - } +fn read_string_from_file(path: &str) -> CommandResult { + map_command_result(fs::read_to_string(path)) } #[tauri::command] -async fn generate_passwords( +fn generate_passwords( min_length: u64, max_length: u64, character_set: &str, include_symbols: &str, amount: u64, allow_duplicates: bool, -) -> Result, String> { - let mut password_list: Vec = Vec::new(); - let mut max_count: f64 = 0.0; +) -> CommandResult> { + let min_length = as_platform_size(min_length, "Minimum length")?; + let max_length = as_platform_size(max_length, "Maximum length")?; + let requested_amount = as_platform_size(amount, "Password amount")?; - let mut total_character_set = String::from(character_set); - total_character_set.push_str(include_symbols); + if min_length > max_length { + return Err("Minimum length cannot be greater than maximum length.".into()); + } + + if requested_amount == 0 { + return Ok(Vec::new()); + } - let graphemes = total_character_set.graphemes(true); - let char_count = graphemes.clone().count(); + let total_character_set = format!("{character_set}{include_symbols}"); + let graphemes = total_character_set.graphemes(true).collect::>(); + let grapheme_count = graphemes.len(); - if !allow_duplicates { - let mut current = min_length; - while current <= max_length { - max_count += (char_count as f64).powf(current as f64); - current += 1; - } + if grapheme_count == 0 { + return Ok(Vec::new()); } + let target_amount = if allow_duplicates { + requested_amount + } else { + requested_amount.min(count_unique_passwords( + min_length, + max_length, + grapheme_count, + )) + }; + + if target_amount == 0 { + return Ok(Vec::new()); + } + + let mut password_list = Vec::with_capacity(target_amount); + let mut seen_passwords = (!allow_duplicates).then(|| HashSet::with_capacity(target_amount)); let mut rng = rand::rng(); - let chars = graphemes.collect::>(); - for _n in 0..amount { - let mut can_continue = false; - while !can_continue { - let mut password = String::from(""); - let length = rng.random_range(min_length..(max_length + 1)); - for _j in 0..length { - let index = rng.random_range(0..char_count); - password.push_str(chars.clone()[index]); - } - if allow_duplicates || !password_list.contains(&password) { - password_list.push(password); - can_continue = true; - } + while password_list.len() < target_amount { + let length = rng.random_range(min_length..=max_length); + let mut password = String::new(); + + for _ in 0..length { + let index = rng.random_range(0..grapheme_count); + password.push_str(graphemes[index]); + } - if !can_continue && !allow_duplicates && password_list.len() as f64 == max_count { - return Ok(password_list); + if let Some(seen_passwords) = seen_passwords.as_mut() { + if !seen_passwords.insert(password.clone()) { + continue; } } + + password_list.push(password); } Ok(password_list) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 03bbf84..049c3d3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,7 +40,7 @@ } } }, - "productName": "advanced-passgen", + "productName": "Advanced PassGen", "mainBinaryName": "advanced-passgen", "version": "2.5.2", "identifier": "com.codedead.advancedpassgen", diff --git a/src/components/PasswordTips/index.jsx b/src/components/PasswordTips/index.jsx index cf8ddbd..a968768 100644 --- a/src/components/PasswordTips/index.jsx +++ b/src/components/PasswordTips/index.jsx @@ -1,4 +1,10 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import CloseIcon from '@mui/icons-material/Close'; import Alert from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; @@ -6,6 +12,9 @@ import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; import { MainContext } from '../../contexts/MainContextProvider'; +const getRandomTipIndex = (length) => + length > 0 ? Math.floor(Math.random() * length) : 0; + const PasswordTips = () => { const [state] = useContext(MainContext); @@ -13,75 +22,64 @@ const PasswordTips = () => { const language = state.languages[languageIndex]; const [tipsOpen, setTipsOpen] = useState(tips); - const intervalId = useRef(); - const [currentTip, setCurrentTip] = useState( - language.passwordTips[ - // eslint-disable-next-line react-hooks/purity - Math.floor(Math.random() * language.passwordTips.length) - ], + const intervalId = useRef(null); + const [tipIndex, setTipIndex] = useState(() => + getRandomTipIndex(language.passwordTips.length), ); + const currentTip = + language.passwordTips[tipIndex % language.passwordTips.length] ?? ''; /** - * Generate a new tipper + * Clear the active tip timer */ - const generateNewTipper = () => { + const clearTipInterval = useCallback(() => { if (intervalId.current !== null) { clearInterval(intervalId.current); + intervalId.current = null; } + }, []); - intervalId.current = setInterval(() => { - setCurrentTip( - language.passwordTips[ - Math.floor(Math.random() * language.passwordTips.length) - ], - ); - }, 30000); - }; + /** + * Start rotating the current tip + * @param passwordTips The available tips for the current language + */ + const startTipRotation = useCallback( + (passwordTips) => { + clearTipInterval(); - useEffect(() => { - if (tips) { - generateNewTipper(); - } + if (!passwordTips || passwordTips.length === 0) { + return; + } - return () => - intervalId !== null ? clearInterval(intervalId.current) : null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + intervalId.current = setInterval(() => { + setTipIndex(getRandomTipIndex(passwordTips.length)); + }, 30000); + }, + [clearTipInterval], + ); useEffect(() => { - if (tips) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setCurrentTip( - language.passwordTips[ - Math.floor(Math.random() * language.passwordTips.length) - ], - ); + if (!tips) { + clearTipInterval(); + return clearTipInterval; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [language]); - useEffect(() => { - if (tips) { - generateNewTipper(); - } else { - clearInterval(intervalId.current); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tips]); + startTipRotation(language.passwordTips); + + return clearTipInterval; + }, [clearTipInterval, language.passwordTips, startTipRotation, tips]); + + const closeTips = () => { + setTipsOpen(false); + clearTipInterval(); + }; return ( - + { - setTipsOpen(false); - clearInterval(intervalId.current); - }} - > + } diff --git a/src/reducers/MainReducer/Actions/index.js b/src/reducers/MainReducer/Actions/index.js index 25259e0..62ec4bb 100644 --- a/src/reducers/MainReducer/Actions/index.js +++ b/src/reducers/MainReducer/Actions/index.js @@ -16,6 +16,30 @@ import { SET_UPDATE, } from './actionTypes'; +const normalizeErrorMessage = (error) => { + if (error === null || error === undefined) { + return null; + } + + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + if ( + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message; + } + + return String(error); +}; + export const setLanguageIndex = (index) => ({ type: SET_LANGUAGE_INDEX, payload: index, @@ -62,7 +86,7 @@ export const setUpdate = (update) => ({ export const setError = (error) => ({ type: SET_ERROR, - payload: error, + payload: normalizeErrorMessage(error), }); export const setLanguageSelector = (value) => ({ diff --git a/src/routes/Generate/index.jsx b/src/routes/Generate/index.jsx index 10398aa..3f86e0c 100644 --- a/src/routes/Generate/index.jsx +++ b/src/routes/Generate/index.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import Alert from '@mui/material/Alert'; import Button from '@mui/material/Button'; import Container from '@mui/material/Container'; @@ -30,6 +30,31 @@ import PasswordStrength from '../../utils/PasswordStrength'; const GraphemerConstructor = Graphemer.default ?? Graphemer; +const EXPORT_TYPES = { + 'application/json': { + extension: 'json', + serialize: (passwordArray) => JSON.stringify(passwordArray, null, 2), + }, + 'text/csv': { + extension: 'csv', + serialize: (passwordArray) => + passwordArray + .map((password) => `"${password.replaceAll('"', '""')}"`) + .join('\n'), + }, + 'text/plain': { + extension: 'txt', + serialize: (passwordArray) => passwordArray.join('\n'), + }, +}; + +const createPasswordRow = (id, password, passwordLength, strength) => ({ + id, + password, + length: passwordLength, + strength, +}); + const Generate = () => { const [state1, d1] = useContext(MainContext); const [state2, d2] = useContext(PasswordContext); @@ -60,23 +85,34 @@ const Generate = () => { const worker = usePasswordGeneratorWorker(); - const simpleCharacterSet = getFullCharacterSet( - characterSet, - useAdvanced, - smallLetters, - capitalLetters, - spaces, - numbers, - specialCharacters, - brackets, - useEmojis, + const simpleCharacterSet = useMemo( + () => + getFullCharacterSet( + characterSet, + useAdvanced, + smallLetters, + capitalLetters, + spaces, + numbers, + specialCharacters, + brackets, + useEmojis, + ), + [ + brackets, + capitalLetters, + characterSet, + numbers, + smallLetters, + spaces, + specialCharacters, + useAdvanced, + useEmojis, + ], ); const cannotGenerate = - !simpleCharacterSet || - simpleCharacterSet.length === 0 || - min > max || - max < min; + !simpleCharacterSet || simpleCharacterSet.length === 0 || min > max; /** * Close the snackbar @@ -120,19 +156,8 @@ const Generate = () => { * @returns {string} The export data */ const getExportData = (passwordArray, type) => { - let toExport = ''; - if (type === 'text/plain') { - passwordArray.forEach((e) => { - toExport += `${e}\n`; - }); - } else if (type === 'application/json') { - toExport = JSON.stringify(passwordArray, null, 2); - } else if (type === 'text/csv') { - passwordArray.forEach((e) => { - toExport += `"${e.replaceAll('"', '""')}",\n`; - }); - } - return toExport; + const exportConfig = EXPORT_TYPES[type] ?? EXPORT_TYPES['text/plain']; + return exportConfig.serialize(passwordArray); }; /** @@ -141,15 +166,8 @@ const Generate = () => { * @param type The file type */ const downloadFile = (data, type) => { - let fileName = 'export'; - - if (type === 'text/plain') { - fileName += '.txt'; - } else if (type === 'application/json') { - fileName += '.json'; - } else if (type === 'text/csv') { - fileName += '.csv'; - } + const exportConfig = EXPORT_TYPES[type] ?? EXPORT_TYPES['text/plain']; + const fileName = `export.${exportConfig.extension}`; const blob = new Blob([getExportData(data, type)], { type }); const href = URL.createObjectURL(blob); @@ -171,31 +189,24 @@ const Generate = () => { */ const onExport = () => { if (window.__TAURI__) { - let ext = ''; - switch (exportType) { - case 'text/plain': - ext = 'txt'; - break; - case 'application/json': - ext = 'json'; - break; - default: - ext = 'csv'; - break; - } + const exportConfig = + EXPORT_TYPES[exportType] ?? EXPORT_TYPES['text/plain']; save({ multiple: false, filters: [ { name: exportType, - extensions: [ext], + extensions: [exportConfig.extension], }, ], }) .then((res) => { if (res && res.length > 0) { const resExt = res.slice(((res.lastIndexOf('.') - 1) >>> 0) + 2); - const path = resExt && resExt.length > 0 ? res : `${res}.${ext}`; + const path = + resExt && resExt.length > 0 + ? res + : `${res}.${exportConfig.extension}`; invoke('save_string_to_disk', { content: getExportData(passwords, exportType), path, @@ -224,17 +235,6 @@ const Generate = () => { * @param strength The strength of the password * @returns {{password, strength: string, length}} The JSON object */ - const createData = (id, password, passwordLength, strength) => ({ - id, - password, - length: passwordLength, - strength, - }); - - /** - * Set the export type - * @param e The change event - */ const handleExportTypeChange = (e) => { setExportType(e.target.value); }; @@ -249,29 +249,31 @@ const Generate = () => { useEffect(() => { d1(setPageIndex(1)); document.title = 'Password Generator | Advanced PassGen'; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [d1]); + + const passwordRows = useMemo(() => { + if (!passwords || passwords.length === 0) { + return []; + } - let passwordRows = []; - if (passwords && passwords.length > 0) { const splitter = new GraphemerConstructor(); - passwords.forEach((e, i) => { - passwordRows.push( - createData( - `${e}${i}`, - e, - splitter.countGraphemes(e), - PasswordStrength(e), - ), - ); - }); + const rows = passwords.map((password, index) => + createPasswordRow( + `${password}${index}`, + password, + splitter.countGraphemes(password), + PasswordStrength(password), + ), + ); - if (sortByStrength) { - passwordRows = passwordRows.sort( - (a, b) => parseFloat(b.strength) - parseFloat(a.strength), - ); + if (!sortByStrength) { + return rows; } - } + + return [...rows].sort( + (a, b) => parseFloat(b.strength) - parseFloat(a.strength), + ); + }, [passwords, sortByStrength]); if (loading) { return ; diff --git a/src/routes/Vault/index.jsx b/src/routes/Vault/index.jsx index d10bda8..32484f7 100644 --- a/src/routes/Vault/index.jsx +++ b/src/routes/Vault/index.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CloseIcon from '@mui/icons-material/Close'; import FileOpenIcon from '@mui/icons-material/FileOpen'; @@ -32,6 +32,23 @@ import { } from '../../reducers/MainReducer/Actions'; import { setPhrase, setVault } from '../../reducers/VaultReducer/Actions'; +const decryptVault = (data, decryptionKey) => { + const bytes = CryptoJS.AES.decrypt(data, decryptionKey); + const originalText = bytes.toString(CryptoJS.enc.Utf8); + + if (!originalText) { + throw new Error('Unable to decrypt vault. Check the encryption key.'); + } + + const parsedVault = JSON.parse(originalText); + + if (!Array.isArray(parsedVault)) { + throw new Error('The selected vault file is invalid.'); + } + + return parsedVault; +}; + const Vault = () => { const [state, d1] = useContext(MainContext); const [vaultState, d3] = useContext(VaultContext); @@ -106,11 +123,7 @@ const Vault = () => { }); if (path && path.length > 0) { const res = await invoke('read_string_from_file', { path }); - - const bytes = CryptoJS.AES.decrypt(res.toString(), decryptionKey); - const originalText = bytes.toString(CryptoJS.enc.Utf8); - - d3(setVault(JSON.parse(originalText))); + d3(setVault(decryptVault(String(res), decryptionKey))); } } else { setSelectFileOpen(true); @@ -128,10 +141,8 @@ const Vault = () => { const openVaultFromData = async (data) => { if (data && data.length > 0) { try { - const bytes = CryptoJS.AES.decrypt(data.toString(), phrase); - const originalText = bytes.toString(CryptoJS.enc.Utf8); - - d3(setVault(JSON.parse(originalText))); + d3(setVault(decryptVault(String(data), phrase))); + setSelectFileOpen(false); } catch (e) { d1(setError(e.toString())); } @@ -156,27 +167,27 @@ const Vault = () => { */ const addPassword = (title, description, url, username, password) => { const id = window.crypto.randomUUID(); - const newVault = JSON.parse(JSON.stringify(vault)); - newVault.push({ - id, - title, - description, - url, - username, - password, - }); - d3(setVault(newVault)); + d3( + setVault([ + ...(vault ?? []), + { + id, + title, + description, + url, + username, + password, + }, + ]), + ); }; /** * Delete a password from the vault */ const deletePassword = () => { - const newVault = JSON.parse(JSON.stringify(vault)).filter( - (p) => p.id !== toDelete, - ); - d3(setVault(newVault)); - + d3(setVault((vault ?? []).filter((password) => password.id !== toDelete))); + setDeleteOpen(false); setToDelete(null); }; @@ -194,12 +205,18 @@ const Vault = () => { * @param id The ID of the password */ const copyToClipboard = async (id) => { - const { password } = vault.find((p) => p.id === id); + const vaultEntry = (vault ?? []).find((password) => password.id === id); + + if (!vaultEntry) { + d1(setError('Password entry not found.')); + return; + } + try { if (window.__TAURI__) { - await writeText(password); + await writeText(vaultEntry.password); } else { - await navigator.clipboard.writeText(password); + await navigator.clipboard.writeText(vaultEntry.password); } } catch (e) { d1(setError(e.toString())); @@ -233,17 +250,22 @@ const Vault = () => { * @param password The new password */ const editPassword = (id, title, description, url, username, password) => { - const newVault = JSON.parse(JSON.stringify(vault)); - newVault - .filter((e) => e.id === id) - .forEach((e) => { - e.title = title; - e.description = description; - e.url = url; - e.username = username; - e.password = password; - }); - d3(setVault(newVault)); + d3( + setVault( + (vault ?? []).map((entry) => + entry.id === id + ? { + ...entry, + title, + description, + url, + username, + password, + } + : entry, + ), + ), + ); }; /** @@ -301,63 +323,76 @@ const Vault = () => { */ const closeEncryptionKeyDialog = () => { setKeyDialogOpen(false); + setKeyAction(null); }; useEffect(() => { d1(setPageIndex(3)); document.title = 'Password Vault | Advanced PassGen'; - // eslint-disable-next-line - }, []); - - let gridItems = null; - if (vault && vault.length > 0) { - const filteredVault = - search && search.length > 0 - ? vault.filter( - (e) => - e.title.toLowerCase().includes(search.toLowerCase()) || - e.description.toLowerCase().includes(search.toLowerCase()) || - e.url.toLowerCase().includes(search.toLowerCase()) || - e.username.toLowerCase().includes(search.toLowerCase()) || - e.id.includes(search), - ) - : vault; - - if (!filteredVault || filteredVault.length === 0) { - gridItems = ( + }, [d1]); + + const filteredVault = useMemo(() => { + if (!vault || vault.length === 0) { + return []; + } + + const normalizedSearch = search.trim().toLowerCase(); + + if (!normalizedSearch) { + return vault; + } + + return vault.filter( + (entry) => + entry.title.toLowerCase().includes(normalizedSearch) || + entry.description.toLowerCase().includes(normalizedSearch) || + entry.url.toLowerCase().includes(normalizedSearch) || + entry.username.toLowerCase().includes(normalizedSearch) || + entry.id.includes(search), + ); + }, [search, vault]); + + const gridItems = + vault && vault.length > 0 ? ( + filteredVault.length === 0 ? ( {language.noResults} - ); - } else { - gridItems = filteredVault.map((item) => ( - - - - )); - } - } + ) : ( + filteredVault.map((item) => ( + + + + )) + ) + ) : null; + + const toEdit = useMemo( + () => + (vault ?? []).find((password) => password.id === editPasswordId) ?? null, + [editPasswordId, vault], + ); - const toEdit = - vault && vault.length > 0 - ? vault.filter((p) => p.id === editPasswordId)[0] - : null; + const closeDeleteDialog = () => { + setDeleteOpen(false); + setToDelete(null); + }; return ( @@ -490,8 +525,8 @@ const Vault = () => { title={language.confirmation} content={language.confirmDelete} onOk={deletePassword} - onCancel={() => setDeleteOpen(false)} - onClose={() => setDeleteOpen(false)} + onCancel={closeDeleteDialog} + onClose={closeDeleteDialog} agreeLabel={language.yes} cancelLabel={language.no} /> diff --git a/src/utils/PasswordGenerator/index.js b/src/utils/PasswordGenerator/index.js index 2fd2357..b6f07d1 100644 --- a/src/utils/PasswordGenerator/index.js +++ b/src/utils/PasswordGenerator/index.js @@ -20,6 +20,16 @@ const getRandomIntInclusive = (min, max) => { return Math.floor(randomNumber * (newMax - newMin + 1)) + newMin; }; +const getMaxUniquePasswordCount = (minLength, maxLength, graphemeCount) => { + let maxCount = 0; + + for (let current = minLength; current <= maxLength; current += 1) { + maxCount += graphemeCount ** current; + } + + return maxCount; +}; + /** * Generate an array of passwords * @param minLength The minimum length of the password @@ -39,53 +49,58 @@ export const PasswordGenerator = ( amount, allowDuplicates, ) => { + const parsedMinLength = Math.ceil(Number(minLength)); + const parsedMaxLength = Math.floor(Number(maxLength)); + const parsedAmount = Math.max(0, Math.floor(Number(amount))); + const totalCharacterSet = `${characterSet}${includeSymbols}`; + + if ( + !Number.isFinite(parsedMinLength) || + !Number.isFinite(parsedMaxLength) || + !Number.isFinite(parsedAmount) || + parsedAmount === 0 || + parsedMinLength > parsedMaxLength || + totalCharacterSet.length === 0 + ) { + return []; + } + const passwordArray = []; + const generatedPasswords = allowDuplicates ? null : new Set(); const splitter = new GraphemerConstructor(); - const totalCharacterSet = characterSet + includeSymbols; - - const graphemeCount = splitter.countGraphemes(totalCharacterSet); const graphemes = splitter.splitGraphemes(totalCharacterSet); + const graphemeCount = graphemes.length; - let maxCount = 0; - if (!allowDuplicates) { - let current = parseInt(minLength, 10); - while (current <= parseInt(maxLength, 10)) { - maxCount += Math.pow(graphemeCount, current); - current += 1; - } + if (graphemeCount === 0) { + return []; } - for (let i = 0; i < amount; i += 1) { - let canContinue = false; - - while (!canContinue) { - let password = ''; - const length = getRandomIntInclusive(minLength, maxLength); - for (let j = 0; j < length; j += 1) { - const randomBuffer = new Uint32Array(1); - globalThis.crypto.getRandomValues(randomBuffer); - - const randomNumber = randomBuffer[0] / (0xffffffff + 1); - password += graphemes[Math.floor(randomNumber * graphemeCount)]; - } - - if ( - allowDuplicates === true || - (!allowDuplicates && !passwordArray.includes(password)) - ) { - passwordArray.push(password); - canContinue = true; - } - - // We've reached the end of the line - if ( - canContinue === false && - allowDuplicates === false && - passwordArray.length === maxCount - ) { - return passwordArray; - } + const maxUniquePasswordCount = allowDuplicates + ? parsedAmount + : getMaxUniquePasswordCount( + parsedMinLength, + parsedMaxLength, + graphemeCount, + ); + const targetAmount = allowDuplicates + ? parsedAmount + : Math.min(parsedAmount, maxUniquePasswordCount); + + while (passwordArray.length < targetAmount) { + let password = ''; + const length = getRandomIntInclusive(parsedMinLength, parsedMaxLength); + + for (let index = 0; index < length; index += 1) { + password += graphemes[getRandomIntInclusive(0, graphemeCount - 1)]; } + + if (generatedPasswords?.has(password)) { + continue; + } + + generatedPasswords?.add(password); + passwordArray.push(password); } + return passwordArray; };