diff --git a/src/password_manager/application/__init__.py b/src/password_manager/application/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/application/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/application/ports.py b/src/password_manager/application/ports.py new file mode 100644 index 0000000..952b5d6 --- /dev/null +++ b/src/password_manager/application/ports.py @@ -0,0 +1,31 @@ +"""@brief Ports the use cases need from the outside. + +@file ports.py +""" + +from typing import Protocol + +from password_manager.domain.password_entry import PasswordEntry + + +class PasswordRepository(Protocol): + """@brief Storage of password entries.""" + + def add(self, entry: PasswordEntry) -> None: + """@brief Store an entry, keeping every other entry. + + @param entry The entry to store; replaces one for the same website. + @throws InvalidDataFileError The stored data has an unexpected format. + """ + ... + + def find(self, website: str) -> PasswordEntry: + """@brief Look an entry up. + + @param website The website to look for. + @return The stored entry. + @throws DataFileNotFoundError There is no storage yet. + @throws EntryNotFoundError The website has no entry. + @throws InvalidDataFileError The stored data has an unexpected format. + """ + ... diff --git a/src/password_manager/application/use_cases.py b/src/password_manager/application/use_cases.py new file mode 100644 index 0000000..e324226 --- /dev/null +++ b/src/password_manager/application/use_cases.py @@ -0,0 +1,76 @@ +"""@brief Use cases of the password manager. + +@file use_cases.py +""" + +import random + +from password_manager.application.ports import PasswordRepository +from password_manager.domain.errors import EmptyFieldError +from password_manager.domain.password_entry import PasswordEntry +from password_manager.domain.password_generator import generate_password + + +class SavePasswordEntry: + """@brief Validate and store a password entry.""" + + def __init__(self, repository: PasswordRepository) -> None: + """@brief Create the use case. + + @param repository Where entries are stored. + """ + self._repository = repository + + def execute(self, website: str, email: str, password: str) -> None: + """@brief Store the entry. + + @param website Website of the entry. + @param email E-mail address or user name. + @param password The password. + @throws EmptyFieldError A field is empty. + """ + if not (website.strip() and email.strip() and password.strip()): + raise EmptyFieldError + self._repository.add(PasswordEntry(website.strip(), email, password)) + + +class FindPasswordEntry: + """@brief Look up the entry of a website.""" + + def __init__(self, repository: PasswordRepository) -> None: + """@brief Create the use case. + + @param repository Where entries are stored. + """ + self._repository = repository + + def execute(self, website: str) -> PasswordEntry: + """@brief Find the entry. + + @param website Website to look for. + @return The stored entry. + @throws EmptyFieldError The website is empty. + @throws DataFileNotFoundError There is no storage yet. + @throws EntryNotFoundError The website has no entry. + """ + if not website.strip(): + raise EmptyFieldError + return self._repository.find(website.strip()) + + +class GeneratePassword: + """@brief Produce a new random password.""" + + def __init__(self, rng: random.Random | None = None) -> None: + """@brief Create the use case. + + @param rng Random source; secure by default. + """ + self._rng = rng + + def execute(self) -> str: + """@brief Generate a password. + + @return The new password. + """ + return generate_password(self._rng) diff --git a/src/password_manager/constants.py b/src/password_manager/constants.py index 10e400b..751ba65 100644 --- a/src/password_manager/constants.py +++ b/src/password_manager/constants.py @@ -16,6 +16,7 @@ DATA_FILE_PATH = Path( JSON_INDENT = 4 ##< Indentation of the written JSON, for readability. KEY_EMAIL = "email" ##< JSON key of an entry's e-mail address. KEY_PASSWORD = "password" ##< JSON key of an entry's password. +FILE_ENCODING = "utf-8" ##< Text encoding of the data file. # Password generator LETTERS = string.ascii_letters ##< Letters a password may use. @@ -36,6 +37,17 @@ DEFAULT_EMAIL = "name@example.com" ##< E-mail address the form starts with. ENTRY_WIDTH_WIDE = 35 ##< Width in characters of the e-mail and add fields. ENTRY_WIDTH_NARROW = 21 ##< Width in characters of the website and password fields. +# Logo (a padlock drawn with canvas shapes) +LOGO_BACKGROUND = "white" ##< Canvas background colour. +LOGO_BODY_COLOR = "#2b6cb0" ##< Fill of the padlock body. +LOGO_SHACKLE_COLOR = "#4a5568" ##< Outline colour of the padlock shackle. +LOGO_KEYHOLE_COLOR = "white" ##< Fill of the keyhole. +LOGO_SHACKLE_WIDTH = 12 ##< Line width of the shackle, in pixels. +LOGO_BODY_BOX = (50, 100, 150, 180) ##< Padlock body rectangle (x0, y0, x1, y1). +LOGO_SHACKLE_BOX = (65, 30, 135, 170) ##< Bounding box of the shackle arc. +LOGO_KEYHOLE_BOX = (92, 125, 108, 141) ##< Bounding box of the keyhole circle. +LOGO_KEYHOLE_SLOT = (97, 138, 103, 160) ##< Rectangle of the keyhole slot. + # Labels and messages LABEL_WEBSITE = "Website:" LABEL_EMAIL = "Email/Username:" @@ -54,3 +66,5 @@ MESSAGE_CONFIRM_SAVE = ( "Password: {password}\nIs it ok to save?" ) MESSAGE_DETAILS = "Email: {email}\nPassword: {password}" +MESSAGE_INVALID_DATA_FILE = "The data file has an unexpected format." +MESSAGE_SAVED = "Details for {website} were saved." diff --git a/src/password_manager/domain/__init__.py b/src/password_manager/domain/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/domain/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/domain/errors.py b/src/password_manager/domain/errors.py new file mode 100644 index 0000000..6f6c391 --- /dev/null +++ b/src/password_manager/domain/errors.py @@ -0,0 +1,32 @@ +"""@brief Errors raised by the business rules of the password manager. + +@file errors.py +""" + + +class PasswordManagerError(Exception): + """@brief Base class of every error the password manager raises on purpose.""" + + +class EmptyFieldError(PasswordManagerError): + """@brief A required field was left empty.""" + + +class DataFileNotFoundError(PasswordManagerError): + """@brief The data file does not exist, so nothing can be looked up.""" + + +class InvalidDataFileError(PasswordManagerError): + """@brief The data file exists but does not hold the expected structure.""" + + +class EntryNotFoundError(PasswordManagerError): + """@brief No entry is stored for a website.""" + + def __init__(self, website: str) -> None: + """@brief Create the error. + + @param website The website that was looked up. + """ + super().__init__(website) + self.website = website diff --git a/src/password_manager/domain/password_entry.py b/src/password_manager/domain/password_entry.py new file mode 100644 index 0000000..611aae2 --- /dev/null +++ b/src/password_manager/domain/password_entry.py @@ -0,0 +1,15 @@ +"""@brief The password entry of the domain. + +@file password_entry.py +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PasswordEntry: + """@brief The credentials stored for one website.""" + + website: str ##< The website the credentials belong to. + email: str ##< E-mail address or user name. + password: str ##< The password. diff --git a/src/password_manager/domain/password_generator.py b/src/password_manager/domain/password_generator.py new file mode 100644 index 0000000..2307281 --- /dev/null +++ b/src/password_manager/domain/password_generator.py @@ -0,0 +1,29 @@ +"""@brief Generation of random passwords. + +@file password_generator.py +""" + +import random + +from password_manager import constants + + +def generate_password(rng: random.Random | None = None) -> str: + """@brief Build a shuffled password of letters, symbols and digits. + + @param rng Random source; a cryptographically secure one when omitted. + @return The generated password; each class count is within its bounds. + """ + source = rng if rng is not None else random.SystemRandom() + characters = ( + _pick(source, constants.LETTERS, constants.MIN_LETTERS, constants.MAX_LETTERS) + + _pick(source, constants.SYMBOLS, constants.MIN_SYMBOLS, constants.MAX_SYMBOLS) + + _pick(source, constants.DIGITS, constants.MIN_DIGITS, constants.MAX_DIGITS) + ) + source.shuffle(characters) + return "".join(characters) + + +def _pick(source: random.Random, alphabet: str, low: int, high: int) -> list[str]: + count = source.randint(low, high) + return [source.choice(alphabet) for _ in range(count)] diff --git a/src/password_manager/infrastructure/__init__.py b/src/password_manager/infrastructure/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/infrastructure/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/infrastructure/persistence/__init__.py b/src/password_manager/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..7bd870c --- /dev/null +++ b/src/password_manager/infrastructure/persistence/__init__.py @@ -0,0 +1 @@ +"""@brief Package marker.""" diff --git a/src/password_manager/infrastructure/persistence/json_password_repository.py b/src/password_manager/infrastructure/persistence/json_password_repository.py new file mode 100644 index 0000000..e0a1fba --- /dev/null +++ b/src/password_manager/infrastructure/persistence/json_password_repository.py @@ -0,0 +1,79 @@ +"""@brief Password repository that keeps entries in a JSON file. + +@file json_password_repository.py +""" + +import json +from pathlib import Path +from typing import Any + +from password_manager import constants +from password_manager.domain.errors import ( + DataFileNotFoundError, + EntryNotFoundError, + InvalidDataFileError, +) +from password_manager.domain.password_entry import PasswordEntry + + +class JsonPasswordRepository: + """@brief Stores entries as {website: {"email": ..., "password": ...}}.""" + + def __init__(self, path: Path) -> None: + """@brief Create the repository. + + @param path Location of the JSON file. + """ + self._path = path + + def add(self, entry: PasswordEntry) -> None: + """@brief Store an entry; a missing or empty file is created or rewritten. + + @param entry The entry to store. + @throws InvalidDataFileError The file holds JSON that is not an object. + """ + try: + data = self._load() + except (FileNotFoundError, json.JSONDecodeError): + data = {} + data[entry.website] = { + constants.KEY_EMAIL: entry.email, + constants.KEY_PASSWORD: entry.password, + } + with self._path.open("w", encoding=constants.FILE_ENCODING, newline="\n") as f: + json.dump(data, f, indent=constants.JSON_INDENT) + + def find(self, website: str) -> PasswordEntry: + """@brief Look an entry up. + + @param website Website to look for. + @return The stored entry. + @throws DataFileNotFoundError The file does not exist. + @throws EntryNotFoundError The website has no entry (or the file is empty). + @throws InvalidDataFileError The file has an unexpected structure. + """ + try: + data = self._load() + except FileNotFoundError as error: + raise DataFileNotFoundError from error + except json.JSONDecodeError as error: + raise EntryNotFoundError(website) from error + if website not in data: + raise EntryNotFoundError(website) + details = data[website] + try: + return PasswordEntry( + website, + details[constants.KEY_EMAIL], + details[constants.KEY_PASSWORD], + ) + except (KeyError, TypeError) as error: + raise InvalidDataFileError from error + + # Any: the values of a JSON object are unchecked until `find` validates them. + def _load(self) -> dict[str, Any]: + with self._path.open("r", encoding=constants.FILE_ENCODING) as f: + data = json.load(f) + if not isinstance(data, dict): + raise InvalidDataFileError + return data