diff --git a/.gitignore b/.gitignore
index 5b1e6a4a..52c5c048 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,7 @@
.worktrees/
DEMO-TRACKER.md
-PROJECTS/beginner/hash-cracker/docs/
-PROJECTS/advanced/monitor-the-situation-dashboard/docs/
+/docs/
# Python virtualenvs and caches
.venv/
diff --git a/PROJECTS/foundations/hash-identifier/.gitignore b/PROJECTS/foundations/hash-identifier/.gitignore
new file mode 100644
index 00000000..834acecb
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/.gitignore
@@ -0,0 +1,70 @@
+# ©AngelaMos | 2026
+# .gitignore
+#
+# This file lists patterns of files and folders that git should NEVER
+# track. Anything matching a pattern below is invisible to `git status`
+# and won't be committed. We do this to avoid two problems:
+#
+# 1. Privacy — we never want to accidentally commit a sensitive file
+# a user might pass to the tool.
+#
+# 2. Noise — build artifacts and caches change constantly. Tracking
+# them would clutter every commit and create merge conflicts.
+#
+# Each line is a "glob" pattern (* matches any characters).
+
+# -----------------------------------------------------------------------------
+# Python build/install artifacts
+# -----------------------------------------------------------------------------
+# __pycache__: bytecode Python generates from .py files when it runs them.
+# We never edit these, and they regenerate on every run, so ignore them.
+__pycache__/
+# *.py[cod] = .pyc, .pyo, .pyd — older bytecode formats Python may emit.
+*.py[cod]
+*$py.class
+# Build outputs from `pip wheel`, hatchling, etc.
+*.egg-info/
+*.egg
+build/
+dist/
+.eggs/
+*.so
+
+# -----------------------------------------------------------------------------
+# Virtual environments — created by `uv venv`, never committed
+# -----------------------------------------------------------------------------
+# A virtual environment is a per-project Python install. It lives inside
+# the project directory. Committing it would balloon the repo and pin
+# the user to your operating system. Always recreate locally.
+.venv/
+venv/
+env/
+
+# -----------------------------------------------------------------------------
+# Tooling caches — these speed up repeated runs but never need committing
+# -----------------------------------------------------------------------------
+.mypy_cache/
+.ruff_cache/
+.pytest_cache/
+# Coverage report files from pytest-cov.
+.coverage
+htmlcov/
+
+# -----------------------------------------------------------------------------
+# IDE / editor scratch files
+# -----------------------------------------------------------------------------
+# Each editor drops its own metadata. None of it belongs in the repo.
+.vscode/
+.idea/
+*.swp
+*.swo
+# macOS Finder dumps these in every directory it has opened.
+.DS_Store
+
+# -----------------------------------------------------------------------------
+# Local environment files (may contain secrets)
+# -----------------------------------------------------------------------------
+# We never use .env in this project, but list it just in case. Anything
+# containing secrets should never reach the public repo.
+.env
+.env.local
diff --git a/PROJECTS/foundations/hash-identifier/.style.yapf b/PROJECTS/foundations/hash-identifier/.style.yapf
new file mode 100644
index 00000000..e3340a5e
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/.style.yapf
@@ -0,0 +1,104 @@
+# ©AngelaMos | 2026
+# .style.yapf
+#
+# Configuration for yapf, our Python code formatter. yapf rewrites
+# Python files to match these rules so every file looks identical
+# no matter who wrote it.
+#
+# The two settings most worth knowing:
+# based_on_style = pep8 → start from PEP 8 (the official style)
+# column_limit = 75 → wrap lines at 75 characters
+#
+# Run with: `just format`
+[style]
+# Start from PEP 8 (Python's official style guide). Every other
+# setting below tweaks PEP 8 in some specific direction.
+based_on_style = pep8
+# Wrap any line longer than this many columns.
+column_limit = 75
+# Use 4 spaces per indent level (PEP 8 standard).
+indent_width = 4
+# Continuation lines (when a statement wraps) also indent by 4.
+continuation_indent_width = 4
+# Don't indent the closing bracket of a multi-line call/list/dict
+indent_closing_brackets = false
+# DO de-indent the closing bracket — line it up with the opener.
+dedent_closing_brackets = true
+# Don't add 4 spaces of indent to blank lines inside functions.
+indent_blank_lines = false
+# Two spaces between code and the # of a trailing comment.
+spaces_before_comment = 2
+# No spaces around ** (power) operator: 2**3 not 2 ** 3.
+spaces_around_power_operator = false
+# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
+spaces_around_default_or_named_assign = true
+# No extra space before a comma followed by a closing bracket.
+space_between_ending_comma_and_closing_bracket = false
+# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
+space_inside_brackets = false
+# Spaces around colons in slices: x[1 : 5] not x[1:5].
+spaces_around_subscript_colon = true
+# No blank line before nested def/class.
+blank_line_before_nested_class_or_def = false
+# No blank line between a class and its docstring.
+blank_line_before_class_docstring = false
+# Two blank lines around top-level functions and classes (PEP 8).
+blank_lines_around_top_level_definition = 2
+# Two blank lines between top-level imports and module-level variables.
+blank_lines_between_top_level_imports_and_variables = 2
+# No blank line before the module docstring.
+blank_line_before_module_docstring = false
+# When wrapping `a and b`, put `and` at the start of the new line.
+split_before_logical_operator = true
+# When wrapping a function call, put the first argument on a new line.
+split_before_first_argument = true
+# Put each named arg on its own line when wrapping.
+split_before_named_assigns = true
+# Multi-line comprehensions wrap onto separate lines.
+split_complex_comprehension = true
+# Don't split right after the opening parenthesis.
+split_before_expression_after_opening_paren = false
+# Put the closing bracket on its own line when wrapping.
+split_before_closing_bracket = true
+# When splitting at commas, give every value its own line.
+split_all_comma_separated_values = true
+# But don't recursively force ALL top-level commas onto new lines.
+split_all_top_level_comma_separated_values = false
+# Don't merge separate brackets into one.
+coalesce_brackets = false
+# Each dict entry gets its own line in multi-line dicts.
+each_dict_entry_on_separate_line = true
+# Disallow lambdas that span multiple lines.
+allow_multiline_lambdas = false
+# Disallow dict keys that span multiple lines.
+allow_multiline_dictionary_keys = false
+# Penalty score for splitting at import names — 0 means free to split.
+split_penalty_import_names = 0
+# Don't try to fit an already-broken statement back onto fewer lines.
+join_multiple_lines = false
+# Closing bracket lines up with the visual indent of the opener.
+align_closing_bracket_with_visual_indent = true
+# Don't add parentheses to clarify operator precedence automatically.
+arithmetic_precedence_indication = false
+# Penalty for splitting after each line — yapf weighs this against rules.
+split_penalty_for_added_line_split = 275
+# Use spaces for indentation, never tabs (PEP 8).
+use_tabs = false
+# Don't split on a `.` when wrapping (so `obj.method()` stays together).
+split_before_dot = false
+# When the last argument in a call has a trailing comma, force a split.
+split_arguments_when_comma_terminated = true
+# Function names yapf treats as i18n calls — leave their args alone.
+i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
+# Comment markers yapf treats as i18n metadata.
+i18n_comment = ['# Translators:', '# i18n:']
+# Penalty for splitting comprehension iterables.
+split_penalty_comprehension = 80
+# Penalty for splitting right after an opening bracket.
+split_penalty_after_opening_bracket = 280
+# Penalty for splitting before an `if` in a ternary expression.
+split_penalty_before_if_expr = 0
+# Penalty for splitting before a bitwise operator.
+split_penalty_bitwise_operator = 290
+# Penalty for splitting before a logical operator (already lowered).
+split_penalty_logical_operator = 0
diff --git a/PROJECTS/foundations/hash-identifier/LICENSE b/PROJECTS/foundations/hash-identifier/LICENSE
new file mode 100644
index 00000000..0ad25db4
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/PROJECTS/foundations/hash-identifier/README.md b/PROJECTS/foundations/hash-identifier/README.md
new file mode 100644
index 00000000..5e23ac78
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/README.md
@@ -0,0 +1,109 @@
+```ruby
+██╗ ██╗ █████╗ ███████╗██╗ ██╗ ██╗██████╗
+██║ ██║██╔══██╗██╔════╝██║ ██║ ██║██╔══██╗
+███████║███████║███████╗███████║ ██║██║ ██║
+██╔══██║██╔══██║╚════██║██╔══██║ ██║██║ ██║
+██║ ██║██║ ██║███████║██║ ██║ ██║██████╔╝
+╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝╚═════╝
+```
+
+[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/hash-identifier)
+[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
+[](https://www.python.org)
+[](https://www.gnu.org/licenses/agpl-3.0)
+[](https://pytest.org)
+[](https://github.com/astral-sh/ruff)
+
+> Identify the algorithm behind a hash string by its prefix, length, and character set — the first move in any password-cracking workflow.
+
+*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
+
+> [!NOTE]
+> **Foundations tier** — this project is built for someone who has never written Python before. The source code is heavily commented as a teaching aid, the `learn/` folder explains every concept from zero, and the whole tool is one readable file. If you already know Python, jump straight to [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural cracking companion to this identifier.
+
+## What It Does
+
+- Identify ~30 hash formats by prefix (`$2b$`, `$argon2id$`, `$apr1$`, `pbkdf2_sha256$`, `{SSHA}`, and more)
+- Identify common hex hashes by length (MD5, SHA-1, SHA-256, SHA-512, NTLM, MD4, RIPEMD, BLAKE2, SHA-3)
+- Recognize MySQL5, NetNTLMv1/v2, and traditional 13-char DES crypt by shape
+- Detect non-hash inputs (JWTs, base64 blobs) and tell the user what they actually pasted
+- Return ranked candidates with `high` / `medium` / `low` confidence and a one-line *reason* for every guess
+- Pure-function core — no network, no filesystem, no global state, instant runtime
+- Rich-rendered colored output table; clean exit codes for shell scripting
+
+## Quick Start
+
+```bash
+./install.sh
+just run -- 5f4dcc3b5aa765d61d8327deb882cf99
+# ✔ MD5 (medium) — 32 hex chars, most likely candidate at this length
+```
+
+> [!TIP]
+> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
+>
+> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
+
+## Demo Hashes
+
+Try these — each demonstrates a different identification path:
+
+| Hash | Detected as | Why |
+|------|-------------|-----|
+| `5f4dcc3b5aa765d61d8327deb882cf99` | MD5 | 32 hex chars — most likely candidate at this length |
+| `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | SHA-256 | 64 hex chars — most likely candidate at this length |
+| `$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK` | bcrypt | prefix `$2b$` — bcrypt PHC string, 2b variant (current) |
+| `$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG` | Argon2id | prefix `$argon2id$` — modern PHC string, the current standard |
+| `$apr1$JlOdSlVe$ipa1mTAv3LFRBHHzqaIaH/` | Apache MD5-crypt | prefix `$apr1$` — Apache htpasswd MD5 variant (`htpasswd -m`) |
+| `*A4B6157319038724E3560894F7F932C8886EBFCF` | MySQL5 | starts with `*` followed by 40 uppercase hex chars |
+| `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgN...` | JWT (not a hash) | leading `eyJ` is base64 of `{"` — JWT, not a hash |
+
+```bash
+just run -- 5f4dcc3b5aa765d61d8327deb882cf99
+just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK'
+just run -- '*A4B6157319038724E3560894F7F932C8886EBFCF'
+just run -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+```
+
+> [!IMPORTANT]
+> Always wrap hashes that begin with `$` in **single quotes**. Without quotes your shell will try to expand `$2`, `$P$`, `$1$` etc. as shell variables and silently mangle the input.
+
+## Tooling
+
+```bash
+just # list available recipes
+just test # run pytest (30+ tests, runs in under a second)
+just lint # ruff + mypy --strict + pylint
+just format # yapf
+just run -- # identify a hash
+```
+
+## Requirements
+
+- **Python 3.14+** — the install script will check.
+- [`uv`](https://github.com/astral-sh/uv) — modern Python package manager (auto-installed by `./install.sh`).
+- [`just`](https://github.com/casey/just) — command runner (auto-installed by `./install.sh`).
+
+No compilers, no system libraries, no network access required. The project is one Python file plus tests.
+
+## Learn
+
+This project includes step-by-step learning materials covering security theory, architecture, and implementation — written for someone who has never touched Python before.
+
+| Module | Topic |
+|--------|-------|
+| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, common problems |
+| [01 - Concepts](learn/01-CONCEPTS.md) | What hashes are, real-world breaches, the three identification signals |
+| [02 - Architecture](learn/02-ARCHITECTURE.md) | Three-layer architecture, six-step decision pipeline, data-driven design |
+| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Line-by-line walkthrough — every Python feature explained when first encountered |
+| [04 - Challenges](learn/04-CHALLENGES.md) | Five tiers of extension ideas, from adding a prefix rule to building an ML classifier |
+
+## See Also
+
+- [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural sibling. Once this tool tells you *what* a hash is, that one teaches you how to crack it.
+- [`PROJECTS/foundations/http-headers-scanner`](../http-headers-scanner) — another foundations-tier Python project, slightly more involved I/O.
+- [`PROJECTS/foundations/password-manager`](../password-manager) — the hardest foundations-tier project; covers Argon2id, AES-GCM, and on-disk vaults.
+
+## License
+
+AGPL 3.0
diff --git a/PROJECTS/foundations/hash-identifier/hash_identifier.py b/PROJECTS/foundations/hash-identifier/hash_identifier.py
new file mode 100644
index 00000000..b76547d6
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/hash_identifier.py
@@ -0,0 +1,679 @@
+"""
+©AngelaMos | 2026
+hash_identifier.py
+
+Identify what kind of hash a string is, by inspecting its shape
+
+When someone hands you a string of gibberish like
+`5f4dcc3b5aa765d61d8327deb882cf99` or `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...`,
+the first question is: what algorithm produced it? You can NOT crack
+or analyze a hash without knowing what flavor of hash you are looking
+at. Every cracking tool — hashcat, john the ripper — needs you to
+specify an algorithm before it will start
+
+This script does the looking-at part. Given a hash string, it returns
+ranked candidates with a confidence score and a short reason
+
+────────────────────────────────────────────────────────────────────
+How identification actually works
+────────────────────────────────────────────────────────────────────
+There is no magic here. Hash strings carry shape clues
+
+ 1. PREFIX. Many modern hashes are stored in "PHC string format" —
+ a self-describing format that starts with a marker like `$2b$`
+ (bcrypt) or `$argon2id$` (Argon2id). When we see a known prefix
+ we know the algorithm with HIGH confidence
+
+ 2. LENGTH. Raw hex output of a hash function is always the same
+ length: MD5 produces 16 bytes = 32 hex chars; SHA-1 produces 20
+ bytes = 40 hex chars; SHA-256 produces 32 bytes = 64 hex chars,
+ and so on. Length alone narrows the field
+
+ 3. CHARSET. Different formats use different alphabets. Hex hashes
+ use only 0-9 and a-f. Base64 uses 0-9, A-Z, a-z, +, /, and =.
+ A string with `+` in it is not a hex hash
+
+So our algorithm is: try prefix rules first, fall back to length +
+charset rules, return the candidates ranked by confidence
+
+────────────────────────────────────────────────────────────────────
+What this script can and cannot do
+────────────────────────────────────────────────────────────────────
+CAN: suggest likely algorithms for a hash you found
+CANNOT: tell you the password the hash was made from
+ (that is hashcat's job — see ../../beginner/hash-cracker)
+
+────────────────────────────────────────────────────────────────────
+What this file exposes
+────────────────────────────────────────────────────────────────────
+ HashCandidate — one ranked guess (algorithm, confidence, reason)
+ identify(text) — return ranked candidates for a hash string
+ main() — CLI entry point used by `hashid `
+"""
+
+# Standard library: parse command-line flags like `--top 3` into a
+# nice object so we do not have to slice `sys.argv` by hand.
+import argparse
+
+# Standard library: access to interpreter internals — we use it to
+# write to stderr and to exit the process with a specific status code.
+import sys
+
+# Standard library: a decorator that turns a class into a small,
+# immutable data record without writing `__init__` boilerplate.
+from dataclasses import dataclass
+
+# Standard library: a type hint that pins a value to a small fixed
+# set of strings (here: "high" / "medium" / "low"). Mypy catches typos.
+from typing import Literal
+
+# Third-party (rich): the printer that actually draws the table to
+# the terminal, with color and Unicode support.
+from rich.console import Console
+
+# Third-party (rich): builds the colored ASCII table we print for
+# ranked hash candidates.
+from rich.table import Table
+
+# =============================================================================
+# Confidence type — only three valid values
+# =============================================================================
+# Literal["high", "medium", "low"] is a type hint that says "this string
+# can ONLY be one of these three values." Mypy will catch typos like
+# "hgih" at edit time. We chose Literal over an Enum because Carter's
+# style guide prefers Literals for small fixed sets
+
+Confidence = Literal["high", "medium", "low"]
+
+
+# =============================================================================
+# Result type — what identify() returns for each guess
+# =============================================================================
+
+
+@dataclass(frozen = True, slots = True)
+class HashCandidate:
+ """
+ One possible identification of a hash string
+
+ `frozen=True` makes the dataclass immutable — once created, its
+ fields cannot change. `slots=True` makes instances lightweight in
+ memory. Together these two flags create a clean "value object"
+
+ Fields
+ ------
+ algorithm
+ Human-readable algorithm name like "SHA-256" or "bcrypt"
+ confidence
+ How sure we are. "high" comes from definitive prefix matches,
+ "medium" from length matches that have only one obvious
+ candidate, "low" for length matches that could be many things
+ reason
+ Short explanation we display next to the algorithm name. Keeps
+ the output debuggable — the user can see WHY each guess fired
+ """
+ algorithm: str
+ confidence: Confidence
+ reason: str
+
+
+# =============================================================================
+# Prefix rules — strongest signal we have
+# =============================================================================
+# Modern hashes use PHC-style strings: a leading `$` marker tells you
+# exactly what algorithm produced the hash. When we see one of these
+# prefixes we report HIGH confidence. The third element of each tuple
+# is a short note we include in the reason field
+#
+# Order matters when prefixes overlap. We list more specific prefixes
+# FIRST so they match before generic ones (e.g. `$argon2id$` before
+# `$argon2$` would matter if both existed in the table)
+
+PREFIX_RULES: list[tuple[str, str, str]] = [
+ # Argon2 family — won the 2015 Password Hashing Competition
+ ("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
+ ("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
+ ("$argon2d$", "Argon2d", "PHC string, GPU-resistant variant"),
+
+ # bcrypt and its many variants — workhorse for the past 15 years
+ ("$2y$", "bcrypt", "bcrypt PHC string, 2y variant (PHP)"),
+ ("$2b$", "bcrypt", "bcrypt PHC string, 2b variant (current)"),
+ ("$2a$", "bcrypt", "bcrypt PHC string, 2a variant (legacy)"),
+ ("$2x$", "bcrypt", "bcrypt PHC string, 2x variant (legacy fix)"),
+
+ # Unix crypt(3) family — what /etc/shadow uses on Linux
+ ("$6$", "SHA-512 crypt", "Unix crypt(3) using SHA-512 (default on Linux)"),
+ ("$5$", "SHA-256 crypt", "Unix crypt(3) using SHA-256"),
+ ("$1$", "MD5 crypt", "Unix crypt(3) using MD5 (legacy, weak)"),
+
+ # Apache htpasswd MD5 variant — same MD5 family as $1$ above but
+ # with Apache's own salt-handling tweak. This is the format that
+ # `htpasswd -m` emits by default, which makes it FAR more common
+ # in the wild than the Unix $1$ form (every basic-auth tutorial
+ # ends with one of these in a .htpasswd file)
+ ("$apr1$", "Apache MD5-crypt", "Apache htpasswd MD5 variant (`htpasswd -m`)"),
+
+ # yescrypt — newer Linux default in some distributions
+ ("$y$", "yescrypt", "PHC string, modern Linux crypt successor"),
+
+ # phpass — used by WordPress, phpBB, and other PHP apps
+ ("$P$", "phpass", "WordPress / phpBB password hash"),
+ ("$H$", "phpass", "phpBB-style phpass variant"),
+
+ # Drupal 7
+ ("$S$", "Drupal 7 (SHA-512)", "Drupal 7 PHC-style hash"),
+
+ # scrypt as some implementations encode it
+ ("$7$", "scrypt", "scrypt PHC-style hash"),
+
+ # Django's default — recognizable by the algorithm name in the prefix
+ ("pbkdf2_sha256$", "Django PBKDF2-SHA256", "Django default password hash"),
+ ("pbkdf2_sha1$", "Django PBKDF2-SHA1", "Django legacy password hash"),
+ ("bcrypt_sha256$", "Django bcrypt-SHA256", "Django bcrypt wrapper"),
+ ("argon2$", "Django Argon2", "Django Argon2 wrapper"),
+
+ # LDAP password schemes — base64 payload after the marker
+ ("{SSHA}", "LDAP SSHA", "LDAP salted SHA-1 (base64 payload)"),
+ ("{SHA}", "LDAP SHA", "LDAP SHA-1 (base64 payload)"),
+ ("{SMD5}", "LDAP SMD5", "LDAP salted MD5 (base64 payload)"),
+ ("{MD5}", "LDAP MD5", "LDAP MD5 (base64 payload)"),
+ ("{CRYPT}", "LDAP CRYPT", "LDAP wrapping a crypt(3) hash"),
+]
+
+
+# =============================================================================
+# Length-and-hex rules — fallback when no prefix matched
+# =============================================================================
+# Raw hash output is always the same length, so a string of N hex chars
+# narrows down the algorithm. The list of algorithms for each length is
+# sorted by REAL-WORLD prevalence. The first item gets MEDIUM confidence
+# (the "most likely default"), the rest LOW (still possible)
+
+# Hex chars are 0-9 plus a-f (or A-F if uppercase)
+HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
+
+# Uppercase-only hex charset — some formats (MySQL5) ONLY ever emit
+# uppercase hex because they print via the `%02X` C format specifier,
+# so checking membership in this tighter charset lets us reject
+# lowercase strings as false-positives instead of confidently saying
+# "yes that is MySQL5" to an obviously hand-edited input
+_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
+
+# Length-in-hex-chars → list of algorithms, ordered by commonality
+HEX_LENGTH_RULES: dict[int, list[str]] = {
+ # 16 hex chars = 8 bytes = 64 bits. This is the pre-MySQL-4.1
+ # OLD_PASSWORD() output — still produced today by MySQL's
+ # OLD_PASSWORD() SQL function for legacy compatibility, and
+ # still appears in CTFs and old MySQL breach dumps. The only
+ # other thing that produces 16 hex chars in a security context
+ # is a 64-bit CRC, which is rare enough that MySQL323 outranks it
+ 16: ["MySQL323", "CRC-64"],
+ # 32 hex chars = 16 bytes = 128 bits
+ 32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
+ # 40 hex chars = 20 bytes = 160 bits
+ 40: ["SHA-1", "RIPEMD-160"],
+ # 48 hex chars = 24 bytes = 192 bits
+ 48: ["Tiger-192"],
+ # 56 hex chars = 28 bytes = 224 bits
+ 56: ["SHA-224", "SHA3-224"],
+ # 64 hex chars = 32 bytes = 256 bits
+ 64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
+ # 80 hex chars = 40 bytes = 320 bits (uncommon)
+ 80: ["RIPEMD-320"],
+ # 96 hex chars = 48 bytes = 384 bits
+ 96: ["SHA-384", "SHA3-384"],
+ # 128 hex chars = 64 bytes = 512 bits
+ 128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
+}
+
+
+# =============================================================================
+# Helpers
+# =============================================================================
+
+
+def _is_hex(text: str) -> bool:
+ """
+ Return True iff every character in text is a hex digit and text is non-empty
+
+ A hash like "5f4dcc..." passes; an empty string or anything with
+ a non-hex character fails. We use the HEX_CHARSET frozenset for
+ membership tests because `c in frozenset` is O(1) lookup, faster
+ than `c in "0123456789abcdef..."` for big inputs
+ """
+ return bool(text) and all(c in HEX_CHARSET for c in text)
+
+
+# MySQL5 layout: `*` followed by 40 uppercase hex chars.
+# Pulling both numbers out as named constants keeps the helper below
+# from reading like sprinkled-in magic numbers — `_MYSQL5_TOTAL_LENGTH`
+# tells the reader WHY the helper compares to 41 (40 hex chars + the
+# leading `*`) instead of leaving 41 unexplained
+_MYSQL5_HEX_BODY_LENGTH = 40
+_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
+
+
+def _is_mysql5(text: str) -> bool:
+ """
+ Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars
+
+ MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
+ a leading `*`. Real MySQL5 output uses the `%02X` C format
+ specifier, which is uppercase-only. We reject lowercase here so
+ we do not return a confident HIGH-confidence "MySQL5" verdict on
+ a hand-edited or mistyped string — better to fall through to
+ no-match than to lie with conviction
+
+ NOTE: we cannot just call `body.isupper()` to enforce the case.
+ Python's `str.isupper` returns False when a string contains NO
+ cased characters at all, which would wrongly reject an all-digit
+ hex body like "0123456789ABCDEF..." with no letters in it.
+ Checking membership in an uppercase-only charset is the test
+ that actually matches the spec
+ """
+ if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
+ return False
+ body = text[1 :]
+ return all(c in _HEX_UPPER_CHARSET for c in body)
+
+
+# Traditional DES crypt — legacy /etc/passwd hashes from pre-shadow
+# Unix systems. They have no prefix at all: just 13 characters drawn
+# from a specific 64-char alphabet (the same alphabet used by all
+# crypt(3) variants for their base64-style output). We pull both the
+# charset and the expected length out as named constants so the helper
+# below reads like its meaning instead of like sprinkled magic numbers
+_DESCRYPT_CHARSET: frozenset[str] = frozenset(
+ "./0123456789"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz"
+)
+_DESCRYPT_TOTAL_LENGTH = 13
+
+
+def _is_descrypt(text: str) -> bool:
+ """
+ Return True for traditional 13-char DES crypt (legacy /etc/passwd)
+
+ No prefix at all — just 13 characters drawn from `./0-9A-Za-z`.
+ Old enough that you mostly see it in retro CTFs now, but real
+ enough that hashcat still ships a mode (1500) for cracking it.
+ We report MEDIUM (not HIGH) confidence on a match because a 13-char
+ string in that charset CAN be other things (some session IDs,
+ encoded values), and a beginner deserves an honest confidence
+ level rather than a false-positive at HIGH
+ """
+ return (
+ len(text) == _DESCRYPT_TOTAL_LENGTH
+ and all(c in _DESCRYPT_CHARSET for c in text)
+ )
+
+
+# =============================================================================
+# The actual identifier
+# =============================================================================
+# pylint flags this function for having many return statements and many
+# branches. That is the cost of writing the function as six numbered,
+# linear steps — each step gets its own short block and returns the
+# moment it finds a match, which is the structure that maps cleanly
+# onto the docstring. Refactoring into helper functions would obscure
+# the pedagogical line-by-line flow, so we accept the extra returns
+# here as a deliberate teaching choice and silence the two warnings
+# pylint: disable=too-many-return-statements,too-many-branches
+
+
+def identify(raw_input: str) -> list[HashCandidate]:
+ """
+ Return ranked candidates for what algorithm produced `raw_input`
+
+ Algorithm
+ ---------
+ Whitespace is trimmed from `raw_input` first. Then the six
+ matching steps below run in order — each step's labeled comment
+ (`# ----- Step N: ... -----`) inside the function body matches
+ the number in this list, so the docstring and the code stay in
+ lockstep:
+
+ 1. Walk the PREFIX_RULES table. The first prefix that matches
+ wins (HIGH confidence). Each table row is unique enough that
+ at most one entry can match a given input
+ 2. Check special non-PHC formats in order — NetNTLMv1/v2
+ challenge-response records, MySQL5 (`*<40 hex>`), and the
+ legacy 13-char DES crypt. Each has a distinctive shape that
+ takes precedence over generic length-based matching
+ 3. If the input is pure hex, look up its length in
+ HEX_LENGTH_RULES and report each candidate. The first entry
+ at each length gets MEDIUM confidence (the modern default);
+ the rest LOW
+ 4. If the input has a `$$...` shape but no PREFIX_RULES
+ row matched, fall back to a generic PHC string match — LOW
+ confidence because we only matched the shape, not a specific
+ rule
+ 5. If the input looks like a JWT (`eyJ...`) or a base64 blob
+ (contains `+`, `/`, or `=`), say so with LOW confidence —
+ these are not hashes, but a beginner deserves to know what
+ they pasted instead of a silent no-match
+ 6. If nothing matched at all, return an empty list
+
+ Parameters
+ ----------
+ raw_input
+ The hash string to identify. Whitespace is trimmed before
+ analysis but the rest is treated literally — case matters
+ because some algorithms use uppercase output
+
+ Returns
+ -------
+ list[HashCandidate]
+ Possibly empty. When non-empty, candidates are ordered by
+ confidence (high before medium before low) and within each
+ confidence band by likelihood
+ """
+ # Trim whitespace — copy-pasted hashes often arrive with trailing
+ # newlines or leading spaces. We do not modify case because some
+ # formats are case-sensitive
+ text = raw_input.strip()
+
+ if not text:
+ return []
+
+ # ----- Step 1: prefix rules -----
+ # Walk the table top-to-bottom. Entries in PREFIX_RULES are crafted
+ # so that no two of them can EVER both match the same input — the
+ # first prefix that matches is THE match. So we return it right
+ # away instead of continuing the loop. HIGH confidence is the
+ # right label because a known prefix is a definitive self-
+ # identification: the hash literally announces what algorithm
+ # produced it, we are not guessing
+ for prefix, algorithm, note in PREFIX_RULES:
+ if text.startswith(prefix):
+ return [
+ HashCandidate(
+ algorithm = algorithm,
+ confidence = "high",
+ reason = f"prefix `{prefix}` — {note}",
+ )
+ ]
+
+ # ----- Step 2: special non-PHC formats -----
+ # Formats that do not fit the `$algo$...` PHC mold but still have
+ # unmistakable shapes. We check each in turn before falling through
+ # to the generic length-based step
+
+ # NetNTLMv1 / NetNTLMv2 — the dominant outputs of AD pentest
+ # tools like Responder, secretsdump, ntlmrelayx, and Inveigh.
+ # They are NOT hashes in the "irreversible function" sense —
+ # they are challenge-response records — but every beginner who
+ # runs their first AD lab pastes one of these into a hash
+ # identifier. The literal `::` (an empty second field, where the
+ # LM-hash used to live) is the structural giveaway. We test v2
+ # FIRST because v2's hmac field at index 4 is 32 hex chars while
+ # v1's nthash at the same index is 48 — so if we tested v1 first
+ # we would still match v2 inputs at parts[3] (also 48 in v2's
+ # case for the challenge length in some encodings)
+ if "::" in text and text.count(":") >= 4:
+ parts = text.split(":")
+ # NetNTLMv2 layout:
+ # user :: domain : challenge : hmac(32 hex) : blob(>=32 hex)
+ # The hmac at parts[4] is exactly 32 hex chars — that single
+ # property is enough to disambiguate v2 from v1
+ if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
+ return [
+ HashCandidate(
+ algorithm = "NetNTLMv2",
+ confidence = "high",
+ reason =
+ "user::domain:challenge:hmac(32 hex):blob shape",
+ )
+ ]
+ # NetNTLMv1 layout:
+ # user :: domain : lmhash(48 hex) : nthash(48 hex) : challenge
+ # The lmhash at parts[3] is exactly 48 hex chars
+ if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
+ return [
+ HashCandidate(
+ algorithm = "NetNTLMv1",
+ confidence = "high",
+ reason =
+ "user::domain:lm(48 hex):nt(48 hex):challenge shape",
+ )
+ ]
+
+ # MySQL5 — literal `*` + 40 uppercase hex chars
+ if _is_mysql5(text):
+ return [
+ HashCandidate(
+ algorithm = "MySQL5",
+ confidence = "high",
+ reason =
+ "starts with `*` followed by 40 uppercase hex chars",
+ )
+ ]
+
+ # Traditional 13-char DES crypt — legacy /etc/passwd format
+ # with no prefix at all. We report MEDIUM (not HIGH) because the
+ # 13-char `./0-9A-Za-z` shape isn't fully unique to DES crypt
+ if _is_descrypt(text):
+ return [
+ HashCandidate(
+ algorithm = "DES crypt",
+ confidence = "medium",
+ reason =
+ "13 chars in `./0-9A-Za-z` — legacy /etc/passwd format",
+ )
+ ]
+
+ # ----- Step 3: length + hex charset -----
+ if _is_hex(text):
+ algorithms = HEX_LENGTH_RULES.get(len(text), [])
+ candidates: list[HashCandidate] = []
+ for index, algorithm in enumerate(algorithms):
+ # The first listed algorithm for each length is the modern
+ # default and gets MEDIUM confidence. The rest are still
+ # possible but less common in 2026 — LOW confidence
+ confidence: Confidence = "medium" if index == 0 else "low"
+ label = (
+ "most likely candidate at this length"
+ if index == 0 else "also possible at this length"
+ )
+ candidates.append(
+ HashCandidate(
+ algorithm = algorithm,
+ confidence = confidence,
+ reason = f"{len(text)} hex chars — {label}",
+ )
+ )
+ return candidates
+
+ # ----- Step 4: generic PHC string fallback -----
+ # If the input starts with `$$...` and looks like a
+ # plausible algorithm identifier, it is almost certainly a PHC
+ # string from an algorithm we do not have a specific rule for.
+ # Passlib alone ships ~30 PHC encodings; our PREFIX_RULES table
+ # covers only the most common 20-or-so. Reporting it as a generic
+ # PHC string with the extracted algorithm name is strictly better
+ # than silence, and the LOW confidence label is honest about not
+ # having a specific rule to confirm the algorithm
+ if text.startswith("$"):
+ # Drop the leading `$`, then look for the second `$` that
+ # closes the algorithm-name field. If there's no second `$`,
+ # this isn't a PHC string at all, just a string that happens
+ # to start with `$` — fall through
+ rest = text[1 :]
+ if "$" in rest:
+ algo_name = rest.split("$", 1)[0]
+ # The PHC spec restricts algorithm IDs to alphanumeric
+ # plus `-` and `_`. We accept exactly that charset and
+ # reject anything weirder — anything containing spaces,
+ # punctuation, etc. is almost certainly not a real PHC
+ # string and we'd rather stay silent than make up a name
+ if algo_name and all(c.isalnum() or c in "-_"
+ for c in algo_name):
+ return [
+ HashCandidate(
+ algorithm = f"PHC string ({algo_name})",
+ confidence = "low",
+ reason =
+ f"`${algo_name}$...` shape — generic PHC, no specific rule",
+ )
+ ]
+
+ # ----- Step 5: not-a-hash shape hints -----
+ # Beginners often paste JWTs or base64 blobs into a hash
+ # identifier — they are auth tokens, not hashes, but the user
+ # may not know that yet. Returning a short shape-hint at LOW
+ # confidence is more educational than a silent no-match: the
+ # user finds out WHAT they pasted and that this tool is not
+ # going to crack it for them
+ if text.startswith("eyJ"):
+ # JWTs always begin with `eyJ` because their JSON header
+ # `{"alg":...}` base64-encodes to a string starting with
+ # those three characters (`{"` → base64 → `eyI`/`eyJ`)
+ return [
+ HashCandidate(
+ algorithm = "JWT (not a hash)",
+ confidence = "low",
+ reason =
+ "leading `eyJ` is base64 of `{\"` — JWT, not a hash",
+ )
+ ]
+ if any(c in text for c in "+/=") and len(text) > 8:
+ # Hex hashes NEVER contain `+`, `/`, or `=`. If our input
+ # does, it is almost certainly base64-encoded data of some
+ # kind. The `> 8` length floor avoids flagging short
+ # strings like "a+b=c" as base64
+ return [
+ HashCandidate(
+ algorithm = "Base64 blob (not a hash)",
+ confidence = "low",
+ reason = "contains base64-only chars (`+`, `/`, `=`)",
+ )
+ ]
+
+ # ----- Step 6: nothing matched -----
+ # If we got here, the input has no known prefix, no special
+ # shape, no hex length we recognize, no PHC-string shape, and
+ # no obvious not-a-hash shape either. Returning an empty list
+ # is better than returning bad guesses — the CLI prints a
+ # clean "could not identify" message instead
+ return []
+
+
+# =============================================================================
+# CLI — argparse + a rich table
+# =============================================================================
+
+
+def _build_argument_parser() -> argparse.ArgumentParser:
+ """
+ Construct the argparse parser used by main()
+
+ Pulled out into its own function so tests can call it without
+ actually running the CLI. Each argument is documented inline
+ """
+ parser = argparse.ArgumentParser(
+ prog = "hashid",
+ description = (
+ "Identify a hash string by prefix, length, and charset. "
+ "Returns ranked candidates with confidence and reasoning."
+ ),
+ )
+ parser.add_argument(
+ "hash",
+ help =
+ "The hash string to identify (wrap in single quotes if it contains $).",
+ )
+ parser.add_argument(
+ "--top",
+ "-n",
+ type = int,
+ default = 5,
+ help = "Show at most this many candidates (default: 5).",
+ )
+ return parser
+
+
+def _render_table(
+ raw_input: str,
+ candidates: list[HashCandidate],
+ console: Console,
+) -> None:
+ """
+ Print a rich Table showing the identified candidates
+
+ A Table is a bordered grid. We give it three columns: algorithm,
+ confidence (color-coded), and reason
+ """
+ table = Table(
+ title = f"Candidates for: {raw_input.strip()}",
+ title_style = "bold cyan",
+ show_lines = False,
+ )
+ table.add_column("algorithm", style = "bold white", no_wrap = True)
+ table.add_column("confidence", no_wrap = True)
+ table.add_column("reason", style = "dim")
+
+ # Color confidence levels so the eye can scan them quickly.
+ # green → yellow → cyan is a gradient that reads "strong, weaker,
+ # weakest" without ever colliding with red, which is reserved for
+ # the no-match error message printed elsewhere in main(). Painting
+ # "low" in red would make three weak-but-valid guesses look like
+ # three errors at a glance — broken visual hierarchy
+ confidence_colors: dict[Confidence,
+ str] = {
+ "high": "green",
+ "medium": "yellow",
+ "low": "cyan",
+ }
+ for candidate in candidates:
+ color = confidence_colors[candidate.confidence]
+ table.add_row(
+ candidate.algorithm,
+ f"[{color}]{candidate.confidence}[/{color}]",
+ candidate.reason,
+ )
+ console.print(table)
+
+
+def main() -> int:
+ """
+ CLI entry point — return an exit code (0 = ok, 1 = nothing found)
+
+ Wrapping the body in a function (instead of running at module
+ import time) means the test suite can import this module without
+ accidentally executing the CLI
+ """
+ parser = _build_argument_parser()
+ args = parser.parse_args()
+ console = Console()
+
+ candidates = identify(args.hash)
+
+ if not candidates:
+ # `[red]...[/red]` is rich's inline color markup
+ console.print(
+ "[red]No identification possible.[/red] "
+ "Input did not match any known prefix, special format, "
+ "or hex length."
+ )
+ return 1
+
+ # Trim to the requested top-N
+ trimmed = candidates[: args.top]
+ _render_table(args.hash, trimmed, console)
+
+ # Helpful nudge — point the user at the cracker once they know
+ # what algorithm to target. Foundations tier is meant to chain
+ if trimmed[0].confidence == "high":
+ console.print(
+ "\n[dim]Next step: try the matching cracker mode "
+ "(see ../../beginner/hash-cracker).[/dim]"
+ )
+
+ return 0
+
+
+# Standard "if invoked directly as a script" guard — lets the file be
+# imported by tests without firing main()
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/PROJECTS/foundations/hash-identifier/install.sh b/PROJECTS/foundations/hash-identifier/install.sh
new file mode 100755
index 00000000..d863ba5a
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/install.sh
@@ -0,0 +1,177 @@
+#!/usr/bin/env bash
+# ©AngelaMos | 2026
+# install.sh
+#
+# Zero-friction install script. Anyone who clones this project should
+# be able to run `./install.sh` and end up with a working setup,
+# regardless of whether they have uv or just installed yet.
+#
+# What this script does, in order:
+# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
+# 2. Installs uv if it is missing (uv is the Python package manager we use)
+# 3. Installs just if it is missing (just is the command runner)
+# 4. Calls `just setup` to create the venv and install dependencies
+# 5. Prints next steps
+#
+# Run with: ./install.sh
+# Or: bash install.sh
+
+# -----------------------------------------------------------------------------
+# Bash safety flags — fail fast and loud
+# -----------------------------------------------------------------------------
+# -e : exit immediately if any command returns a non-zero (error) status
+# -u : treat unset variables as an error
+# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
+set -euo pipefail
+
+# -----------------------------------------------------------------------------
+# Color helpers — pretty terminal output without external dependencies
+# -----------------------------------------------------------------------------
+# These are ANSI escape codes. \033 is the ESC character; the bracketed
+# digits tell the terminal which color to switch to. NC = "no color",
+# resets back to whatever the terminal had before.
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+# Tiny helper functions so we don't repeat the format strings everywhere.
+# `>&2` redirects to stderr (where errors belong) instead of stdout.
+info() { echo -e "${GREEN}[INFO]${NC} $1"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
+success() { echo -e "${GREEN}[OK]${NC} $1"; }
+
+# -----------------------------------------------------------------------------
+# Step 1 — Confirm Python 3.13+ is on the system
+# -----------------------------------------------------------------------------
+check_python() {
+ info "Checking for Python 3.13+..."
+
+ # `command -v ` prints the path of if it exists, nothing
+ # otherwise. `&>/dev/null` discards both stdout and stderr — we only
+ # care about the exit code (0 = found, non-zero = missing).
+ if ! command -v python3 &>/dev/null; then
+ error "python3 not found. Please install Python 3.13 or newer."
+ error " macOS: brew install python@3.13"
+ error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
+ error " Windows: download from python.org"
+ exit 1
+ fi
+
+ # Read the version from Python itself — the most reliable source.
+ # `local` makes these variables function-scoped instead of leaking
+ # into the rest of the script.
+ local version
+ version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+
+ local major minor
+ # `cut -d. -f1` splits the string on `.` and takes field 1.
+ major=$(echo "$version" | cut -d. -f1)
+ minor=$(echo "$version" | cut -d. -f2)
+
+ # `(( ... ))` is bash arithmetic context — lets us write `<` `>` etc.
+ # The compound condition fails if major < 3, OR if major == 3 and
+ # minor < 13. So Python 3.12 fails, 3.13 passes, 4.0 passes.
+ if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
+ error "Python 3.13+ is required, found Python $version"
+ exit 1
+ fi
+
+ success "Python $version detected"
+}
+
+# -----------------------------------------------------------------------------
+# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
+# -----------------------------------------------------------------------------
+install_uv() {
+ # Already installed? Print confirmation and bail out of this function.
+ # `return 0` exits the function with success — the caller continues.
+ if command -v uv &>/dev/null; then
+ success "uv already installed ($(uv --version))"
+ return 0
+ fi
+
+ info "Installing uv (Python package manager)..."
+ # Pipe the official install script into sh. `-LsSf`:
+ # -L : follow redirects
+ # -s : silent (no progress meter)
+ # -S : show errors even when silent
+ # -f : fail on HTTP errors instead of writing them to disk
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+
+ # The installer drops uv into ~/.local/bin or ~/.cargo/bin.
+ # Add both to PATH for the rest of THIS script's run.
+ export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
+
+ # If we still cannot find uv, something went wrong.
+ if ! command -v uv &>/dev/null; then
+ error "uv install completed but \`uv\` is still not on PATH."
+ error "Restart your shell and re-run this script, or add uv to PATH manually."
+ exit 1
+ fi
+ success "uv installed"
+}
+
+# -----------------------------------------------------------------------------
+# Step 3 — Install just if missing (https://github.com/casey/just)
+# -----------------------------------------------------------------------------
+install_just() {
+ if command -v just &>/dev/null; then
+ success "just already installed ($(just --version))"
+ return 0
+ fi
+
+ info "Installing just (command runner)..."
+ # Make sure the install destination exists first.
+ mkdir -p "$HOME/.local/bin"
+ # Official install script. `--to ` controls where the binary lands.
+ # `--proto '=https'` rejects any protocol but HTTPS.
+ # `--tlsv1.2` insists on a modern TLS version.
+ curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
+ | bash -s -- --to "$HOME/.local/bin"
+
+ export PATH="$HOME/.local/bin:$PATH"
+ success "just installed"
+}
+
+# -----------------------------------------------------------------------------
+# Step 4 — Use just to set up the project (venv + dependencies)
+# -----------------------------------------------------------------------------
+project_setup() {
+ info "Running 'just setup'..."
+ # Calling our own justfile recipe — single source of truth for setup.
+ just setup
+}
+
+# -----------------------------------------------------------------------------
+# Main — orchestrate the steps and print next instructions
+# -----------------------------------------------------------------------------
+main() {
+ echo ""
+ echo "================================================"
+ echo " hash-identifier — install"
+ echo "================================================"
+ echo ""
+
+ check_python
+ install_uv
+ install_just
+ project_setup
+
+ echo ""
+ echo "================================================"
+ success "Install complete!"
+ echo "================================================"
+ echo ""
+ echo "Next steps:"
+ echo " just run -- 5f4dcc3b5aa765d61d8327deb882cf99 # identify a hash"
+ echo " just run -- --help # see options"
+ echo " just test # run the test suite"
+ echo ""
+}
+
+# `"$@"` forwards every argument the script was called with into main.
+# We do not use any args today, but keeping this pattern means future
+# flags can be added without changing the bottom of the file.
+main "$@"
diff --git a/PROJECTS/foundations/hash-identifier/justfile b/PROJECTS/foundations/hash-identifier/justfile
new file mode 100644
index 00000000..3f50f4b4
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/justfile
@@ -0,0 +1,189 @@
+# ©AngelaMos | 2026
+# justfile
+#
+# A "justfile" is a list of commands you can run with `just `.
+# Think of it as a project's command center — instead of remembering
+# `uv run pytest -v`, you just type `just test`.
+#
+# Why use just instead of make? It is simpler, cross-platform,
+# and the syntax is easier to read.
+#
+# Show all commands: `just`
+# Run a command: `just ` (e.g. `just setup`)
+
+# Export every variable defined here as an environment variable for
+# the recipes that just runs.
+set export
+# On Linux/macOS, run recipe lines with bash and -u (error on
+# unset variables) and -c (read commands from a string).
+set shell := ["bash", "-uc"]
+# On Windows, fall back to PowerShell with no logo / non-interactive.
+set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
+# Make recipe arguments available to shebang scripts as `$1`, `$2`,
+# `$@`, and `$#`. Without this, shebang recipes only see args via the
+# textual `{{args}}` substitution, which is unsafe for inputs that
+# contain `$` (the substituted text gets re-expanded by bash).
+set positional-arguments
+
+# Show available commands when you run `just` with no args.
+default:
+ @just --list --unsorted
+
+
+# =============================================================================
+# Setup Commands
+# =============================================================================
+
+# One-shot first-time setup — creates .venv and installs everything
+[group('setup')]
+setup:
+ @echo "Creating virtual environment with uv..."
+ # uv venv creates a .venv/ folder using the system Python that
+ # matches `requires-python` in pyproject.toml.
+ # --allow-existing makes the recipe safe to re-run after a partial install.
+ uv venv --allow-existing
+ @echo ""
+ @echo "Installing dependencies (including dev tools)..."
+ # --all-extras pulls in every optional-dependencies group (just `dev`
+ # for us). Without this, dev tools like pytest do not get installed.
+ uv sync --all-extras
+ @echo ""
+ @echo "✓ Setup complete!"
+ @echo ""
+ @echo "Try it out:"
+ @echo " just run 5f4dcc3b5aa765d61d8327deb882cf99"
+ @echo " just test"
+
+# Install runtime dependencies only (no dev tools)
+[group('setup')]
+install:
+ uv sync
+
+# Install runtime + dev dependencies
+[group('setup')]
+install-dev:
+ uv sync --all-extras
+
+
+# =============================================================================
+# Testing & Quality Checks
+# =============================================================================
+
+# Run the test suite
+[group('test')]
+test:
+ @echo "Running tests..."
+ # `uv run` runs a command inside the project's virtual environment
+ # without us having to `source .venv/bin/activate` first.
+ uv run pytest
+
+# Run all linters in sequence (ruff + pylint + mypy)
+[group('test')]
+lint:
+ @echo "=== Ruff ==="
+ uv run ruff check hash_identifier.py test_hash_identifier.py
+ @echo ""
+ @echo "=== Pylint ==="
+ uv run pylint hash_identifier.py
+ @echo ""
+ @echo "=== Mypy ==="
+ uv run mypy hash_identifier.py
+ @echo ""
+ @echo "✓ All linters passed"
+
+# Auto-format every Python file with yapf
+[group('test')]
+format:
+ @echo "Formatting code with yapf..."
+ # -i = in place. Edits the files directly instead of printing diffs.
+ uv run yapf -i hash_identifier.py test_hash_identifier.py
+ @echo "✓ Code formatted"
+
+# Auto-fix what ruff can fix on its own (unused imports, etc.)
+[group('test')]
+fix:
+ uv run ruff check hash_identifier.py test_hash_identifier.py --fix
+
+
+# =============================================================================
+# Run the CLI
+# =============================================================================
+
+# Run hashid — pass the hash to identify after `--`
+# Example: just run 5f4dcc3b5aa765d61d8327deb882cf99
+# just run '$2b$12$abcdefghijklmnopqrstuv'
+# [no-exit-message] silences just's "Recipe `run` failed with exit
+# code N" line when hashid exits non-zero. hashid uses exit 1 to mean
+# "no candidates matched" — a legitimate result, not a failure. The
+# exit code itself is still propagated to whoever invoked just, so
+# CI use cases keep working.
+[group('run')]
+[no-exit-message]
+run *args:
+ #!/usr/bin/env bash
+ # If no args were given, print a friendly usage block and exit
+ # cleanly. Without this, `just run` (no args) would invoke
+ # `uv run hashid` with nothing, argparse would exit 2, and just
+ # would tack a "Recipe `run` failed" error on top — confusing for
+ # someone just trying to see how the command works.
+ if [ $# -eq 0 ]; then
+ cat <<'EOF'
+ Usage: just run --
+
+ Examples:
+ just run -- 5f4dcc3b5aa765d61d8327deb882cf99
+ just run -- '$2b$12$abcdefghijklmnopqrstuv'
+
+ See all options:
+ just run -- --help
+ EOF
+ exit 0
+ fi
+ # Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
+ # TEXTUAL substitution done by just before bash runs the script,
+ # so a hash like $2b$12$abc would get its $2b and $12 expanded as
+ # bash variables and arrive at hashid mangled. "$@" hands bash the
+ # original argv unchanged.
+ uv run hashid "$@"
+
+
+# =============================================================================
+# Utility / Cleanup
+# =============================================================================
+
+# Delete the venv and all build / cache artifacts
+[group('utility')]
+clean:
+ rm -rf .venv
+ rm -rf __pycache__
+ rm -rf .mypy_cache .ruff_cache .pytest_cache
+ rm -rf *.egg-info build dist
+ rm -rf .coverage htmlcov
+ @echo "✓ Cleaned"
+
+# Lock the exact dependency versions to uv.lock
+[group('utility')]
+lock:
+ uv lock
+
+# Upgrade all dependencies to latest allowed versions
+[group('utility')]
+update:
+ uv lock --upgrade
+ uv sync --all-extras
+
+
+# =============================================================================
+# CI Pipeline
+# =============================================================================
+
+# Full pipeline: setup + lint + test. For first-time runs.
+[group('ci')]
+all: setup lint test
+ @echo ""
+ @echo "✓ Setup, lint, and tests all passed"
+
+# Lint + test only — what CI runs after dependencies are installed
+[group('ci')]
+ci: lint test
+ @echo "✓ CI checks passed"
diff --git a/PROJECTS/foundations/hash-identifier/learn/00-OVERVIEW.md b/PROJECTS/foundations/hash-identifier/learn/00-OVERVIEW.md
new file mode 100644
index 00000000..24d852b0
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/learn/00-OVERVIEW.md
@@ -0,0 +1,158 @@
+# Hash Identifier
+
+## What this is
+
+A small Python program that looks at a string of weird-looking characters and tells you what kind of cryptographic hash it probably is. You give it something like this:
+
+```
+5f4dcc3b5aa765d61d8327deb882cf99
+```
+
+and it tells you "that's an MD5 hash" with a reason for why it thinks so.
+
+That's the whole job. It does not crack the hash. It does not turn the hash back into a password. It just answers the question "what flavor of hash is this?" — which is the question you have to answer *first* before any other tool will help you.
+
+## Why anyone needs this
+
+The first thing that happens when a real attack succeeds is that the attacker walks out with a database dump full of password hashes. The hashes look like nonsense, but they are not random — every hash carries clues about how it was made. Once you know the algorithm (MD5, SHA-256, bcrypt, Argon2, whatever) you can hand the hash to a cracking tool like [hashcat](https://hashcat.net) or [John the Ripper](https://www.openwall.com/john/) and start trying to recover the original password.
+
+But here's the thing: hashcat needs you to tell it the algorithm. It has [over 400 hash modes](https://hashcat.net/wiki/doku.php?id=example_hashes), each with a different number. Mode 0 is MD5. Mode 100 is SHA-1. Mode 3200 is bcrypt. If you pick the wrong mode, hashcat will sit there forever and find nothing. So before cracking, you identify. That's this tool.
+
+**Real-world moments where you'd reach for this:**
+
+- A pentester finds a dump file on a compromised server full of strings like `$2b$12$EixZaYVK1...` and needs to know what to feed hashcat.
+- A CTF challenge hands you a hash and zero hints about what algorithm made it.
+- You're reading a breach writeup and want to understand whether the leaked passwords were stored as fast unsalted MD5 (a disaster) or slow salted bcrypt (much better).
+- The [2012 LinkedIn breach](https://en.wikipedia.org/wiki/2012_LinkedIn_hack) leaked 6.5 million unsalted SHA-1 hashes. The first thing any researcher had to do before doing *anything* was confirm "yes, these are SHA-1." Forty-character hex strings. Easy. The tool you're about to read would have told them that in milliseconds.
+
+## What you will learn
+
+**Security ideas:**
+
+- What a cryptographic hash actually is (a function that turns any input into a fixed-length jumble that you can't reverse).
+- The three signals every hash leaks about itself: its **prefix**, its **length**, and its **character set**.
+- Why modern password hashes (`$2b$...`, `$argon2id$...`) *announce themselves* on purpose, and why old fast hashes (MD5, SHA-1) don't.
+- The difference between a fast hash (made for speed, terrible for passwords) and a slow hash (made on purpose to resist cracking).
+- Why you can never recover the password from a hash, only *guess* the password and check if its hash matches.
+
+**Python ideas (assuming this is your first time):**
+
+- How to read a Python file from top to bottom and understand what it's doing.
+- What `import` does and where the standard library ends and third-party packages begin.
+- Functions, type hints (`str`, `int`, `list[str]`), and what `-> bool` means after a function signature.
+- `@dataclass` — a shortcut for making little record-like objects.
+- `frozenset`, `dict`, `list`, `tuple` — the core Python containers and when to pick which.
+- How a command-line tool actually starts running (the `if __name__ == "__main__"` line at the bottom).
+- How a test file works and why every function in the main code has tests next to it.
+
+**Tools you'll touch:**
+
+- [`uv`](https://github.com/astral-sh/uv) — the modern Python package manager. Like `pip` but ~100× faster.
+- [`just`](https://github.com/casey/just) — a command runner. Instead of memorizing long commands, you type `just test` or `just run`.
+- [`rich`](https://github.com/Textualize/rich) — the library that prints the pretty colored table at the end.
+- [`pytest`](https://pytest.org) — Python's test runner.
+- [`ruff`](https://github.com/astral-sh/ruff) + [`mypy`](https://mypy-lang.org) + [`pylint`](https://pylint.org) — the linters that yell at you if your code is wrong, slow, or sloppy.
+
+## What you need before starting
+
+**Knowledge you should have:**
+
+- You've used a terminal at least once (you know what `cd` and `ls` do).
+- You vaguely know that "a hash" is a one-way function. If not, [01-CONCEPTS.md](./01-CONCEPTS.md) will get you there in 10 minutes.
+- You can read code, or at least you're willing to. We will explain every Python feature as we hit it.
+
+**Knowledge you do NOT need:**
+
+- Any prior Python experience. The whole point of the **foundations** tier is that you start here.
+- Any prior cybersecurity experience.
+- Any math beyond "counting." There is no math in this project. Cryptography uses math under the hood, but identifying a hash by its shape doesn't.
+
+**Software you need installed:**
+
+- Python 3.14 or newer.
+- The `uv` tool (the install script will get this for you if you don't have it).
+- The `just` tool (also handled by the install script).
+- A terminal. Any terminal. On Mac it's Terminal.app or iTerm2; on Linux it's whatever your distro shipped; on Windows it's WSL2 + Ubuntu (we strongly recommend WSL2 instead of native Windows).
+
+You do *not* need an IDE — a text editor is fine. We recommend [VS Code](https://code.visualstudio.com) with the Python extension, but `nano`, `vim`, `helix`, or whatever you already use will work.
+
+## Quick start
+
+From inside `PROJECTS/foundations/hash-identifier/`:
+
+```bash
+./install.sh
+```
+
+That script will install `uv` and `just` if missing, create a virtual environment (an isolated Python sandbox just for this project), install all the dependencies, and verify the tests pass. It prints what it's doing as it goes — read the output, don't just close the terminal.
+
+Then try the tool:
+
+```bash
+just run -- 5f4dcc3b5aa765d61d8327deb882cf99
+```
+
+You should see a colored table identifying that string as MD5 (with NTLM, MD4, and RIPEMD-128 as less-likely alternatives — all four produce 32 hex characters, so length alone can't separate them).
+
+Try a few more:
+
+```bash
+# bcrypt — modern password hash, announces itself with the $2b$ prefix
+just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G'
+
+# SHA-256 — 64 hex characters
+just run -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+
+# A JWT (this is NOT a hash, but the tool will say so politely)
+just run -- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
+
+# Total garbage — the tool will say "no idea" rather than guess
+just run -- helloworld
+```
+
+**Note on quoting:** when a hash starts with `$`, you must wrap it in single quotes (`'$2b$...'`). Without quotes, your shell will try to expand `$2` as a shell variable and chop the hash up. This is a shell thing, not a Python thing — every Unix shell does it.
+
+## Project layout
+
+```
+hash-identifier/
+├── hash_identifier.py the whole tool — one file, ~680 lines
+├── test_hash_identifier.py tests for every behavior the tool claims to have
+├── install.sh one-shot setup script
+├── justfile shortcuts for run / test / lint / format
+├── pyproject.toml project config: dependencies, linter rules, etc.
+├── README.md short pointer to this learn/ folder
+├── learn/ you are here
+│ ├── 00-OVERVIEW.md quick start (this file)
+│ ├── 01-CONCEPTS.md what hashes are and how identification works
+│ ├── 02-ARCHITECTURE.md how the code is structured, with diagrams
+│ ├── 03-IMPLEMENTATION.md line-by-line walkthrough of the code
+│ └── 04-CHALLENGES.md extension ideas if you want to go further
+└── assets/ images, screenshots
+```
+
+One file of code is on purpose. The foundations tier is meant to be readable in one sitting. The intermediate and advanced tiers split into many files; foundations does not.
+
+## Where to go next
+
+1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** — understand *what* a hash is, *why* identification is the first move, and *how* prefix/length/charset clues actually work. Read this even if you think you know it; the framing matters.
+2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** — see the six-step pipeline the tool uses to make a decision, drawn out as a flow diagram.
+3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** — read `hash_identifier.py` with us, line by line. Every Python feature gets explained when it first appears.
+4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** — extensions you can try on your own once you've absorbed the rest.
+
+## Common problems
+
+**"command not found: just"**
+The install script should set this up, but if it didn't: `curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash`. Then close and reopen your terminal so it sees the new tool.
+
+**"command not found: uv"**
+Same idea: `curl -LsSf https://astral.sh/uv/install.sh | sh`, then reopen your terminal.
+
+**`just run -- $2b$12$...` chops the hash up**
+You forgot the single quotes around the hash. Re-run with `just run -- '$2b$12$...'`.
+
+**"ModuleNotFoundError: No module named 'rich'"**
+You ran `python hash_identifier.py` directly instead of `just run`. The `just run` recipe uses the virtual environment that has `rich` installed. Either use `just run`, or activate the venv first: `source .venv/bin/activate`, *then* `python hash_identifier.py `.
+
+**Tests fail right after install**
+Tests should pass on a fresh `./install.sh`. If they don't, you probably have an older Python (run `python --version`; you need 3.14+). On Ubuntu, install it via [`deadsnakes`](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa); on Mac use [Homebrew](https://brew.sh).
diff --git a/PROJECTS/foundations/hash-identifier/learn/01-CONCEPTS.md b/PROJECTS/foundations/hash-identifier/learn/01-CONCEPTS.md
new file mode 100644
index 00000000..13c2ee37
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/learn/01-CONCEPTS.md
@@ -0,0 +1,210 @@
+# Concepts
+
+This page builds up the ideas you need to understand the code. We start from "what is a hash" and end at "here is exactly why a 32-character hex string is probably MD5." No prior security knowledge required.
+
+## 1. What is a hash?
+
+A **cryptographic hash function** takes any input — a single byte, a password, a 4GB video file — and produces a fixed-length output that looks like random garbage. Same input always gives the same output. Different inputs give different outputs. And critically: if you only have the output, you cannot work backwards to the input.
+
+Picture it as a kitchen blender that only goes forwards:
+
+```
+"password" ─────► [ MD5 blender ] ─────► 5f4dcc3b5aa765d61d8327deb882cf99
+"hello" ─────► [ MD5 blender ] ─────► 5d41402abc4b2a76b9719d911017c592
+"hello!" ─────► [ MD5 blender ] ─────► d9014c4624844aa5bac314773d6b689a
+ │
+ └─ change ONE character → totally different output
+```
+
+A few properties that fall out of this:
+
+- **Deterministic.** Same input, same output, every single time. This is the whole reason hashes are useful — you can store the hash of a password, and when someone logs in you re-hash what they typed and compare.
+- **Fixed-length output.** Whether you hash `"a"` or the entire Bible, MD5 always produces 32 hex characters. SHA-256 always produces 64. This length is the first big clue we use to identify which algorithm was used.
+- **One-way.** You can go from password → hash, but not from hash → password. There is no "un-hash" button. The only way to figure out which password produced a hash is to *guess passwords and hash them yourself* until you get a match. That guessing is what hashcat does.
+- **Avalanche effect.** Change one letter of the input and the entire output changes. `"password"` and `"Password"` produce hashes that share zero characters.
+
+If you want to play with this for yourself, in a Python REPL (`uv run python`):
+
+```python
+>>> import hashlib
+>>> hashlib.md5(b"password").hexdigest()
+'5f4dcc3b5aa765d61d8327deb882cf99'
+>>> hashlib.md5(b"Password").hexdigest()
+'dc647eb65e6711e155375218212b3964'
+```
+
+The `b"..."` syntax means "this is bytes, not text" — hash functions work on raw bytes, not characters. Don't worry about that distinction yet; just notice that one letter change produced a completely different hash.
+
+## 2. Why hashes exist (the password storage problem)
+
+Imagine you run a website. Users sign up with a password. The naive thing to do is store passwords in your database directly:
+
+```
++----------+------------+
+| username | password |
++----------+------------+
+| alice | hunter2 |
+| bob | letmein |
++----------+------------+
+```
+
+This is a catastrophe waiting to happen. The moment somebody breaches your database — and someone always eventually does — every user is exposed. Worse, since people [reuse passwords across sites](https://www.security.org/digital-safety/password-reuse-statistics/), the attacker now has the keys to Alice's bank, Alice's email, and Alice's Netflix.
+
+The fix is to never store the password itself. Store its hash:
+
+```
++----------+----------------------------------+
+| username | password_hash |
++----------+----------------------------------+
+| alice | 5f4dcc3b5aa765d61d8327deb882cf99 | ← MD5("hunter2")... if it were
+| bob | 0d107d09f5bbe40cade3de5c71e9e9b7 | "password" (it isn't)
++----------+----------------------------------+
+```
+
+When Alice logs in, you hash what she just typed and compare it to the stored hash. If they match, she gets in. You never knew her password and you never have to.
+
+The attacker who steals this database now has hashes, not passwords. They have to *guess* every password by hashing candidate passwords and comparing. With a fast hash like MD5, a modern GPU can try billions of guesses per second. With a slow hash like bcrypt, it can try only thousands. That's the entire reason modern systems use slow hashes — not because they're "more secure" in some abstract way, but because they make guessing *expensive*.
+
+## 3. Real breaches that turned on hash identification
+
+Identifying the hash format is the *first move* in every password-leak story. Until you know what algorithm made the hashes, nothing else happens.
+
+**[2012 LinkedIn breach](https://en.wikipedia.org/wiki/2012_LinkedIn_hack)** — 6.5 million unsalted SHA-1 hashes leaked. Forty hex characters each. Researchers identified the format in seconds, then cracked 90% of the hashes in 72 hours because SHA-1 is fast and the passwords had no salt (more on salt below). LinkedIn later admitted [117 million more accounts](https://www.theguardian.com/technology/2016/may/19/linkedin-2012-data-breach-hack-117-million-email-password-details) were exposed than originally disclosed.
+
+**[2013 Adobe breach](https://krebsonsecurity.com/2013/11/adobe-breach-impacted-at-least-38-million-users/)** — 153 million accounts, with passwords stored using 3DES encryption (not even hashing) and no unique salts. The lack of unique salts meant identical passwords produced identical ciphertext. Researchers could see, just by looking at the dump, which 1.9 million accounts shared the password `123456`.
+
+**[2016 Yahoo breach](https://en.wikipedia.org/wiki/Yahoo!_data_breaches)** — 3 billion accounts. Some hashed with MD5 (catastrophic), some with bcrypt (much better). The mixed format made identification the first task before any defense analysis could proceed.
+
+**[2019 Collection #1](https://www.troyhunt.com/the-773-million-record-collection-1-data-reach/)** — 773 million email/password pairs aggregated from past breaches. Researchers had to sort which hashes were which algorithm before anything else.
+
+The pattern is the same every time: dump appears → identify the algorithm → decide whether cracking is feasible → reach for hashcat.
+
+## 4. The three signals a hash leaks about itself
+
+This is the heart of the tool. A hash string carries up to three clues about its own origin: its **prefix**, its **length**, and its **character set**. We use these in order, strongest first.
+
+### Signal 1: prefix (the strongest clue)
+
+Modern password hashes use a self-describing format called **PHC string format** (PHC stands for "Password Hashing Competition," the contest that gave us Argon2 in 2015). A PHC string starts with a marker that announces the algorithm:
+
+```
+$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
+^^^^^^^^^^
+ │
+ └─ "I am an Argon2id hash. You don't have to guess."
+```
+
+```
+$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK
+^^^^
+ │
+ └─ "I am a bcrypt hash, variant 2b, cost factor 12."
+```
+
+When a hash announces itself like this, identification is essentially free — you just compare prefixes. The tool reports **HIGH confidence** for prefix matches because the hash literally told you what it is. The PHC spec is documented [here](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) if you want the full grammar.
+
+Old hashes do *not* do this. MD5 and SHA-1 just give you the raw hex digest with no prefix. Which is why the modern shift to PHC was a big deal — it makes the system self-documenting.
+
+Here's a non-exhaustive cheat sheet of common prefixes. The full table is in `hash_identifier.py` under `PREFIX_RULES`:
+
+| Prefix | Algorithm | Where you see it |
+| ------------------ | ------------------- | ----------------------------------------------- |
+| `$argon2id$` | Argon2id | Modern web apps, the current gold standard |
+| `$2b$` | bcrypt | Workhorse for the past 15 years |
+| `$6$` | SHA-512 crypt | `/etc/shadow` on most Linux distros |
+| `$apr1$` | Apache MD5-crypt | `.htpasswd` files (Apache basic auth) |
+| `$P$` | phpass | WordPress and old phpBB forums |
+| `pbkdf2_sha256$` | Django PBKDF2 | Default for Django web apps |
+| `{SSHA}` | LDAP salted SHA-1 | LDAP directory passwords |
+
+### Signal 2: length (the second-strongest clue)
+
+Hash functions emit fixed-length output. If you see no prefix but you have 64 hex characters, you can be confident it came from one of the 256-bit hash family (SHA-256, SHA3-256, BLAKE2s-256, RIPEMD-256). Each algorithm always emits the same number of bytes:
+
+```
+algorithm bytes hex chars
+─────────────────────────────────────
+MD5 16 32
+SHA-1 20 40
+SHA-224 28 56
+SHA-256 32 64
+SHA-384 48 96
+SHA-512 64 128
+```
+
+The reason the number of hex characters is double the number of bytes: each byte is 8 bits, but a hex character represents only 4 bits (one of 16 possible values: `0-9a-f`). So you need two hex characters per byte.
+
+Length narrows the field but rarely picks a unique winner. 32 hex characters could be MD5, NTLM, MD4, or RIPEMD-128 — they all produce 128 bits of output. So when the tool matches on length, it reports **MEDIUM confidence** for the most likely algorithm at that length and **LOW** for the rest. "Most likely" means "most common in the wild in 2026" — MD5 vastly outranks RIPEMD-128.
+
+### Signal 3: charset (used as a sanity check)
+
+The character set of the hash narrows things further. Three common alphabets show up:
+
+- **Hex:** only `0-9a-f` (or `0-9A-F` if uppercase). Used by raw MD5, SHA-family, NTLM, etc.
+- **Base64-ish:** `0-9A-Za-z+/=`. Used by LDAP and some Java password formats.
+- **crypt(3) base64:** a peculiar alphabet `./0-9A-Za-z` (note the `.` and `/` at the start, no `=` for padding). Used by bcrypt and the old Unix crypt formats.
+
+A string with `+` in it is *not* a hex hash. A string with `*` followed by 40 uppercase hex characters is almost certainly MySQL5 (and only MySQL5 — because MySQL prints its hashes using the `%02X` C format specifier, which is uppercase-only).
+
+Charset alone rarely identifies anything, but it's the tiebreaker that lets us *rule things out*. For example, the helper `_is_mysql5` in the code refuses to match if the body is lowercase, because real MySQL5 output is always uppercase. Better to say "I don't know" than to lie with confidence.
+
+## 5. Salts (a quick detour)
+
+You'll see the word **salt** all over the place when you read about password hashing. A salt is a unique random string that you mix into the password before hashing, then store alongside the hash:
+
+```
+hash = bcrypt("hunter2" + random_salt_for_alice)
+```
+
+The point of a salt is to make every user's hash *different*, even if two users picked the same password. Without salts, you can sort the database by hash column, count duplicates, and immediately learn which password is most popular (this is exactly how researchers learned `123456` was Adobe's most popular password — no cracking required).
+
+Salts also defeat **rainbow tables**: precomputed lookup tables that map `hash → password` for billions of common passwords. With a unique salt per user, every entry in the rainbow table would have to be recomputed for *every salt*, which is infeasible.
+
+A PHC string like `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...` contains the salt right there in the string (the `EixZaYVK1fsbw1ZfbX3OXe` part). That's not a security problem — the salt is supposed to be public. Its job is to make every hash unique, not to be secret.
+
+## 6. Why identification has to come first
+
+Cracking tools don't auto-detect the algorithm — they need you to tell them.
+
+```bash
+# Hashcat, mode 0 (MD5):
+hashcat -m 0 -a 0 5f4dcc3b5aa765d61d8327deb882cf99 wordlist.txt
+
+# Hashcat, mode 3200 (bcrypt):
+hashcat -m 3200 -a 0 '$2b$12$EixZaY...' wordlist.txt
+
+# John the Ripper, --format=raw-md5:
+john --format=raw-md5 --wordlist=wordlist.txt hashes.txt
+```
+
+Pick the wrong mode and hashcat will sit there comparing your bcrypt hash against MD5 outputs forever and finding nothing. So *before* you crack, you identify.
+
+This is why a tool like the one in this project (and the older [`hashid`](https://github.com/psypanda/hashID) and [`hash-identifier`](https://github.com/blackploit/hash-identifier) projects it's inspired by) exists. It is the first step. Our tool is a beginner-friendly clone of that idea, written so you can read every line and understand every decision.
+
+## 7. The tradeoff this tool is making
+
+We could theoretically achieve higher accuracy by trying every algorithm and computing diagnostics. We don't. We make decisions from *string shape alone*:
+
+- We never run any hash function.
+- We never make network requests.
+- We never touch the filesystem.
+- We never call any external tool.
+
+This makes the tool **fast** (instant), **safe** (impossible to leak data), and **trivially testable** (every test is "given input X, expect output Y"). It's a pure function, in the mathematical sense — same input always gives same output, no side effects.
+
+The cost is that we sometimes report multiple candidates with MEDIUM/LOW confidence. We could in principle pick a winner if we tried each algorithm against a known wordlist — but that's a different tool. That tool is the cracker, and it lives in `PROJECTS/beginner/hash-cracker`. Our tool's whole job is to point you at the *right cracker mode* in the first place.
+
+## 8. What the tool will and will not do
+
+| Will | Will not |
+| ----------------------------------------------------- | --------------------------------------------------- |
+| Identify ~30 hash formats by prefix | Crack any hash |
+| Identify common hex hashes by length | Compute hashes for you |
+| Recognize MySQL5, NetNTLM, DES crypt by shape | Call hashcat or john for you |
+| Tell you "that's a JWT" or "that's base64, not a hash"| Tell you the password |
+| Print ranked candidates with confidence and reasoning | Make network requests |
+| Run as a one-shot CLI in milliseconds | Touch the filesystem |
+
+## 9. Where to go from here
+
+Now that you know *what* the tool is doing and *why*, read **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** to see how the code is structured into a six-step decision pipeline. Then **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks the actual Python file with you, function by function.
diff --git a/PROJECTS/foundations/hash-identifier/learn/02-ARCHITECTURE.md b/PROJECTS/foundations/hash-identifier/learn/02-ARCHITECTURE.md
new file mode 100644
index 00000000..55f35dce
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/learn/02-ARCHITECTURE.md
@@ -0,0 +1,353 @@
+# Architecture
+
+This page is about *how the code is shaped*. Not what each line does — that's the next page. Here we zoom out and look at how the pieces fit together, what data flows where, and why we picked this shape over the alternatives.
+
+## 1. The big picture
+
+The whole tool is one Python file: `hash_identifier.py`. Everything that runs lives in that file. There are three layers inside it:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ CLI layer (main, _build_argument_parser, _render_table) │
+│ - reads command-line arguments │
+│ - prints the colored table to your terminal │
+│ - returns an exit code │
+└──────────────────────────┬──────────────────────────────────┘
+ │ calls
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Pure-function layer (identify) │
+│ - the actual decision-making │
+│ - takes a string, returns a list of HashCandidate │
+│ - touches NO files, NO network, NO global state │
+└──────────────────────────┬──────────────────────────────────┘
+ │ uses
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Data layer (PREFIX_RULES, HEX_LENGTH_RULES, charsets) │
+│ - lookup tables describing what we know about hashes │
+│ - read-only, defined at module load time │
+└─────────────────────────────────────────────────────────────┘
+```
+
+Reading top to bottom, the CLI layer is the **outside** of the program: it deals with the human. The pure-function layer is the **brain**: it takes a clean string in and returns a clean answer out. The data layer is the **knowledge**: every "we know X about hash Y" lives in one of those tables, not scattered through the code.
+
+This three-way split is deliberate. We can test the brain in isolation without ever spawning a CLI — and the test file does exactly that, calling `identify()` directly. We can change how the table looks (color, layout, JSON output) without touching the brain. And we can add new hash formats by adding a row to a table, not by adding a new function.
+
+## 2. Data flow on a single run
+
+Here's what happens when you type `just run -- 5f4dcc3b5aa765d61d8327deb882cf99`:
+
+```
+ (your terminal)
+ │
+ │ "5f4dcc3b5aa765d61d8327deb882cf99"
+ ▼
+ ┌─────────────────────────────────────┐
+ │ argparse │
+ │ parses sys.argv into args.hash │
+ └────────────────┬────────────────────┘
+ │ args.hash = "5f4dcc..."
+ ▼
+ ┌─────────────────────────────────────┐
+ │ identify(args.hash) │
+ │ │
+ │ text = args.hash.strip() │
+ │ │
+ │ ┌─────────────────────────────┐ │
+ │ │ Step 1: prefix match? │ │
+ │ └────────────┬────────────────┘ │
+ │ no match │ │
+ │ ▼ │
+ │ ┌─────────────────────────────┐ │
+ │ │ Step 2: special shape? │ │
+ │ │ (NetNTLM / MySQL5 / DES) │ │
+ │ └────────────┬────────────────┘ │
+ │ no match │ │
+ │ ▼ │
+ │ ┌─────────────────────────────┐ │
+ │ │ Step 3: hex + length match? │ │
+ │ │ → 32 hex chars → MD5/NTLM..│ ✔ │
+ │ └────────────┬────────────────┘ │
+ │ │ │
+ │ returns [HashCandidate, ...] │
+ └────────────────┬────────────────────┘
+ │
+ ▼
+ ┌─────────────────────────────────────┐
+ │ _render_table() │
+ │ builds a rich.Table, prints it │
+ └────────────────┬────────────────────┘
+ │
+ ▼
+ (your terminal)
+
+ ┌─────────────────────────────────────────────────────────┐
+ │ Candidates for: 5f4dcc3b5aa765d61d8327deb882cf99 │
+ │ ╭───────────┬────────────┬───────────────────────────╮ │
+ │ │ algorithm │ confidence │ reason │ │
+ │ ├───────────┼────────────┼───────────────────────────┤ │
+ │ │ MD5 │ medium │ 32 hex chars — most likely│ │
+ │ │ NTLM │ low │ 32 hex chars — also poss. │ │
+ │ │ MD4 │ low │ 32 hex chars — also poss. │ │
+ │ │ RIPEMD-128│ low │ 32 hex chars — also poss. │ │
+ │ ╰───────────┴────────────┴───────────────────────────╯ │
+ └─────────────────────────────────────────────────────────┘
+```
+
+The brain is the middle box. Everything above and below it is just plumbing: getting the string in, getting the table out.
+
+## 3. The six-step decision pipeline
+
+The brain (`identify()`) is structured as **six numbered steps**. Each step is a chance to short-circuit and return a verdict. If a step matches, the function returns immediately. If not, control falls through to the next step.
+
+This shape — "try the strongest signal first, fall back to weaker ones" — is called a **decision cascade** or **rule pipeline**. You'll see this pattern all over security tooling: spam filters, IDS rules, antivirus heuristics, fingerprinting. They all share the same skeleton.
+
+```
+ ┌────────────────────────────────┐
+ │ Step 1: PREFIX_RULES? │ HIGH confidence
+ │ Walk the prefix table. │ ────────► return
+ │ Any prefix start match wins. │ first match
+ └─────────────┬──────────────────┘
+ │ no match
+ ▼
+ ┌────────────────────────────────┐
+ │ Step 2: special shapes? │ HIGH/MEDIUM
+ │ NetNTLMv2 / NetNTLMv1 (`::`) │ ────────► return
+ │ MySQL5 (`*` + 40 upper hex) │ first match
+ │ DES crypt (13 chars) │
+ └─────────────┬──────────────────┘
+ │ no match
+ ▼
+ ┌────────────────────────────────┐
+ │ Step 3: pure hex? │ MEDIUM/LOW
+ │ If yes, look up length in │ ────────► return
+ │ HEX_LENGTH_RULES, return all │ ranked list
+ │ candidates ranked by likelihood│
+ └─────────────┬──────────────────┘
+ │ not hex
+ ▼
+ ┌────────────────────────────────┐
+ │ Step 4: generic `$algo$...`? │ LOW
+ │ Looks like a PHC string but │ ────────► return
+ │ we have no specific rule. │ generic match
+ │ Report it as a generic PHC. │
+ └─────────────┬──────────────────┘
+ │ no
+ ▼
+ ┌────────────────────────────────┐
+ │ Step 5: shape hint? │ LOW
+ │ Looks like a JWT (eyJ...) or │ ────────► return
+ │ base64 (`+`, `/`, `=`)? │ "not a hash"
+ │ Tell the user it's not a hash. │
+ └─────────────┬──────────────────┘
+ │ no
+ ▼
+ ┌────────────────────────────────┐
+ │ Step 6: give up. │ none
+ │ Return empty list. │ ────────► []
+ │ CLI prints "could not identify"│
+ └────────────────────────────────┘
+```
+
+Order matters here, and the order isn't arbitrary. We always try **the most specific test first** and **the most general test last**:
+
+1. PHC prefixes are dead giveaways — the hash itself names the algorithm.
+2. Special shapes (NetNTLM, MySQL5, DES crypt) are also strong: they have distinctive structures.
+3. Hex + length is a *narrowing* signal, not a definitive one — it picks a family, not a member.
+4. Generic PHC fallback catches hashes that *look* PHC-shaped but aren't in our table.
+5. Shape hints handle the common "I pasted the wrong thing" case (people drop JWTs into hash identifiers all the time).
+6. Empty list = honest "I don't know."
+
+If we reversed the order — say, checked length before prefix — we would misclassify a bcrypt hash as "60 hex chars, no length rule" because we'd never look at its `$2b$` prefix. Order encodes priority.
+
+## 4. The HashCandidate object
+
+The brain doesn't return a string — it returns a list of `HashCandidate` objects. A candidate has three fields:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ HashCandidate │
+│ │
+│ algorithm: str e.g. "MD5", "bcrypt", "SHA-256" │
+│ confidence: Literal "high" | "medium" | "low" │
+│ reason: str "prefix `$2b$` — bcrypt PHC..." │
+│ │
+│ frozen=True ── immutable, can't be mutated after creation│
+│ slots=True ── memory-efficient (no __dict__) │
+└─────────────────────────────────────────────────────────────┘
+```
+
+The shape is intentional. **`algorithm`** is what hashcat wants to know. **`confidence`** tells the human reader how much to trust this guess. **`reason`** is the *evidence* — a one-line explanation of why the tool made this guess. The reason field is what makes the tool teachable: the user sees not just "bcrypt" but "prefix `$2b$` — bcrypt PHC string, 2b variant (current)."
+
+We use `@dataclass(frozen=True, slots=True)` instead of writing a class with `__init__` and `__repr__` by hand:
+
+- **`frozen=True`** means once you build a `HashCandidate`, you can't mutate it. If somewhere in the code tried `candidate.algorithm = "something else"`, Python would raise `FrozenInstanceError`. This makes the data flow predictable: a candidate that comes out of `identify()` is the same candidate everywhere it shows up later.
+- **`slots=True`** is a memory optimization. Without slots, every instance carries around a `__dict__` for adding attributes on the fly. We don't need that, so we turn it off and save memory.
+
+Both flags also signal *intent* to a reader: "this is a value object, not a mutable bag of state." That signal matters more than the bytes saved.
+
+## 5. The data tables as the source of truth
+
+If you wanted to add a new hash format to this tool, you would not write new logic. You would add a row to one of these tables:
+
+```
+PREFIX_RULES: list of (prefix, algorithm, note)
+─────────────────────────────────────────────────
+("$argon2id$", "Argon2id", "modern PHC string..."),
+("$2b$", "bcrypt", "bcrypt PHC string..."),
+("$6$", "SHA-512 crypt", "Unix crypt..."),
+... ~25 more rows
+
+
+HEX_LENGTH_RULES: dict of {length_in_hex_chars: [algorithms]}
+─────────────────────────────────────────────────────────────
+32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
+40: ["SHA-1", "RIPEMD-160"],
+64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
+128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
+... etc
+
+
+HEX_CHARSET, _HEX_UPPER_CHARSET, _DESCRYPT_CHARSET
+──────────────────────────────────────────────────
+The alphabets used by each format. frozenset for fast lookup.
+```
+
+This is called a **data-driven design**. The rules live in data, not code. Three benefits:
+
+1. **Adding a new format is one line.** No new function, no new test scaffolding to write.
+2. **The rules are inspectable.** You can read `PREFIX_RULES` and immediately see every format the tool knows.
+3. **The rules are testable.** The test file iterates over `PREFIX_RULES` and confirms each prefix is recognized — so the data and the behavior cannot drift out of sync.
+
+When you read the implementation page next, watch how few of the function bodies have *if/elif/elif* chains. The decisions happen inside table lookups, not inside conditionals. That's the data-driven design at work.
+
+## 6. Two helper functions
+
+The brain is one big function (`identify`), but two small helpers live next to it. They both answer yes/no questions about the input string:
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ _is_hex(text) -> bool │
+│ "Is every character of text a valid hex digit?" │
+│ Used in step 3 to decide whether to look up length. │
+├──────────────────────────────────────────────────────────┤
+│ _is_mysql5(text) -> bool │
+│ "Does text look like `*` + 40 uppercase hex chars?" │
+│ Used in step 2 for MySQL5 detection. │
+├──────────────────────────────────────────────────────────┤
+│ _is_descrypt(text) -> bool │
+│ "Is text 13 chars from `./0-9A-Za-z`?" │
+│ Used in step 2 for legacy DES crypt detection. │
+└──────────────────────────────────────────────────────────┘
+```
+
+The leading underscore (`_is_hex`, not `is_hex`) is a Python convention meaning **"this is module-private."** It says to other developers: "this is an implementation detail of `hash_identifier.py`; don't import it from somewhere else." Python doesn't *enforce* this — you can still import private names — but every linter and every reviewer will flag you if you do.
+
+The helpers are tiny on purpose. Each one is a single boolean question. We pull them out of `identify()` not because they're complicated but because giving them a name makes `identify()` read like English: "if `_is_hex(text)`, do hex-length matching." If we inlined the test, the eye would have to parse it.
+
+## 7. The CLI layer
+
+The CLI layer is the part the human actually interacts with. It does three things:
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ _build_argument_parser() │
+│ Sets up argparse — defines that the program takes one │
+│ positional argument (`hash`) and an optional `--top N` │
+│ flag. Returns a configured parser. │
+│ │
+│ Pulled out of main() so tests can build the parser │
+│ without actually running the CLI. │
+└────────────────────────────────────────────────────────────┘
+
+┌────────────────────────────────────────────────────────────┐
+│ _render_table(raw_input, candidates, console) │
+│ Builds a rich.Table object, adds one row per candidate, │
+│ colors the confidence column (green/yellow/cyan), │
+│ and prints it. │
+│ │
+│ Takes the rich Console as an argument so tests can pass │
+│ a captured-output Console and verify what got printed. │
+└────────────────────────────────────────────────────────────┘
+
+┌────────────────────────────────────────────────────────────┐
+│ main() │
+│ Parses args, calls identify(), prints the table. │
+│ Returns an exit code: │
+│ 0 → at least one candidate found │
+│ 1 → no candidates (printed an error message) │
+│ │
+│ The exit code lets shell scripts do │
+│ `if hashid "$x"; then ...` │
+│ to react to whether identification succeeded. │
+└────────────────────────────────────────────────────────────┘
+```
+
+The crucial design choice in the CLI layer is **dependency injection**. `_render_table` takes a `Console` object as a parameter instead of creating one inside. That sounds fancy, but it just means: "give me the printer to use." The function doesn't care if you give it a real terminal Console or a test Console that captures output to a string. This makes the function testable without writing to your real terminal during tests.
+
+## 8. The test file mirrors the brain
+
+`test_hash_identifier.py` is structured to mirror `hash_identifier.py`. It tests every behavior the tool claims to have:
+
+```
+test_bcrypt_prefix_is_recognized ── covers PREFIX_RULES row $2b$
+test_argon2id_prefix_is_recognized ── covers PREFIX_RULES row $argon2id$
+test_apr1_prefix_is_recognized ── covers PREFIX_RULES row $apr1$
+test_sha512_crypt_prefix_is_recognized ── covers PREFIX_RULES row $6$
+test_django_pbkdf2_prefix_is_recognized ── covers PREFIX_RULES row pbkdf2_sha256$
+
+test_mysql5_format_is_recognized ── covers Step 2 / _is_mysql5
+test_mysql5_rejects_lowercase_body ── covers the "be honest, don't lie" rule
+test_netntlmv2_format_is_recognized ── covers Step 2 / NetNTLMv2
+test_netntlmv1_format_is_recognized ── covers Step 2 / NetNTLMv1
+test_descrypt_format_is_recognized ── covers Step 2 / _is_descrypt
+
+test_md5_length_returns_md5_first ── covers Step 3 / 32 hex chars
+test_sha1_length_returns_sha1_first ── covers Step 3 / 40 hex chars
+test_sha256_length_returns_sha256_first ── covers Step 3 / 64 hex chars
+test_mysql323_length_returns_mysql323_first ── covers Step 3 / 16 hex chars
+
+test_unknown_phc_string_falls_back_to_generic
+ ── covers Step 4
+
+test_jwt_input_is_called_out_as_not_a_hash ── covers Step 5
+test_base64_blob_is_called_out_as_not_a_hash── covers Step 5
+
+test_empty_input_returns_no_candidates ── covers Step 6 (edge case)
+test_garbage_returns_no_candidates ── covers Step 6
+test_input_is_trimmed_of_whitespace ── covers the .strip() at the top
+
+test_hash_candidate_is_frozen ── covers the @dataclass(frozen=True)
+
+test_every_prefix_rule_is_recognized_with_high_confidence
+ ── meta-test: iterates over
+ ── PREFIX_RULES and asserts every
+ ── row produces a HIGH-confidence
+ ── match. Keeps data and code in sync.
+```
+
+The meta-test at the bottom is the most interesting one. It's a guard against future regressions: if you add a new row to `PREFIX_RULES` and forget to update the matching logic, this test fires. The test loops over the data, not over hardcoded inputs — so the test grows automatically as the data table grows.
+
+## 9. Why pure functions matter here
+
+The brain (`identify()`) is what's called a **pure function**:
+
+- Given the same input, it always returns the same output.
+- It doesn't modify anything outside itself (no global variables, no files, no network).
+- It doesn't depend on anything outside itself (no current time, no environment variables, no random numbers).
+
+This sounds like a small thing. It's enormous. Pure functions are:
+
+- **Trivially testable.** `assert identify("5f4d...") == [HashCandidate(...)]`. No mocking, no setup, no teardown.
+- **Trivially parallelizable.** You could run `identify()` on a million hashes across 16 CPU cores with zero coordination, because no two calls can interfere with each other.
+- **Trivially cacheable.** Same input → same output → memoize freely.
+- **Trivially understandable.** You can read `identify()` in isolation. You don't have to know what state the program is in.
+
+Most real programs can't be all-pure — they have to read files, send packets, write to databases. But you can almost always *carve out* a pure core and put a thin shell around it that does the side-effecty stuff. That's exactly the architecture here: pure brain in the middle, side-effecty CLI shell around it.
+
+This is sometimes called the [Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) pattern. It's worth learning the name because once you see it, you'll spot it everywhere.
+
+## 10. Next up
+
+You now know the shape: three layers, six steps, three data tables, three helpers, one `HashCandidate` record, one CLI shell. Read **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** next and we'll walk every line of `hash_identifier.py` together.
diff --git a/PROJECTS/foundations/hash-identifier/learn/03-IMPLEMENTATION.md b/PROJECTS/foundations/hash-identifier/learn/03-IMPLEMENTATION.md
new file mode 100644
index 00000000..5a538aa7
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/learn/03-IMPLEMENTATION.md
@@ -0,0 +1,616 @@
+# Implementation walkthrough
+
+This page reads `hash_identifier.py` from top to bottom with you. Every Python feature gets explained the first time it shows up. If you're brand new to Python, read this with the source file open in another window so you can see the lines we're talking about.
+
+> Throughout this page, we say "the source file" to mean `hash_identifier.py`. Open it now: `code hash_identifier.py`, or `nano hash_identifier.py`, or whatever editor you use.
+
+## 1. The file header
+
+```python
+"""
+©AngelaMos | 2026
+hash_identifier.py
+
+Identify what kind of hash a string is, by inspecting its shape
+...
+"""
+```
+
+That triple-quoted block at the very top of the file is a **module docstring**. In Python, anything inside `"""..."""` is a string literal. When the file is loaded, Python sees this string sitting at the top with no name attached to it, and treats it as documentation for the whole module. You can read it later with `help(hash_identifier)` or by hovering over the import in an IDE.
+
+The first line, `©AngelaMos | 2026`, is the copyright marker required by every file in this repo. The second line is the filename. Then a longer human-readable explanation of what the file does. You'll see the same pattern in every file in `PROJECTS/foundations/`.
+
+> **Why a docstring instead of a comment?** Python has both. A `# comment` is stripped before the code runs. A `"""docstring"""` is stored on the module/function/class and is available at runtime via `__doc__`. Tools like Sphinx, mkdocs, and your IDE's hover help all read docstrings, not comments. Rule of thumb: use docstrings for "what this thing is and how to use it," and use comments for "why this *specific line* exists."
+
+## 2. Imports
+
+```python
+import argparse
+import sys
+from dataclasses import dataclass
+from typing import Literal
+
+from rich.console import Console
+from rich.table import Table
+```
+
+An `import` statement brings code from another file into yours. Python ships with hundreds of modules in its **standard library** (always available, no install needed) and you can install more from [PyPI](https://pypi.org/) using `uv add `.
+
+There are two `import` shapes:
+
+- `import argparse` — imports the whole module under its own name. You then refer to things inside it as `argparse.ArgumentParser`.
+- `from dataclasses import dataclass` — imports just one thing out of the module, so you can use it bare: `dataclass` instead of `dataclasses.dataclass`.
+
+Use the second form when you only need one or two things and they have descriptive names. Use the first form when you'd otherwise pull in a bunch of names that might collide with yours.
+
+A blank line separates standard-library imports from third-party imports. That's a [PEP 8](https://peps.python.org/pep-0008/#imports) convention. Linters will yell at you if you mix them.
+
+Tour of what we just imported:
+
+| Import | What it is | Why we need it |
+| ------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------- |
+| `argparse` | Standard-library CLI argument parser | Turns `sys.argv` into nice attributes (`args.hash`) |
+| `sys` | Standard library, talks to the Python interpreter | We use `sys.exit(...)` to set the program's exit code |
+| `dataclass` | A decorator that turns a class into a small record | Saves us writing `__init__` for `HashCandidate` |
+| `Literal` | A type hint meaning "this value is one of these specific strings" | Pins `confidence` to `"high" / "medium" / "low"` |
+| `Console` | Third-party, from the [`rich`](https://github.com/Textualize/rich) library | The thing that draws colored text to the terminal |
+| `Table` | Also from `rich` | Builds the colored ASCII table we print to the user |
+
+## 3. The Literal type
+
+```python
+Confidence = Literal["high", "medium", "low"]
+```
+
+This line creates a **type alias**. We give the type `Literal["high", "medium", "low"]` a friendly name, `Confidence`, and use that name everywhere else.
+
+A `Literal` type says: "the value of this thing must be exactly one of these specific values — not just any string." With mypy (our type checker) turned on, code like this:
+
+```python
+candidate = HashCandidate(algorithm="MD5", confidence="hgih", reason=...)
+ ^^^^^^
+ typo!
+```
+
+would be flagged at edit time, before you ever run the code. Without `Literal`, the type would be `str` and `"hgih"` would slide by until a user noticed the misspelled output.
+
+We picked `Literal` over Python's `Enum` because for small fixed sets of strings, `Literal` is lighter weight — no separate class definition, no `.value` attribute to remember. (For bigger sets or when you need behavior on the values, `Enum` is the right choice.)
+
+## 4. The HashCandidate dataclass
+
+```python
+@dataclass(frozen=True, slots=True)
+class HashCandidate:
+ """One possible identification of a hash string ..."""
+ algorithm: str
+ confidence: Confidence
+ reason: str
+```
+
+This is the data record the brain returns. Let's unpack what's happening:
+
+- **`class HashCandidate:`** defines a new type called `HashCandidate`. A class is a blueprint for objects.
+- **`algorithm: str`** declares an attribute named `algorithm` of type `str` (a string). The colon-then-type syntax is a **type annotation**. It's optional in Python but heavily used in modern code.
+- **`@dataclass(...)`** is a *decorator*. A decorator is a function that wraps your class and modifies it. The `@dataclass` decorator looks at the three attributes you declared (`algorithm`, `confidence`, `reason`) and generates an `__init__` method, a `__repr__` method, and a few other dunder methods automatically. Without `@dataclass`, you'd have to write:
+
+ ```python
+ class HashCandidate:
+ def __init__(self, algorithm: str, confidence: Confidence, reason: str):
+ self.algorithm = algorithm
+ self.confidence = confidence
+ self.reason = reason
+ def __repr__(self):
+ return f"HashCandidate(algorithm={self.algorithm!r}, ...)"
+ def __eq__(self, other):
+ ...
+ ```
+
+ `@dataclass` writes all that for you.
+
+- **`frozen=True`** makes instances immutable. After the object is built, `candidate.algorithm = "different"` raises `FrozenInstanceError`. This is what makes `HashCandidate` a **value object** — like an integer or a tuple, it doesn't change after creation.
+
+- **`slots=True`** is a memory optimization. By default, every Python object has a `__dict__` so you can add arbitrary attributes to it on the fly. We don't want that — the three fields are all we'll ever have. `slots=True` tells Python to allocate a fixed array for the three fields, skipping the dict. Faster, smaller, and `obj.typo = "anything"` now also fails (which is good — it catches bugs).
+
+You'll use both flags together a lot for tiny data records. Together they say "this is a record, treat it like one."
+
+## 5. The PREFIX_RULES table
+
+```python
+PREFIX_RULES: list[tuple[str, str, str]] = [
+ ("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
+ ("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
+ ...
+]
+```
+
+This is a `list` of `tuple`s. Let's break that down.
+
+A **list** is Python's basic ordered container. You write it with square brackets: `[1, 2, 3]`. You can add to it, remove from it, index into it (`items[0]`).
+
+A **tuple** is like a list but immutable. You write it with parentheses: `(1, 2, 3)`. You can read from it but you can't change it after creation. Tuples are the right container when the *position* of each value has meaning — like coordinates `(x, y)`, or here, "the prefix, the algorithm name, and the note" always in that order.
+
+So `PREFIX_RULES` is a list of 3-tuples. Each tuple says "if you see this prefix, the algorithm is this name, and here is a short note about it."
+
+The type annotation `list[tuple[str, str, str]]` says exactly that: "a list whose elements are tuples of three strings." This is purely for the human reader and for mypy — at runtime Python doesn't enforce it.
+
+> **Why a list and not a dict?** A dict would let us look up by prefix in O(1) time. But our prefixes are not all the same length — `$2b$` is 4 chars, `$argon2id$` is 10. There's no fast "is this string a prefix of that string for any key in my dict" operation, so we walk the list. Performance is fine because the list is short (~25 entries) and we only do this once per program invocation.
+
+Notice the comment groupings — `# Argon2 family`, `# bcrypt and its many variants` — these are the only kind of comment we use heavily in foundations-tier projects. They name *sections* of related data so the reader can scan.
+
+The order of entries matters when two prefixes could overlap. Specifically, `$argon2id$` must come before `$argon2$` because `"$argon2id$something".startswith("$argon2$")` would *also* be true if `$argon2$` were in our table. We list more specific prefixes first so they match first.
+
+## 6. The HEX_LENGTH_RULES table
+
+```python
+HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
+_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
+
+HEX_LENGTH_RULES: dict[int, list[str]] = {
+ 16: ["MySQL323", "CRC-64"],
+ 32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
+ 40: ["SHA-1", "RIPEMD-160"],
+ ...
+}
+```
+
+A **set** is an unordered container of unique values. A **frozenset** is a set you can't modify after creation. We use frozenset here because:
+
+1. We never need to add/remove characters from the hex alphabet — it's known and fixed.
+2. Lookup (`c in HEX_CHARSET`) is O(1) — constant time. Faster than `c in "0123456789abcdef..."` which would scan the string character by character.
+3. Marking it `frozenset` signals to the reader: "this is a fixed constant, don't try to mutate it."
+
+A **dict** (dictionary) is a mapping from keys to values. You write it with curly braces: `{key: value, key: value}`. Lookup is O(1) on the key.
+
+`HEX_LENGTH_RULES` maps "length-in-hex-chars" to "list of algorithm names that produce that length." So `HEX_LENGTH_RULES[32]` is `["MD5", "NTLM", "MD4", "RIPEMD-128"]` — the four algorithms that produce a 32-hex-character output.
+
+`_HEX_UPPER_CHARSET` starts with an underscore. Convention: **leading underscore means module-private.** It's saying "this is an implementation detail, not part of the public interface." Python doesn't enforce this, but every linter does. The uppercase variant exists because MySQL5 prints its hex in uppercase only (`%02X` C format), so we use a tighter charset to avoid false positives on hand-typed inputs.
+
+## 7. The `_is_hex` helper
+
+```python
+def _is_hex(text: str) -> bool:
+ """Return True iff every character in text is a hex digit and text is non-empty"""
+ return bool(text) and all(c in HEX_CHARSET for c in text)
+```
+
+A `def` statement defines a function. Reading this signature:
+
+- `def _is_hex(text: str) -> bool:` declares a function named `_is_hex` that takes one argument `text` of type `str` and returns a `bool` (True or False).
+- The leading underscore makes it module-private.
+
+The body is one line. Let's read it right to left:
+
+- `(c in HEX_CHARSET for c in text)` is a **generator expression**. It produces a sequence of booleans: for each character `c` in `text`, yield `True` if `c` is in our hex charset, else `False`.
+- `all(...)` takes that sequence and returns `True` only if *every* yielded value is True. Equivalent to "every character of text is a hex digit."
+- `bool(text)` evaluates to `False` if `text` is empty, `True` otherwise. We need this guard because `all([])` of an empty sequence returns True (mathematically reasonable, practically annoying — an empty string is not a valid hex string).
+- `... and ...` short-circuits: if `bool(text)` is False, we don't bother checking `all(...)`.
+
+The word `iff` in the docstring is shorthand for "if and only if." Math nerds and CS people use it constantly; it means a biconditional.
+
+## 8. MySQL5 detection
+
+```python
+_MYSQL5_HEX_BODY_LENGTH = 40
+_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
+
+
+def _is_mysql5(text: str) -> bool:
+ """Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars ..."""
+ if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
+ return False
+ body = text[1:]
+ return all(c in _HEX_UPPER_CHARSET for c in body)
+```
+
+The two constants at the top come from one of the rules of this codebase: **no magic numbers**. Compare:
+
+```python
+if len(text) != 41 or not text.startswith("*"):
+ return False
+```
+
+vs:
+
+```python
+if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
+ return False
+```
+
+Both work. Only the second one tells the reader *why* 41 is the right number (it's 40 hex chars for the body plus 1 for the leading `*`).
+
+The function does three things:
+
+1. Check the total length is exactly 41 and the first character is `*`. If not, bail with False.
+2. Slice off the leading `*` using `text[1:]`. Python slicing: `text[start:stop]` gives you the substring from index `start` (inclusive) to `stop` (exclusive). Omitting `stop` means "to the end." So `text[1:]` means "everything from index 1 onwards" — i.e. drop the first character.
+3. Check that every character in the body is in our uppercase-only hex charset.
+
+The docstring contains an important caveat: we *cannot* use `body.isupper()` to enforce uppercase, because Python's `str.isupper()` returns False for a string with no cased characters at all. So `"0123456789ABCDEF...".isupper()` would correctly return True, but an all-digit body would return False — wrongly rejecting valid input. Checking membership in `_HEX_UPPER_CHARSET` is the test that actually matches the spec.
+
+This is the kind of subtle gotcha you only learn from being burned. Worth memorizing: **don't use `.isupper()` / `.islower()` as a "this string contains only uppercase chars" check.**
+
+## 9. DES crypt detection
+
+```python
+_DESCRYPT_CHARSET: frozenset[str] = frozenset(
+ "./0123456789"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz"
+)
+_DESCRYPT_TOTAL_LENGTH = 13
+
+
+def _is_descrypt(text: str) -> bool:
+ """Return True for traditional 13-char DES crypt (legacy /etc/passwd) ..."""
+ return (
+ len(text) == _DESCRYPT_TOTAL_LENGTH
+ and all(c in _DESCRYPT_CHARSET for c in text)
+ )
+```
+
+DES crypt is the *original* Unix password hash format from the 1970s. No prefix, no salt marker, just 13 characters from a specific 64-character alphabet.
+
+The charset definition uses **string literal concatenation**: three string literals sitting next to each other are automatically joined by Python at parse time. So this:
+
+```python
+"./0123456789"
+"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+"abcdefghijklmnopqrstuvwxyz"
+```
+
+is exactly the same as:
+
+```python
+"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+```
+
+But the split form is much easier to read. This works for any sequence of adjacent string literals — useful when wrapping long strings.
+
+`_is_descrypt` itself is two checks: right length, right alphabet. Both must be true (`and`). The whole expression is wrapped in parentheses just for visual layout — Python allows you to split expressions across lines if they're inside `()`, `[]`, or `{}`.
+
+## 10. The `identify` function — the brain
+
+This is the main attraction. It's about 100 lines long and follows the six-step pipeline from [02-ARCHITECTURE.md](./02-ARCHITECTURE.md).
+
+### 10a. The pylint silencing comment
+
+```python
+# pylint: disable=too-many-return-statements,too-many-branches
+```
+
+This comment turns off two specific pylint warnings *for this function only*. Pylint normally complains about functions with lots of `return` statements or lots of branches, because they're usually a sign that you should refactor.
+
+We're keeping the warnings off because in this case the branching *is* the structure. Six numbered steps, each potentially returning a result — refactoring them into six helper functions would scatter the pipeline across the file and make it harder, not easier, to read. So we acknowledge the warnings, explain why we're keeping them off in the inline comment block above, and turn them off explicitly.
+
+> **Rule of thumb on silencing linters:** never silence broadly. Always silence the *specific* warning, on the *specific* function, with a comment that explains why. If you find yourself silencing the same warning everywhere, your linter config is wrong, not your code.
+
+### 10b. The signature and docstring
+
+```python
+def identify(raw_input: str) -> list[HashCandidate]:
+ """Return ranked candidates for what algorithm produced `raw_input` ..."""
+```
+
+Takes a string. Returns a list of `HashCandidate` objects. That's the whole contract. The function does not raise exceptions for unknown inputs — it returns an empty list instead. (Throwing exceptions for "I don't know" forces every caller to wrap the call in try/except. Returning an empty list is cleaner.)
+
+The docstring is **Numpy-style**: it has labeled sections (`Parameters`, `Returns`) with the parameter name on its own line and the description indented underneath. Numpy style is what most scientific Python uses. The other big style is Google ("Args:" and "Returns:" with colons), and you'll see both in the wild. Either is fine; just pick one and be consistent.
+
+### 10c. Trim and bail on empty
+
+```python
+text = raw_input.strip()
+
+if not text:
+ return []
+```
+
+`str.strip()` returns a *new* string with leading and trailing whitespace removed. (Strings are immutable in Python — every "modifying" method actually returns a new string.) We do this because hashes copy-pasted from terminals often arrive with trailing newlines or leading spaces.
+
+We do *not* lowercase the text. Some formats (MySQL5) are case-sensitive on purpose.
+
+The `if not text:` check catches the empty string. In Python, an empty string is **falsy** — it counts as False in a boolean context. So `not text` is True when `text` is empty. Same for empty list, empty dict, `None`, and `0`. Get used to this — it's one of Python's defining features.
+
+### 10d. Step 1 — walk PREFIX_RULES
+
+```python
+for prefix, algorithm, note in PREFIX_RULES:
+ if text.startswith(prefix):
+ return [
+ HashCandidate(
+ algorithm=algorithm,
+ confidence="high",
+ reason=f"prefix `{prefix}` — {note}",
+ )
+ ]
+```
+
+A `for ... in ...` loop iterates over each element of a sequence. Here, each element of `PREFIX_RULES` is a 3-tuple, so we **unpack** it directly into three variables — `prefix`, `algorithm`, `note` — in one go. This is called *tuple unpacking* and it's used constantly in Python.
+
+`text.startswith(prefix)` is a method on `str` that returns True if `text` begins with `prefix`. There's also `endswith`, by the way.
+
+`f"..."` is an **f-string** (formatted string literal). Anything inside `{}` is evaluated and inserted into the string. So `f"prefix `{prefix}` — {note}"` produces something like `"prefix \`$2b$\` — bcrypt PHC string, 2b variant (current)"`. F-strings are the modern way to format strings in Python (3.6+); avoid the older `%` and `.format()` styles in new code.
+
+The function returns a list containing one `HashCandidate`. We use keyword arguments (`algorithm=algorithm`) instead of positional ones so the call reads clearly even if you don't remember the parameter order.
+
+The whole `if text.startswith(prefix):` check returns immediately on the first match. This is fine because the table is designed so that no two prefixes can match the same input (the longer, more specific ones come first).
+
+### 10e. Step 2 — special non-PHC shapes
+
+NetNTLMv2, NetNTLMv1, MySQL5, DES crypt. Each gets its own block.
+
+```python
+if "::" in text and text.count(":") >= 4:
+ parts = text.split(":")
+ if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
+ return [HashCandidate(algorithm="NetNTLMv2", ...)]
+ if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
+ return [HashCandidate(algorithm="NetNTLMv1", ...)]
+```
+
+A few Python features here:
+
+- `"::" in text` returns True if the substring `"::"` appears anywhere in `text`. The `in` operator works on strings (substring check), lists (membership), dicts (key lookup), sets (membership), and any iterable.
+- `text.count(":")` returns how many times `:` appears in the string.
+- `text.split(":")` returns a list of substrings, splitting on `:`. So `"a:b:c".split(":")` gives `["a", "b", "c"]`.
+- `parts[3]` indexes into the list. Python uses zero-based indexing, so `parts[3]` is the *fourth* element.
+
+NetNTLMv2 records look like `user::domain:challenge:hmac(32 hex):blob`. We split on `:`, then look at part index 4 (the hmac field): if it's exactly 32 hex characters, we've got a v2. NetNTLMv1 looks similar but the field at index 3 is 48 hex chars (the LM hash). We test v2 *first* because v2's distinguishing field at index 4 is more specific.
+
+This is the messiest step in the whole function. NetNTLM records were not designed to be pretty — they evolved from Microsoft authentication protocols of the 1990s. The shape match is the best we can do without parsing the entire NTLM protocol.
+
+```python
+if _is_mysql5(text):
+ return [HashCandidate(algorithm="MySQL5", confidence="high", ...)]
+
+if _is_descrypt(text):
+ return [HashCandidate(algorithm="DES crypt", confidence="medium", ...)]
+```
+
+Calls our helpers. Note that MySQL5 gets **HIGH** confidence (the `*` + 40 uppercase hex shape is essentially unique) while DES crypt gets **MEDIUM** (a 13-char `./0-9A-Za-z` string could plausibly be other things — some session IDs, some encoded values). Honesty is a feature.
+
+### 10f. Step 3 — hex + length lookup
+
+```python
+if _is_hex(text):
+ algorithms = HEX_LENGTH_RULES.get(len(text), [])
+ candidates: list[HashCandidate] = []
+ for index, algorithm in enumerate(algorithms):
+ confidence: Confidence = "medium" if index == 0 else "low"
+ label = (
+ "most likely candidate at this length"
+ if index == 0 else "also possible at this length"
+ )
+ candidates.append(
+ HashCandidate(algorithm=algorithm, confidence=confidence, reason=...)
+ )
+ return candidates
+```
+
+Three new Python features here:
+
+- **`dict.get(key, default)`** returns the value if the key exists, or `default` if not. So `HEX_LENGTH_RULES.get(len(text), [])` returns the list of algorithms at this length, or an empty list if no rule exists for this length. This avoids a `KeyError` exception that would happen with `HEX_LENGTH_RULES[len(text)]` on an unknown length.
+- **`enumerate(iterable)`** is a built-in that wraps an iterable and yields `(index, value)` pairs. So `for index, algorithm in enumerate(algorithms)` walks the list and gives us both the position and the value. The first algorithm gets index 0, the second gets index 1, etc.
+- **Ternary expression**: `value_if_true if condition else value_if_false`. So `"medium" if index == 0 else "low"` evaluates to `"medium"` for the first item and `"low"` for the rest. This is the Python equivalent of `cond ? a : b` in C/JavaScript.
+
+The first algorithm at each length is the one that's most common in 2026 — MD5 at length 32, SHA-1 at length 40, SHA-256 at length 64. The list ordering in `HEX_LENGTH_RULES` is by descending prevalence, so "first" really does mean "most likely."
+
+`candidates.append(item)` adds an item to the end of the list. `list[T]` is a generic type — `list[HashCandidate]` means "a list whose elements are HashCandidate objects." The empty list literal `[]` is unannotated, so we write `candidates: list[HashCandidate] = []` to tell mypy what we intend the list to hold.
+
+### 10g. Step 4 — generic PHC fallback
+
+```python
+if text.startswith("$"):
+ rest = text[1:]
+ if "$" in rest:
+ algo_name = rest.split("$", 1)[0]
+ if algo_name and all(c.isalnum() or c in "-_" for c in algo_name):
+ return [HashCandidate(algorithm=f"PHC string ({algo_name})", ...)]
+```
+
+If the input starts with `$` but didn't match any of our specific rules, it might still be a PHC string from an algorithm we don't have a rule for. We try to extract the algorithm name and report it as a generic PHC at LOW confidence.
+
+- `text[1:]` slices off the leading `$`.
+- `rest.split("$", 1)` splits on `$` but only once — the optional second argument to `split` is the max number of splits. So `"argon2id$v=19$...".split("$", 1)` returns `["argon2id", "v=19$..."]`. Without the `1`, it would split on every `$` and we'd lose information.
+- `[0]` takes the first element of the resulting list.
+- `c.isalnum()` is a `str` method returning True if `c` is a letter or digit. We use this to validate that the algorithm name field contains only PHC-legal characters (alphanumeric plus `-` and `_`). Anything weirder and we bail rather than make up an algorithm name from garbage.
+
+The whole step is gated on the algorithm name being non-empty AND every character being legal. Better to report nothing than to guess wildly.
+
+### 10h. Step 5 — shape hints
+
+```python
+if text.startswith("eyJ"):
+ return [HashCandidate(algorithm="JWT (not a hash)", ...)]
+
+if any(c in text for c in "+/=") and len(text) > 8:
+ return [HashCandidate(algorithm="Base64 blob (not a hash)", ...)]
+```
+
+People paste JWTs and base64 blobs into hash identifiers all the time. Rather than returning a silent "no match," we point out what they probably pasted.
+
+JWTs always start with `eyJ` because the JWT header is JSON like `{"alg":"HS256","typ":"JWT"}`, and the bytes `{"` base64-encoded begin with `eyI` or `eyJ`. The leading `eyJ` is essentially diagnostic for JWTs.
+
+`any(c in text for c in "+/=")` is a generator expression inside `any()` — mirror of the `all(...)` we used in `_is_hex`. `any()` returns True if *any* element of the sequence is True. Together: "is there any character in `'+/='` that appears in `text`?" If yes, the input contains base64-only characters and cannot be a hex hash.
+
+The `len(text) > 8` floor exists because a short string like `"a+b=c"` might trip the base64 check accidentally. We require enough length to be sure it's actually base64, not a math expression.
+
+### 10i. Step 6 — give up
+
+```python
+return []
+```
+
+Empty list. The CLI prints "could not identify" when it sees this. Always better to admit defeat than to lie with confidence.
+
+## 11. The CLI layer
+
+### 11a. The argument parser
+
+```python
+def _build_argument_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="hashid", description="...")
+ parser.add_argument("hash", help="The hash string to identify ...")
+ parser.add_argument("--top", "-n", type=int, default=5, help="...")
+ return parser
+```
+
+`argparse.ArgumentParser` is the standard library's CLI parser. We set:
+
+- `prog="hashid"` — the program name shown in help text.
+- `description="..."` — the one-line summary at the top of `--help`.
+
+Then we add two arguments:
+
+- **`"hash"`** — a positional argument. The user types `hashid `. Required. After parsing, `args.hash` holds the string.
+- **`"--top", "-n"`** — an optional flag with both a long form (`--top 3`) and short form (`-n 3`). `type=int` converts the string the user typed into an integer. `default=5` is what you get if the user doesn't pass it.
+
+The function *returns* the parser without calling `.parse_args()`. Why? So the test file can build the parser, inspect it, run it on test input, etc., without actually executing the CLI. **Separating construction from execution is a recurring pattern for testability.** Whenever you find yourself writing `something().run()` in one line, ask whether someone needs to build that something without running it.
+
+### 11b. The table renderer
+
+```python
+def _render_table(raw_input, candidates, console) -> None:
+ table = Table(title=f"Candidates for: {raw_input.strip()}", ...)
+ table.add_column("algorithm", style="bold white", no_wrap=True)
+ table.add_column("confidence", no_wrap=True)
+ table.add_column("reason", style="dim")
+
+ confidence_colors: dict[Confidence, str] = {
+ "high": "green",
+ "medium": "yellow",
+ "low": "cyan",
+ }
+ for candidate in candidates:
+ color = confidence_colors[candidate.confidence]
+ table.add_row(
+ candidate.algorithm,
+ f"[{color}]{candidate.confidence}[/{color}]",
+ candidate.reason,
+ )
+ console.print(table)
+```
+
+`rich.Table` is a class from the `rich` library. You create a Table, add columns, add rows, then print it. The library handles all the box-drawing characters, color codes, terminal-width detection, and Unicode handling.
+
+The `f"[{color}]{candidate.confidence}[/{color}]"` syntax is `rich`'s inline color markup. `[green]text[/green]` colors `text` green. We use a dict to look up the color for each confidence level — three colors, predictable, easy to change in one place if we wanted to.
+
+The `-> None` return type means the function doesn't return anything meaningful (it just has side effects: printing to the terminal). This is the right annotation for "this function does its work via side effects."
+
+`console` is passed in as a parameter rather than created inside. Same idea as the parser: the test can pass a captured-output Console, the real CLI passes a real Console. Dependency injection at work.
+
+### 11c. `main()` and the script guard
+
+```python
+def main() -> int:
+ parser = _build_argument_parser()
+ args = parser.parse_args()
+ console = Console()
+
+ candidates = identify(args.hash)
+
+ if not candidates:
+ console.print("[red]No identification possible.[/red] ...")
+ return 1
+
+ trimmed = candidates[:args.top]
+ _render_table(args.hash, trimmed, console)
+
+ if trimmed[0].confidence == "high":
+ console.print("\n[dim]Next step: try the matching cracker ...[/dim]")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+```
+
+`main()` is the entry point. The body reads like English:
+
+1. Build the parser.
+2. Parse `sys.argv` (`parser.parse_args()` defaults to using `sys.argv`).
+3. Build a Console.
+4. Run the brain on the user's input.
+5. If no candidates, print an error and return exit code 1.
+6. Trim to the top-N results (`candidates[:args.top]` is slicing — same as before).
+7. Render the table.
+8. If the top candidate is HIGH confidence, print a hint about what to do next.
+9. Return exit code 0.
+
+The function returns the exit code as an integer. We hand it to `sys.exit()` at the bottom.
+
+The final block:
+
+```python
+if __name__ == "__main__":
+ sys.exit(main())
+```
+
+is the classic Python script idiom. `__name__` is a special variable Python sets automatically:
+
+- When you run `python hash_identifier.py`, Python sets `__name__ = "__main__"`.
+- When you `import hash_identifier` from somewhere else, Python sets `__name__ = "hash_identifier"`.
+
+So `if __name__ == "__main__":` means "only do this when the file is run directly, not when it's imported." This lets the test file `import hash_identifier` and call `identify()` without accidentally firing the CLI.
+
+`sys.exit(N)` terminates the program with exit code N. Exit code 0 conventionally means success; non-zero means failure. Shell scripts (`if hashid "$x"; then ...`) read the exit code to decide what to do next.
+
+## 12. Running through a real example
+
+Let's trace what happens for `just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G'`.
+
+```
+1. Shell invokes: python hash_identifier.py '$2b$12$EixZ...'
+2. Python runs the file. sys.argv = [".../hash_identifier.py", "$2b$12$EixZ..."]
+3. Since __name__ == "__main__", call sys.exit(main()).
+4. main() builds parser, calls parser.parse_args().
+5. argparse sees positional arg → args.hash = "$2b$12$EixZ..."
+ args.top = 5 (default).
+6. main() calls identify(args.hash).
+
+7. identify():
+ text = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
+ text is non-empty.
+ Step 1: walk PREFIX_RULES.
+ First row $argon2id$ — does text start with "$argon2id$"? no.
+ ... a few more rows ...
+ Row ("$2b$", "bcrypt", ...) — does text start with "$2b$"? YES!
+ return [HashCandidate(algorithm="bcrypt", confidence="high", reason="prefix `$2b$` — bcrypt PHC string, 2b variant (current)")]
+
+8. Back in main(): candidates is non-empty.
+9. trimmed = candidates[:5] → just the one candidate.
+10. _render_table(args.hash, trimmed, console)
+ Build a Table with title "Candidates for: $2b$12$EixZ...".
+ Add three columns.
+ Build one row: ("bcrypt", "[green]high[/green]", "prefix `$2b$` — ...").
+ console.print(table) — rich draws the colored ASCII table to your terminal.
+11. Top candidate is HIGH confidence → print the "Next step" nudge.
+12. return 0.
+13. sys.exit(0) — clean exit.
+```
+
+Total elapsed time: well under a millisecond for the brain, a few more for `rich` to render. Almost all of the program's runtime is `rich` drawing the table.
+
+## 13. The test file, in brief
+
+Open `test_hash_identifier.py` if you haven't yet. It's structured as ~25 small `test_*` functions. Each one:
+
+1. Builds a known input.
+2. Calls `identify(input)` or `_is_mysql5(input)` etc.
+3. Asserts something about the output (`assert candidates[0].algorithm == "bcrypt"`).
+
+A few interesting ones to read:
+
+- **`test_every_prefix_rule_is_recognized_with_high_confidence`** (~line 531) — loops over `PREFIX_RULES` and confirms every row produces a HIGH-confidence match. Adds rows automatically if you add new prefixes.
+- **`test_mysql5_rejects_lowercase_body`** (~line 188) — confirms the "don't lie with confidence" rule from the implementation.
+- **`test_hash_candidate_is_frozen`** (~line 481) — uses `pytest.raises` to confirm that mutating a frozen dataclass raises `FrozenInstanceError`.
+
+Run them: `just test`. The whole suite is ~30 tests and finishes in under a second.
+
+## 14. What to try next
+
+You've read the file. To make the knowledge stick:
+
+1. Try `just run -- ` with weird inputs — empty string (after quoting), pure digits, super long strings, hashes with trailing whitespace, JWTs, base64.
+2. Open `hash_identifier.py` and add a `print()` statement inside `identify()` to see which step matches for each input. Then remove it before committing.
+3. Add a new prefix to `PREFIX_RULES` — for instance, scrypt sometimes shows up with `$scrypt$` prefix. Add it, add a test, run `just test`.
+4. Read **[04-CHALLENGES.md](./04-CHALLENGES.md)** for harder extension ideas.
diff --git a/PROJECTS/foundations/hash-identifier/learn/04-CHALLENGES.md b/PROJECTS/foundations/hash-identifier/learn/04-CHALLENGES.md
new file mode 100644
index 00000000..cf714716
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/learn/04-CHALLENGES.md
@@ -0,0 +1,222 @@
+# Challenges
+
+You've read the code. You know what every line does. Now the only way to make this knowledge actually yours is to *change* the code. This page is a ladder of extensions, easiest to hardest. Don't skip rungs — each one teaches a thing that the next one assumes.
+
+For every challenge: **write the test first.** Then make the test pass. That's the rhythm professional developers actually use. The test file already shows you the pattern — copy one of the existing `test_*` functions, change the input and expected output, watch it fail, then change the code until it passes.
+
+## Tier 1 — get comfortable
+
+### Challenge 1.1: Add a new prefix rule
+
+`PREFIX_RULES` has ~25 entries today. There are dozens more. Pick one from the list below and add it:
+
+| Prefix | Algorithm | Where it comes from |
+| ------------ | ------------------ | ----------------------------------------- |
+| `$pbkdf2$` | PBKDF2-SHA1 (Atlassian) | Older Atlassian / Jira hashes |
+| `$ml$` | macOS / iCloud Keychain | Apple PBKDF2-SHA512 |
+| `{x-pbkdf2}` | PBKDF2 (some Atlassian) | LDAP-style wrapper |
+| `$sha1$` | sha1crypt | A rare crypt(3) variant |
+| `$md5,` | Solaris MD5 crypt | Note the comma instead of `$` — be careful |
+
+**Steps:**
+
+1. Open `hash_identifier.py`. Add one row to `PREFIX_RULES`.
+2. Open `test_hash_identifier.py`. Copy an existing prefix test (e.g. `test_argon2id_prefix_is_recognized`). Rename it. Replace the input string with a sample of your new prefix. Update the assertion.
+3. Run `just test`. The new test should pass. The meta-test `test_every_prefix_rule_is_recognized_with_high_confidence` should also still pass.
+4. Run the tool on your new input: `just run -- '$pbkdf2$...'`. Confirm the new algorithm shows up.
+
+**What you learn:** the table-driven design pays off — you wrote zero new logic, just data. That's the goal.
+
+### Challenge 1.2: Add a length to HEX_LENGTH_RULES
+
+There's no rule for 24 hex chars (96 bits) right now. That length is rare but `Tiger-128` and some old custom hashes produce it.
+
+1. Add `24: ["Tiger-128"]` to `HEX_LENGTH_RULES`.
+2. Write a test (`test_tiger128_length_returns_tiger128`).
+3. Run `just test`.
+
+**Twist:** what should happen if someone passes a 24-character string that *isn't* hex? The existing `_is_hex` check should handle it. Read step 3 of `identify()` and confirm.
+
+### Challenge 1.3: Add a `--json` output mode
+
+Right now the CLI only prints a colored table. Add a `--json` flag that prints the candidates as JSON instead. JSON is what every other tool will want to consume — your output becomes machine-readable.
+
+**Hints:**
+
+- `argparse` supports boolean flags via `action="store_true"`. Add `parser.add_argument("--json", action="store_true", help="...")`.
+- The standard library `json` module has `json.dumps(data)`.
+- `HashCandidate` is a dataclass, so `dataclasses.asdict(candidate)` converts it to a plain dict that `json.dumps` can serialize.
+- Test it: `just run -- --json 5f4d...` should output a JSON array.
+
+**Twist:** make the JSON include a top-level `input` field with the original string, so a downstream tool knows what was identified. And pretty-print with `indent=2`.
+
+## Tier 2 — actually new behavior
+
+### Challenge 2.1: Read hashes from a file or stdin
+
+Right now the tool takes one hash per invocation. Real workflows have files with millions of hashes. Extend the CLI so it can take input from a file (`--file hashes.txt`) or from standard input when no positional argument is given (so `cat hashes.txt | hashid` works).
+
+**Hints:**
+
+- Make the positional `hash` argument optional with `nargs="?"`.
+- Add `--file` with `type=argparse.FileType("r")`.
+- Use `sys.stdin.read()` (or iterate `sys.stdin` line-by-line) when both are missing.
+- The output for batch input should be different — probably one line per input, not a colored table for each. Decide what format makes sense and document it.
+
+**Twist:** what should happen if the same input appears 1000 times in the file? Should you re-run `identify()` each time, or cache results? Try both and measure with `just run` on a file of 1M repeated hashes. (Bigger lesson: caching is only a win when the function is pure. Ours is.)
+
+### Challenge 2.2: Add hashcat-mode hints
+
+Hashcat assigns a numeric mode to every algorithm: 0 for MD5, 100 for SHA-1, 3200 for bcrypt, etc. The full list is documented at [hashcat.net/wiki/doku.php?id=example_hashes](https://hashcat.net/wiki/doku.php?id=example_hashes).
+
+Extend `HashCandidate` with an optional `hashcat_mode: int | None` field. When you build a candidate, look up its mode (you'll need a `dict[str, int]` mapping algorithm name → mode) and fill it in.
+
+Then print the mode in the table, and update the "next step" nudge at the end of `main()` to suggest the exact hashcat command:
+
+```
+Next step: hashcat -m 3200 -a 0 '$2b$12$EixZ...' wordlist.txt
+```
+
+**Hints:**
+
+- `Optional` fields on a dataclass need defaults: `hashcat_mode: int | None = None`.
+- The mode lookup is another data table — keep the data-driven design.
+- John the Ripper uses different names (e.g. `bcrypt`, `raw-md5`). Add those too if you're feeling generous.
+
+### Challenge 2.3: Recognize more "not a hash" inputs
+
+Step 5 only catches JWTs and base64 blobs. Many other things get pasted into hash identifiers by accident. Add detectors for:
+
+- **URLs** — start with `http://` or `https://`. Tell the user it's a URL.
+- **Hex with `0x` prefix** — Ethereum addresses, memory addresses. Tell the user.
+- **Base58** — used by Bitcoin addresses and IPFS hashes. Alphabet is `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz` (no `0`, `O`, `I`, `l`).
+- **Base32** — uppercase letters + digits 2-7. Used by some Tor onion addresses and TOTP secrets.
+
+Each one is a new branch in step 5, returning a LOW-confidence "not a hash" candidate. Be honest with the confidence — these are *shape* hints, not certainties.
+
+## Tier 3 — extend the model
+
+### Challenge 3.1: Multi-hash detection in one string
+
+Some breach dumps contain *combined* records like `user:hash:salt`. Real workflows often have to split these into the constituent parts before identification. Add a `--split` mode that:
+
+1. Takes a string with colon-separated fields.
+2. Identifies each field independently.
+3. Prints a table showing which field is what (username, hash, salt, garbage).
+
+**Hints:**
+
+- This is mostly heuristics. A field that's all letters is probably a username; a field that matches a hash rule is probably the hash; a short random-looking field could be a salt.
+- Pull the field-classification logic out of `identify()` — it's a new layer on top.
+- Test on real-looking lines like `alice:$2b$12$EixZ...`, `bob:5f4dcc3b5aa765d61d8327deb882cf99:salt123`.
+
+### Challenge 3.2: Confidence rebalancing with evidence weights
+
+Right now `confidence` is a single value: high, medium, or low. But evidence is more nuanced. A 32-hex string that *also* contains no characters above 'f' is "very likely hex." A 13-character string from the DES charset is "13-char DES-compatible" but the same characters could appear in many short strings.
+
+Replace `confidence: Literal["high", "medium", "low"]` with `confidence_score: float` in the range 0.0–1.0. Compute the score from multiple evidence weights:
+
+- prefix match: 0.95
+- special shape match: 0.85
+- length match (1st candidate): 0.55
+- length match (Nth candidate): 0.55 / N
+- charset match: small additive bonus
+- not-a-hash hint: 0.30
+
+Then in the CLI, map score back to a color (>0.8 green, 0.5–0.8 yellow, <0.5 cyan) for display. The user still sees three buckets; the internal model gets richer.
+
+**What you learn:** how scoring systems work under the hood. This is the same idea as Bayesian spam classifiers, search relevance ranking, and antivirus heuristics — combine multiple weak signals into one numeric score, then bucket for display.
+
+### Challenge 3.3: Suggest the *crack difficulty* alongside the algorithm
+
+Once you know the algorithm, you also implicitly know how hard the hash is to crack. MD5 cracks at billions of guesses per second on a modern GPU; bcrypt at thousands. Argon2id with strong parameters might be hundreds.
+
+Add a `crack_difficulty: Literal["trivial", "moderate", "hard", "very_hard"]` field to `HashCandidate`, filled in from a per-algorithm table. Print it in the output table.
+
+**Twist:** for parameterized hashes (bcrypt cost factor, Argon2 memory/time), parse the parameters out of the PHC string. A bcrypt hash with cost factor 4 is much weaker than one with cost factor 14. Have your output reflect that:
+
+```
+algorithm difficulty reason
+───────── ────────── ──────────────────────────────────────────
+bcrypt moderate cost=4 — much weaker than the default 12
+bcrypt hard cost=12 — the modern default
+bcrypt very_hard cost=14 — paranoid setting
+```
+
+## Tier 4 — make it real
+
+### Challenge 4.1: Run identification against a real breach dump
+
+The [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) password file is a publicly-distributed list of ~1 billion SHA-1 hashes of leaked passwords, available as a torrent. (Use the **SHA-1** version, not the NTLM version — and use it only for educational analysis; don't try to "crack" it for any malicious purpose. The hashes themselves are public.)
+
+Run your tool against the first 1000 lines. Confirm it identifies them all as SHA-1 (40 hex chars). Measure throughput: how many hashes per second can your tool process? Where's the bottleneck?
+
+**Bigger lesson:** the brain is microseconds; the bottleneck is the CLI printing. To process millions of hashes you'd skip `rich` and stream JSON to stdout. That's a different program for a different use case.
+
+### Challenge 4.2: Compare to `hashid` and `name-that-hash`
+
+Two existing tools do roughly what ours does:
+
+- [hashid](https://github.com/psypanda/hashID) — the classic, ~10 years old, written in pure Python.
+- [name-that-hash](https://github.com/HashPals/Name-That-Hash) — newer, more thorough, more aggressive about guessing.
+
+Run all three on the same inputs. Compare:
+
+- Which detects more formats?
+- Which has the best false-positive rate (says "definitely SHA-256" on garbage that isn't)?
+- Which is fastest on a batch input?
+
+Write up your findings. This is exactly the kind of work security researchers do when picking a tool for production use.
+
+### Challenge 4.3: Wrap it into a `pre-commit` hook
+
+`pre-commit` is a tool that runs checks before you `git commit`. People sometimes accidentally commit password hashes to repos (extremely bad). Build a `pre-commit` hook that runs your identifier on every changed file and refuses the commit if it finds anything that looks like a real hash.
+
+**Hints:**
+
+- Read [pre-commit's hook tutorial](https://pre-commit.com/#new-hooks).
+- For each line of each changed file, run `identify(line)`. If the top candidate is HIGH confidence and isn't a generic-PHC or not-a-hash, refuse.
+- Exit code 1 = block the commit; exit code 0 = allow it.
+- Add allowlist comments like `# pragma: allow-hash` so that legitimate test fixtures don't get blocked.
+
+This is the kind of "small tool, real impact" project that ends up in real security teams' toolchains.
+
+## Tier 5 — break the model
+
+### Challenge 5.1: Why is this a hard problem?
+
+So far we've matched on *structure*: prefix, length, charset. But the model has limits. Consider:
+
+- Two different algorithms producing the same length output (MD5 vs NTLM at 32 hex chars). We *can't* distinguish them from structure alone.
+- An algorithm whose output is sometimes uppercase and sometimes lowercase (different libraries produce different cases for the same algo).
+- A truncated SHA-256 — someone took the first 32 hex chars of a SHA-256 output and called it a "hash." We'd identify it as MD5.
+
+Write a short document (`docs/limitations.md`) listing every case where the tool *cannot* distinguish two formats and explain why. This is the kind of honesty good security tools document up-front. Don't pretend the tool is more capable than it is.
+
+### Challenge 5.2: Probabilistic identification with a ML classifier
+
+The structural approach is interpretable but limited. The opposite extreme is to train a classifier on labeled hashes and let it learn the structure for you.
+
+Build a tiny experiment:
+
+1. Generate 100k labeled training samples: known passwords hashed with each algorithm. (Use Python's `hashlib`, `bcrypt`, `argon2-cffi`, etc.)
+2. Train a simple classifier — `sklearn.linear_model.LogisticRegression` with character-n-gram features works fine for this.
+3. Run the classifier on held-out test samples. Measure accuracy.
+4. Compare to your rule-based identifier on the same test set.
+
+**What you'll learn:** the rule-based system probably wins on common formats (it has all the structural priors baked in) and the ML model probably wins on unusual cases (it picks up patterns you didn't think to encode). Real-world tools combine both — rules first, ML for tie-breaking. This is how spam classifiers, web application firewalls, and antivirus engines actually work.
+
+> **Don't run any of these challenges on real user data without permission.** The challenges assume publicly-released breach archives, your own test data, or CTF inputs. Identifying hashes from data you don't have rights to is a different conversation and outside the scope of this project.
+
+## Where to go after the challenges
+
+If you finished tier 4 or 5, you should now have a strong feel for the *whole loop*: read code → understand → modify → test → measure → document. That loop is the entire job of a security engineer. The specific topic doesn't matter much — you'd do the same loop for a port scanner, a fuzzer, a SIEM rule, or a vulnerability scanner.
+
+When you're ready for more:
+
+- **`PROJECTS/beginner/hash-cracker`** — the natural sibling to this tool. You learned to identify a hash; that project teaches you how to crack one.
+- **`PROJECTS/foundations/http-headers-scanner`** — another foundations-tier project, single-file Python. Slightly more involved I/O (it makes a network request) but the same architectural shape.
+- **`PROJECTS/foundations/password-manager`** — the hardest foundations project. Encryption, key derivation, and a real on-disk vault. Read [01-CONCEPTS.md](./01-CONCEPTS.md) first to understand why Argon2id is the right choice for the key derivation step.
+- **`PROJECTS/beginner/simple-port-scanner`** — once you've absorbed the patterns here, port scanning is a natural next step. Sockets, concurrency, and timing all show up.
+
+Good luck. Read everything. Write code. Break it. Fix it. That's how you learn this.
diff --git a/PROJECTS/foundations/hash-identifier/pyproject.toml b/PROJECTS/foundations/hash-identifier/pyproject.toml
new file mode 100644
index 00000000..6185c772
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/pyproject.toml
@@ -0,0 +1,216 @@
+# ©AngelaMos | 2026
+# pyproject.toml
+#
+# This file is the "instruction manual" for our Python project. Every
+# modern Python project has one. It tells tools like uv, pip, pytest,
+# ruff, mypy, and pylint:
+#
+# - What our project is called
+# - Which third-party libraries we depend on
+# - How to build and install it
+# - How to lint, type-check, and test it
+#
+# The file format is called TOML — a simple, human-readable config
+# language. Square brackets like [project] start a "table" (a section).
+# Lines like name = "..." are key-value pairs inside that table.
+#
+# Read this top-to-bottom. Each section has a short comment explaining
+# what it does and why it is here.
+
+
+# =============================================================================
+# [project] — the metadata that describes WHAT this project is
+# =============================================================================
+# This section is standardized across the whole Python ecosystem (PEP 621).
+# Anyone who runs `pip install` or `uv sync` reads from here.
+[project]
+# The name people will install this package under.
+# Must be unique on PyPI if we ever publish there.
+name = "hash-identifier"
+
+# Semantic version: MAJOR.MINOR.PATCH.
+# 1.0.0 means "first stable release."
+# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
+version = "1.0.0"
+
+# One-line description that shows up in `pip show` and on PyPI.
+description = "Identify hash types by prefix, length, and charset (foundations tier)"
+
+# The minimum Python version this project supports.
+# >=3.13 means "Python 3.13 or anything newer."
+# We need 3.13 because we use modern type-hint syntax (X | None).
+requires-python = ">=3.13"
+
+# Who wrote it. Shows up in package metadata.
+authors = [
+ {name = "CarterPerez-dev", email = "support@certgames.com"}
+]
+
+# The README file is shown on PyPI and in `pip show`.
+readme = "README.md"
+
+# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
+# service, they must publish their changes. It protects the project
+# from being privatized.
+license = {text = "AGPL-3.0-or-later"}
+
+# -----------------------------------------------------------------------------
+# dependencies — third-party libraries we MUST have at runtime
+# -----------------------------------------------------------------------------
+# When a user runs `pip install hash-identifier`, these get installed too.
+# The string format is "". We use ranges so we
+# get bug fixes (>=) but never an incompatible major version (<).
+#
+# Just one runtime dependency — the detection logic is pure stdlib.
+dependencies = [
+ # rich: pretty terminal output — colors, tables, panels.
+ # Used to render the results of identification as a clean table.
+ "rich>=15.0.0",
+]
+
+# -----------------------------------------------------------------------------
+# optional-dependencies — extra libraries that are NOT needed at runtime
+# -----------------------------------------------------------------------------
+# These only matter to developers (testing, linting, formatting). End users
+# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
+[project.optional-dependencies]
+dev = [
+ # pytest — runs our test suite (the test_*.py file in this directory).
+ "pytest>=9.0.3",
+
+ # ruff — extremely fast linter and formatter. Catches bugs, style
+ # issues, dead imports. Modern replacement for flake8/black/isort.
+ "ruff>=0.15.12",
+
+ # mypy — static type checker. Reads our type hints and tells us if
+ # we passed a string where an int was expected, BEFORE running the code.
+ "mypy>=2.1.0",
+
+ # pylint — second linter that catches deeper logic issues ruff misses.
+ "pylint>=4.0.5",
+
+ # yapf — code formatter. Keeps every file looking identical.
+ "yapf>=0.43.0,<1.0.0",
+]
+
+# -----------------------------------------------------------------------------
+# project.scripts — command-line entry points
+# -----------------------------------------------------------------------------
+# After `uv sync`, the user can type `hashid ` instead of
+# `python hash_identifier.py `. The right-hand side is
+# ":" — the function gets called when the command runs.
+# Here it points to the `main` function inside hash_identifier.py.
+[project.scripts]
+hashid = "hash_identifier:main"
+
+
+# =============================================================================
+# [build-system] — how to BUILD this project into an installable package
+# =============================================================================
+# Required by PEP 517. Tools like uv read this to know which builder
+# to use. We chose hatchling — modern, fast, zero-config for our case.
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+# Tell hatchling exactly which file to package. This project is a SINGLE
+# Python file (no src// layout), so we use `only-include` to
+# point at it directly. Without this, hatchling would not know what
+# to ship.
+[tool.hatch.build.targets.wheel]
+only-include = ["hash_identifier.py"]
+
+
+# =============================================================================
+# [tool.ruff] — linter / formatter configuration
+# =============================================================================
+# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
+[tool.ruff]
+# Target Python version — ruff adjusts which warnings apply.
+target-version = "py313"
+
+# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
+line-length = 88
+
+# -----------------------------------------------------------------------------
+# Which lint rules to enable. Each "code" is a category of check.
+# -----------------------------------------------------------------------------
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors — basic PEP 8 spacing/indent rules
+ "F", # Pyflakes — unused imports, undefined names, real bugs
+ "W", # pycodestyle warnings — softer style issues
+ "B", # Bugbear — sneaky bugs (mutable default args, etc.)
+ "C4", # Comprehensions — encourages cleaner list/dict comprehensions
+ "UP", # Pyupgrade — flags old syntax we should modernize
+ "SIM", # Simplify — suggests cleaner equivalents
+]
+
+# Some rules we deliberately ignore.
+ignore = [
+ # Line length is handled by yapf, not ruff. Avoids double-flagging.
+ "E501",
+]
+
+
+# =============================================================================
+# [tool.mypy] — static type checker configuration
+# =============================================================================
+# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
+# without running the code. Catches whole categories of bugs at edit time.
+[tool.mypy]
+python_version = "3.13"
+
+# Require type annotations on every function — strict mode.
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+# Don't auto-add Optional just because a default is None. Be explicit.
+no_implicit_optional = true
+
+# Warn if we cast a value to a type it already has (dead code).
+warn_redundant_casts = true
+
+# Warn if a function might fall off the end without returning.
+warn_no_return = true
+
+# Pretty error output — helps when reading mypy output.
+show_error_codes = true
+pretty = true
+
+
+# =============================================================================
+# [tool.pylint] — second-opinion linter
+# =============================================================================
+# Slower than ruff but catches different things — class design issues,
+# dead variables, complex code patterns.
+[tool.pylint.main]
+# Match the project's Python version.
+py-version = "3.13"
+
+# Run lint checks in parallel across 4 cores for speed.
+jobs = 4
+
+# Specific pylint warnings we deliberately turn off.
+[tool.pylint.messages_control]
+disable = [
+ "R0903", # too-few-public-methods — small data classes are fine
+ "C0103", # invalid-name — short names like `s` for hash strings are clearer
+]
+
+
+# =============================================================================
+# [tool.pytest.ini_options] — test runner configuration
+# =============================================================================
+[tool.pytest.ini_options]
+# Where to look for tests. "." = the project root, since this project's
+# test file lives next to the source file.
+testpaths = ["."]
+
+# Files that match this pattern are treated as test modules.
+python_files = ["test_*.py"]
+
+# Default flags every `pytest` invocation gets:
+# -v → verbose, show each test name
+# --tb=short → short tracebacks on failure (less wall of text)
+addopts = "-v --tb=short"
diff --git a/PROJECTS/foundations/hash-identifier/test_hash_identifier.py b/PROJECTS/foundations/hash-identifier/test_hash_identifier.py
new file mode 100644
index 00000000..0157471e
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/test_hash_identifier.py
@@ -0,0 +1,554 @@
+"""
+©AngelaMos | 2026
+test_hash_identifier.py
+
+Tests for hash_identifier — focused on the most-relied-on cases
+
+────────────────────────────────────────────────────────────────────
+What "tests" are and why we write them
+────────────────────────────────────────────────────────────────────
+A test is a tiny Python function that calls our real code with a
+known input and then ASSERTS that the result is what we expected.
+If the assertion fails, pytest prints a red FAIL message — which
+means we changed something and broke a behavior we cared about
+
+Tests are insurance. The first time you write the code, the test
+just confirms it works. But six months later when you refactor or
+add a new feature, the existing tests catch any accidental breakage.
+This is why every senior codebase has tests: not because the code is
+hard to write, but because the code is hard to keep WORKING over time
+
+────────────────────────────────────────────────────────────────────
+The shape of a pytest test
+────────────────────────────────────────────────────────────────────
+ def test_() -> None:
+ result = some_function(some_input)
+ assert result == expected
+
+Three rules
+ 1. The function name must start with `test_` — pytest only collects
+ functions that match that pattern
+ 2. The function takes no arguments (unless using fixtures)
+ 3. Use the `assert` keyword to declare what should be true. If the
+ condition is false, the test fails
+
+We follow the "Arrange-Act-Assert" structure inside each test
+ - Arrange: set up inputs (the `sample = ...` line)
+ - Act: call the real code (`candidates = identify(sample)`)
+ - Assert: check the result (`assert candidates[0]...`)
+
+────────────────────────────────────────────────────────────────────
+Coverage strategy
+────────────────────────────────────────────────────────────────────
+We do NOT try to test every algorithm in the table — that would be
+hundreds of nearly identical tests. Instead we exercise each BRANCH
+of identify() at least once
+
+ - prefix matches (one bcrypt, one Argon2id, one Django, one crypt)
+ - special MySQL5 format
+ - hex length matches (MD5, SHA-1, SHA-256 lengths)
+ - the empty / garbage / whitespace fallbacks
+ - HashCandidate's immutability
+
+Together these give us confidence that every code path runs without
+explosion, and that the most-common inputs produce the expected
+top-ranked candidate
+"""
+
+# Importing from `hash_identifier` (NOT `hash_identifier.py`) tells
+# Python to load the module that lives in this same directory. We
+# pull in three things:
+# - `identify` — the function under test
+# - `HashCandidate`— the return-type dataclass (used in the
+# immutability test)
+# - `PREFIX_RULES` — the prefix lookup table (used by the
+# parametrized "every row is covered" test
+# at the bottom of this file)
+# `pytest` is also imported so we can use the @pytest.mark.parametrize
+# decorator to expand one test function into many test cases
+# Third-party: the test runner itself. We also need it imported here
+# so we can use its `@pytest.mark.parametrize` decorator below.
+import pytest
+
+# Local: our own module. We pull in the public pieces under test —
+# the prefix-rule table, the result dataclass, and the entry function.
+from hash_identifier import PREFIX_RULES, HashCandidate, identify
+
+# =============================================================================
+# Prefix matches (high confidence)
+# =============================================================================
+# These tests verify Step 1 of identify(): when the input starts with a
+# known prefix, we report HIGH confidence. The exact hash payload after
+# the prefix does not matter to identify() — it only inspects the
+# leading characters
+
+
+def test_bcrypt_prefix_is_recognized() -> None:
+ """
+ A real bcrypt hash starts with `$2b$` and should be reported as bcrypt
+ """
+ # Sample: an actual bcrypt hash for the password "password" with cost 12.
+ # The interesting part for our test is just `$2b$` — we never even
+ # decode the rest
+ sample = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
+
+ # Call the function under test. `candidates` is a list of HashCandidate
+ candidates = identify(sample)
+
+ # First assert the list is non-empty. `assert ` fails when
+ # is falsy — empty lists are falsy, so this catches the
+ # "no candidates returned" bug
+ assert candidates
+ # Then check the FIRST candidate (highest-priority guess) is bcrypt.
+ # `candidates[0]` is the first item; `.algorithm` is the field we
+ # check. `==` compares for equality
+ assert candidates[0].algorithm == "bcrypt"
+ # And confidence must be "high" — prefix matches are definitive
+ assert candidates[0].confidence == "high"
+
+
+def test_argon2id_prefix_is_recognized() -> None:
+ """
+ Argon2id PHC strings begin with `$argon2id$`
+ """
+ # PHC format for Argon2id: $argon2id$v=$m=...,t=...,p=...$$
+ sample = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaA"
+ candidates = identify(sample)
+ # `any(...)` returns True if at least one element of the iterable
+ # makes the inner expression true. We check that AT LEAST ONE
+ # candidate is Argon2id — using any() instead of [0] keeps the test
+ # robust if we ever add a second guess to the same prefix
+ assert any(c.algorithm == "Argon2id" for c in candidates)
+
+
+def test_sha512_crypt_prefix_is_recognized() -> None:
+ """
+ `$6$` is the marker for SHA-512 crypt — what /etc/shadow uses on Linux
+ """
+ sample = "$6$rounds=10000$salt$hashedpasswordhere"
+ candidates = identify(sample)
+ # `[0]` because we want the TOP candidate. If anything else was
+ # ranked first, this assertion would fail loudly
+ assert candidates[0].algorithm == "SHA-512 crypt"
+
+
+def test_django_pbkdf2_prefix_is_recognized() -> None:
+ """
+ Django stores passwords as `pbkdf2_sha256$$$`
+ """
+ sample = "pbkdf2_sha256$260000$salt$hash"
+ candidates = identify(sample)
+ assert candidates[0].algorithm == "Django PBKDF2-SHA256"
+
+
+def test_apr1_prefix_is_recognized() -> None:
+ """
+ Apache `.htpasswd` MD5 hashes start with $apr1$
+
+ The htpasswd tool generates these by default with the `-m` flag.
+ Same MD5 family as the Unix $1$ format but with Apache's own salt
+ handling — and FAR more common in the wild because every Apache
+ basic-auth tutorial ends with one of these in a .htpasswd file
+ """
+ # Real-looking apr1 hash. The trailing payload after the second
+ # `$` is the base64-flavored encoding of the MD5 digest + salt.
+ # identify() never decodes it — only the leading `$apr1$` matters
+ sample = "$apr1$rsalt$mp7TYYDvbgvNCJN3JTd6q1"
+ candidates = identify(sample)
+ assert candidates[0].algorithm == "Apache MD5-crypt"
+ assert candidates[0].confidence == "high"
+
+
+# =============================================================================
+# Special formats
+# =============================================================================
+# Step 2 of identify(): formats that are NOT PHC strings but still have
+# unmistakable shapes. Today we recognize NetNTLMv1, NetNTLMv2, and
+# MySQL5 — three structurally-distinct records that all benefit from
+# the same "check the literal shape before falling back to length" path
+
+
+def test_mysql5_format_is_recognized() -> None:
+ """
+ MySQL5 = literal `*` followed by 40 uppercase hex chars
+
+ MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
+ a leading asterisk. So the whole hash is exactly 41 characters
+ """
+ # The * matters — without it, this would just be 40 hex chars
+ # and would fall through to the SHA-1 length rule
+ sample = "*23AE809DDACAF96AF0FD78ED04B6A265E05AA257"
+ candidates = identify(sample)
+
+ # MySQL5 is a definitive shape, so we expect HIGH confidence
+ assert candidates[0].algorithm == "MySQL5"
+ assert candidates[0].confidence == "high"
+
+
+def test_mysql5_rejects_lowercase_body() -> None:
+ """
+ Lowercase hex after the leading `*` is not real MySQL5 output
+
+ MySQL emits uppercase via `%02X`, so a `*` followed by lowercase
+ hex is almost certainly hand-edited junk rather than a real
+ MySQL5 hash. We would rather return nothing than return a
+ confident WRONG answer here
+ """
+ # Lowercase version of the previous test's body. The leading `*`
+ # is the only thing it shares with real MySQL5 output, and the
+ # case mismatch alone should disqualify it
+ lowercase_body = "23ae809ddacaf96af0fd78ed04b6a265e05aa257"
+ candidates = identify("*" + lowercase_body)
+
+ # Either the list is empty (preferred, what we expect today) OR
+ # whatever did match must NOT be labeled MySQL5. The `if`
+ # guard makes the test robust to future rules that might catch
+ # this shape under a different (correct) label — what matters is
+ # that we do not LIE and call it MySQL5
+ if candidates:
+ assert candidates[0].algorithm != "MySQL5"
+
+
+def test_netntlmv2_format_is_recognized() -> None:
+ """
+ NetNTLMv2 records from Responder look like
+ `user::domain:challenge:hmac:blob`
+
+ The hmac field is exactly 32 hex chars. The leading `::` (empty
+ LM-hash slot) is the giveaway that this is an AD challenge-
+ response record, not a stored password hash
+ """
+ # Build a realistic NetNTLMv2 record:
+ # alice :: CORP : <16-char challenge> : <32 hex hmac> : <64 hex blob>
+ # The actual hash values do not matter — only the structural
+ # shape (colon count + hex field lengths) is what identify() checks
+ sample = "alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 64
+ candidates = identify(sample)
+
+ # NetNTLMv2 is a definitive shape — HIGH confidence
+ assert candidates[0].algorithm == "NetNTLMv2"
+ assert candidates[0].confidence == "high"
+
+
+def test_netntlmv1_format_is_recognized() -> None:
+ """
+ NetNTLMv1 records have 48-hex-char lmhash AND nthash before the challenge
+
+ Layout: `user::domain:lm(48 hex):nt(48 hex):challenge`. We
+ recognize this by looking at field index 3 — in NetNTLMv1 it is
+ exactly 48 hex chars, while in NetNTLMv2 it is the (shorter)
+ challenge field
+ """
+ # Build a realistic NetNTLMv1 record. The 48-char lmhash and
+ # nthash are the load-bearing signal; identify() never decodes
+ # them — only their length and charset matter
+ sample = "alice::CORP:" + "a" * 48 + ":" + "b" * 48 + ":1122334455667788"
+ candidates = identify(sample)
+ assert candidates[0].algorithm == "NetNTLMv1"
+ assert candidates[0].confidence == "high"
+
+
+def test_descrypt_format_is_recognized() -> None:
+ """
+ Traditional DES crypt has NO prefix — only length and charset identify it
+
+ Legacy /etc/passwd from pre-shadow Unix systems used this format:
+ 13 characters drawn from the alphabet `./0-9A-Za-z`. Rare today
+ but still turns up in retro CTFs, and hashcat still ships a mode
+ (1500) for cracking it
+ """
+ # A realistic 13-char DES crypt output. Content does not matter
+ # for the test — only the length and the all-valid-charset
+ # property are what _is_descrypt checks
+ sample = "kRq14pmccuMOA"
+ candidates = identify(sample)
+
+ assert candidates[0].algorithm == "DES crypt"
+ # MEDIUM (not HIGH) confidence because a 13-char `./0-9A-Za-z`
+ # string CAN technically be other things (session IDs, random
+ # tokens). An honest medium beats a confident false-positive
+ assert candidates[0].confidence == "medium"
+
+
+# =============================================================================
+# Hex length matches (medium / low confidence)
+# =============================================================================
+# Step 3 of identify(): when the input is pure hex, length narrows down
+# the algorithm. The FIRST listed algorithm for each length gets
+# medium confidence (the modern default); the rest are low
+
+
+def test_mysql323_length_returns_mysql323_first() -> None:
+ """
+ 16 hex chars points at MySQL323 (legacy MySQL OLD_PASSWORD output)
+
+ MySQL versions before 4.1 stored passwords as a 16-char hex string
+ from a custom (now-broken) hash function. The OLD_PASSWORD() SQL
+ function still produces this format on modern MySQL for legacy
+ compatibility, so these still show up in CTFs and old MySQL
+ breach dumps
+ """
+ # A 16-hex-char string. The content does not matter — only the
+ # length and the all-hex charset are what identify() checks
+ sample = "5d2e19393cc5ef67"
+ candidates = identify(sample)
+
+ # MySQL323 ranks ABOVE CRC-64 because in a security context (a
+ # CTF challenge, a breach dump, a password column) MySQL323 is
+ # by far the more likely source. A 64-bit CRC almost never
+ # arrives at a hash-identifier tool
+ assert candidates[0].algorithm == "MySQL323"
+ # MEDIUM confidence: length alone is suggestive but cannot rule
+ # out CRC-64 with certainty without seeing the surrounding context
+ assert candidates[0].confidence == "medium"
+
+
+def test_md5_length_returns_md5_first() -> None:
+ """
+ 32 hex chars matches MD5, NTLM, MD4, RIPEMD-128
+
+ MD5 is BY FAR the most common 32-hex hash in the wild, so it must
+ be the top candidate. NTLM (a Windows hash) is also 32 hex but
+ far less common in pasted hash dumps, so it should appear later
+ """
+ # The literal MD5 of the string "password". A useful sample because
+ # any reader can verify it: `echo -n password | md5sum`
+ sample = "5f4dcc3b5aa765d61d8327deb882cf99"
+ candidates = identify(sample)
+
+ # Top candidate is MD5
+ assert candidates[0].algorithm == "MD5"
+ # And we report MEDIUM confidence — length alone is suggestive
+ # but not definitive (no prefix to confirm)
+ assert candidates[0].confidence == "medium"
+
+ # NTLM should appear in the candidate list as a less-likely option.
+ # We pull just the algorithm names into a list using a comprehension,
+ # then check membership with `in`
+ algorithms = [c.algorithm for c in candidates]
+ assert "NTLM" in algorithms
+
+
+def test_sha256_length_returns_sha256_first() -> None:
+ """
+ 64 hex chars points at SHA-256 first
+
+ SHA3-256 and BLAKE2s also produce 64 hex chars but are rarer in
+ real systems, so SHA-256 takes the top spot
+ """
+ # `"a" * 64` is Python shorthand for "the character 'a' repeated 64
+ # times" — a quick way to make a 64-char string for length tests.
+ # The actual content does not matter; only the length does
+ sample = "a" * 64
+ candidates = identify(sample)
+ assert candidates[0].algorithm == "SHA-256"
+
+
+def test_sha1_length_returns_sha1_first() -> None:
+ """
+ 40 hex chars = SHA-1 (RIPEMD-160 as a backup guess)
+ """
+ sample = "a" * 40
+ candidates = identify(sample)
+ assert candidates[0].algorithm == "SHA-1"
+
+
+# =============================================================================
+# No-match / edge cases
+# =============================================================================
+# Always test the boring edge cases. Empty inputs, whitespace-only,
+# garbage. These caused real bugs in real codebases more than once
+
+
+def test_empty_input_returns_no_candidates() -> None:
+ """
+ Empty string returns an empty list — never blows up
+ """
+ # Two checks because both of these have caused crashes elsewhere:
+ # truly empty AND whitespace-only (which strips down to empty)
+ assert identify("") == []
+ assert identify(" ") == []
+
+
+def test_garbage_returns_no_candidates() -> None:
+ """
+ A string with neither a known prefix nor a hex shape returns []
+ """
+ # Sentence with spaces and punctuation: cannot be hex, has no
+ # PHC prefix. identify() should return an empty list, not guess
+ assert identify("hello, this is not a hash") == []
+
+
+def test_input_is_trimmed_of_whitespace() -> None:
+ """
+ Trailing newlines and leading spaces should not block recognition
+
+ This matters because copy-paste from a terminal often picks up
+ invisible whitespace. We strip it inside identify()
+ """
+ # Note the leading spaces and trailing \n — escaped newline character
+ sample = " 5f4dcc3b5aa765d61d8327deb882cf99\n"
+ candidates = identify(sample)
+ # If trim works, we still recognize MD5 despite the surrounding noise
+ assert candidates[0].algorithm == "MD5"
+
+
+# =============================================================================
+# Soft-match fallbacks (shape hints, LOW confidence)
+# =============================================================================
+# Steps 4 and 5 of identify(): when nothing in the PREFIX_RULES table
+# or the special-formats step or the hex-length table fires, we still
+# try two soft matches — generic PHC string shape, then "this looks
+# like a JWT / base64 blob, not a hash." Both return LOW confidence
+# because shape alone is a weaker signal than a known prefix
+
+
+def test_unknown_phc_string_falls_back_to_generic() -> None:
+ """
+ A PHC string from an algorithm we don't have a specific rule for
+ is still reported as a PHC string with the extracted algorithm name
+
+ This is a SOFT match (LOW confidence) but still beats the
+ alternative of returning nothing on an obviously-PHC-shaped input
+ """
+ # Passlib's pbkdf2-sha512 PHC encoding. We have no specific rule
+ # for it in PREFIX_RULES — but the `$pbkdf2-sha512$...` shape is
+ # unambiguous, so the generic fallback should pick it up
+ sample = "$pbkdf2-sha512$25000$cnNhbHQ$aGFzaA"
+ candidates = identify(sample)
+
+ assert candidates
+ # The algorithm column should say "PHC string (pbkdf2-sha512)" —
+ # both the marker word "PHC" and the extracted algorithm name
+ # should appear so the user knows WHAT kind of thing they pasted
+ assert "PHC" in candidates[0].algorithm
+ assert "pbkdf2-sha512" in candidates[0].algorithm
+ # LOW confidence because we matched on shape only, not on a
+ # specific rule that would let us confirm the algorithm
+ assert candidates[0].confidence == "low"
+
+
+def test_jwt_input_is_called_out_as_not_a_hash() -> None:
+ """
+ JWTs start with `eyJ` and should be called out as not-a-hash
+
+ Beginners often paste JWTs into a hash identifier because they
+ look hash-like. Saying "this is a JWT, not a hash" is more
+ useful than silence — it teaches the user what they have
+ """
+ # A short but real-shaped JWT (header.payload.signature). The
+ # signature here is intentionally not real — identify() never
+ # validates it, only the leading `eyJ` matters
+ sample = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig"
+ candidates = identify(sample)
+
+ assert candidates
+ # The algorithm column should contain "JWT" so the user knows
+ # exactly what kind of thing they pasted
+ assert "JWT" in candidates[0].algorithm
+ # LOW confidence is honest: we are not 100% sure this is a JWT,
+ # we are 100% sure it starts with `eyJ` and JWTs start with `eyJ`
+ assert candidates[0].confidence == "low"
+
+
+def test_base64_blob_is_called_out_as_not_a_hash() -> None:
+ """
+ A string containing base64-only chars (`+`, `/`, `=`) is not hex
+
+ Hex hashes never contain those characters, so their presence is a
+ strong signal that the input is base64-encoded data of some kind
+ rather than a hash — even when we cannot say what it decodes to
+ """
+ # A base64-looking blob with the `=` padding character
+ sample = "VGhpcyBpcyBub3QgYSBoYXNoLCBpdHMgYmFzZTY0Lg=="
+ candidates = identify(sample)
+
+ assert candidates
+ # "Base64 blob" should appear in the algorithm name so the user
+ # knows we did identify SOMETHING, just not a hash
+ assert "Base64" in candidates[0].algorithm
+
+
+# =============================================================================
+# HashCandidate is immutable
+# =============================================================================
+# We declared HashCandidate with @dataclass(frozen=True). Frozen means
+# you cannot reassign fields after construction — like a sealed envelope.
+# This test makes sure the seal actually holds, so future refactors do
+# not accidentally drop the `frozen` flag
+
+
+def test_hash_candidate_is_frozen() -> None:
+ """
+ Attempting to mutate a HashCandidate must raise an error
+ """
+ # First, construct a normal instance
+ candidate = HashCandidate(
+ algorithm = "MD5",
+ confidence = "medium",
+ reason = "test",
+ )
+
+ # try/except is Python's "guard against an error" syntax. Inside
+ # `try`, we attempt the operation we EXPECT to fail. The `except`
+ # block runs ONLY if the listed exception types fire — and our
+ # frozen dataclass raises one of these on assignment
+ try:
+ # `# type: ignore[misc]` tells mypy "I know this is a type error;
+ # I am doing it on purpose to verify it actually fails at runtime"
+ candidate.algorithm = "SHA-1" # type: ignore[misc]
+ except (AttributeError, TypeError):
+ # Got the expected exception — the test passes by returning
+ return
+
+ # If we reached this line, no exception was raised — frozen is
+ # broken. Fail the test loudly with a clear message
+ raise AssertionError(
+ "HashCandidate should be frozen; assignment should have raised"
+ )
+
+
+# =============================================================================
+# Comprehensive PREFIX_RULES table coverage
+# =============================================================================
+# The individual prefix tests above (bcrypt, Argon2id, etc.) double as
+# readable EXAMPLES of how identify() handles known prefixes. This last
+# test plays a different role: it is the safety-net that guarantees
+# EVERY row of PREFIX_RULES is exercised, so a typo in any single row's
+# algorithm name or note string fails its own test case rather than
+# slipping through silently.
+#
+# `@pytest.mark.parametrize(name, values)` is pytest's mechanism for
+# expanding ONE test function into MANY test cases. Here we hand it
+# the entire PREFIX_RULES list. Pytest unpacks each `(prefix, algorithm,
+# note)` tuple into the three parameters of the test function and runs
+# the body once per row. The leading `_` in `_note` tells linters
+# (and the next reader) that we intentionally do not assert on the
+# note string — we accept whatever the table author wrote
+
+
+@pytest.mark.parametrize("prefix,algorithm,_note", PREFIX_RULES)
+def test_every_prefix_rule_is_recognized_with_high_confidence(
+ prefix: str,
+ algorithm: str,
+ _note: str,
+) -> None:
+ """
+ Every entry in PREFIX_RULES produces a HIGH-confidence candidate
+ with the matching algorithm when its prefix sits at the start
+ of the input
+
+ The body of the hash after the prefix does not matter to
+ identify() — it only inspects the leading characters — so we
+ just glue any junk onto the end to form a syntactically-plausible
+ input
+ """
+ sample = prefix + "fakebodydoesntmatter"
+ candidates = identify(sample)
+
+ # If identify() returned nothing, the prefix-loop branch is
+ # broken — fail with a message that names the offending prefix
+ # so the failure is debuggable at a glance
+ assert candidates, f"no candidates returned for prefix `{prefix}`"
+ assert candidates[0].algorithm == algorithm
+ assert candidates[0].confidence == "high"
diff --git a/PROJECTS/foundations/hash-identifier/uv.lock b/PROJECTS/foundations/hash-identifier/uv.lock
new file mode 100644
index 00000000..bf5a4019
--- /dev/null
+++ b/PROJECTS/foundations/hash-identifier/uv.lock
@@ -0,0 +1,350 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15'",
+]
+
+[[package]]
+name = "astroid"
+version = "4.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "dill"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
+]
+
+[[package]]
+name = "hash-identifier"
+version = "1.0.0"
+source = { editable = "." }
+dependencies = [
+ { name = "rich" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pylint" },
+ { name = "pytest" },
+ { name = "ruff" },
+ { name = "yapf" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0,<2.0.0" },
+ { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.0,<5.0.0" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
+ { name = "rich", specifier = ">=14.2.0,<15.0.0" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.0,<0.15.0" },
+ { name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
+]
+provides-extras = ["dev"]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "isort"
+version = "8.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
+]
+
+[[package]]
+name = "librt"
+version = "0.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" },
+ { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" },
+ { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" },
+ { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" },
+ { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" },
+ { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" },
+ { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" },
+ { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" },
+ { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.20.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
+ { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
+ { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
+ { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
+ { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
+ { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
+ { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
+ { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
+ { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pylint"
+version = "4.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "astroid" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "dill" },
+ { name = "isort" },
+ { name = "mccabe" },
+ { name = "platformdirs" },
+ { name = "tomlkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "rich"
+version = "14.3.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.14.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
+ { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
+ { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
+ { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "yapf"
+version = "0.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
+]
diff --git a/PROJECTS/foundations/http-headers-scanner/.gitignore b/PROJECTS/foundations/http-headers-scanner/.gitignore
new file mode 100644
index 00000000..834acecb
--- /dev/null
+++ b/PROJECTS/foundations/http-headers-scanner/.gitignore
@@ -0,0 +1,70 @@
+# ©AngelaMos | 2026
+# .gitignore
+#
+# This file lists patterns of files and folders that git should NEVER
+# track. Anything matching a pattern below is invisible to `git status`
+# and won't be committed. We do this to avoid two problems:
+#
+# 1. Privacy — we never want to accidentally commit a sensitive file
+# a user might pass to the tool.
+#
+# 2. Noise — build artifacts and caches change constantly. Tracking
+# them would clutter every commit and create merge conflicts.
+#
+# Each line is a "glob" pattern (* matches any characters).
+
+# -----------------------------------------------------------------------------
+# Python build/install artifacts
+# -----------------------------------------------------------------------------
+# __pycache__: bytecode Python generates from .py files when it runs them.
+# We never edit these, and they regenerate on every run, so ignore them.
+__pycache__/
+# *.py[cod] = .pyc, .pyo, .pyd — older bytecode formats Python may emit.
+*.py[cod]
+*$py.class
+# Build outputs from `pip wheel`, hatchling, etc.
+*.egg-info/
+*.egg
+build/
+dist/
+.eggs/
+*.so
+
+# -----------------------------------------------------------------------------
+# Virtual environments — created by `uv venv`, never committed
+# -----------------------------------------------------------------------------
+# A virtual environment is a per-project Python install. It lives inside
+# the project directory. Committing it would balloon the repo and pin
+# the user to your operating system. Always recreate locally.
+.venv/
+venv/
+env/
+
+# -----------------------------------------------------------------------------
+# Tooling caches — these speed up repeated runs but never need committing
+# -----------------------------------------------------------------------------
+.mypy_cache/
+.ruff_cache/
+.pytest_cache/
+# Coverage report files from pytest-cov.
+.coverage
+htmlcov/
+
+# -----------------------------------------------------------------------------
+# IDE / editor scratch files
+# -----------------------------------------------------------------------------
+# Each editor drops its own metadata. None of it belongs in the repo.
+.vscode/
+.idea/
+*.swp
+*.swo
+# macOS Finder dumps these in every directory it has opened.
+.DS_Store
+
+# -----------------------------------------------------------------------------
+# Local environment files (may contain secrets)
+# -----------------------------------------------------------------------------
+# We never use .env in this project, but list it just in case. Anything
+# containing secrets should never reach the public repo.
+.env
+.env.local
diff --git a/PROJECTS/foundations/http-headers-scanner/.style.yapf b/PROJECTS/foundations/http-headers-scanner/.style.yapf
new file mode 100644
index 00000000..e3340a5e
--- /dev/null
+++ b/PROJECTS/foundations/http-headers-scanner/.style.yapf
@@ -0,0 +1,104 @@
+# ©AngelaMos | 2026
+# .style.yapf
+#
+# Configuration for yapf, our Python code formatter. yapf rewrites
+# Python files to match these rules so every file looks identical
+# no matter who wrote it.
+#
+# The two settings most worth knowing:
+# based_on_style = pep8 → start from PEP 8 (the official style)
+# column_limit = 75 → wrap lines at 75 characters
+#
+# Run with: `just format`
+[style]
+# Start from PEP 8 (Python's official style guide). Every other
+# setting below tweaks PEP 8 in some specific direction.
+based_on_style = pep8
+# Wrap any line longer than this many columns.
+column_limit = 75
+# Use 4 spaces per indent level (PEP 8 standard).
+indent_width = 4
+# Continuation lines (when a statement wraps) also indent by 4.
+continuation_indent_width = 4
+# Don't indent the closing bracket of a multi-line call/list/dict
+indent_closing_brackets = false
+# DO de-indent the closing bracket — line it up with the opener.
+dedent_closing_brackets = true
+# Don't add 4 spaces of indent to blank lines inside functions.
+indent_blank_lines = false
+# Two spaces between code and the # of a trailing comment.
+spaces_before_comment = 2
+# No spaces around ** (power) operator: 2**3 not 2 ** 3.
+spaces_around_power_operator = false
+# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
+spaces_around_default_or_named_assign = true
+# No extra space before a comma followed by a closing bracket.
+space_between_ending_comma_and_closing_bracket = false
+# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
+space_inside_brackets = false
+# Spaces around colons in slices: x[1 : 5] not x[1:5].
+spaces_around_subscript_colon = true
+# No blank line before nested def/class.
+blank_line_before_nested_class_or_def = false
+# No blank line between a class and its docstring.
+blank_line_before_class_docstring = false
+# Two blank lines around top-level functions and classes (PEP 8).
+blank_lines_around_top_level_definition = 2
+# Two blank lines between top-level imports and module-level variables.
+blank_lines_between_top_level_imports_and_variables = 2
+# No blank line before the module docstring.
+blank_line_before_module_docstring = false
+# When wrapping `a and b`, put `and` at the start of the new line.
+split_before_logical_operator = true
+# When wrapping a function call, put the first argument on a new line.
+split_before_first_argument = true
+# Put each named arg on its own line when wrapping.
+split_before_named_assigns = true
+# Multi-line comprehensions wrap onto separate lines.
+split_complex_comprehension = true
+# Don't split right after the opening parenthesis.
+split_before_expression_after_opening_paren = false
+# Put the closing bracket on its own line when wrapping.
+split_before_closing_bracket = true
+# When splitting at commas, give every value its own line.
+split_all_comma_separated_values = true
+# But don't recursively force ALL top-level commas onto new lines.
+split_all_top_level_comma_separated_values = false
+# Don't merge separate brackets into one.
+coalesce_brackets = false
+# Each dict entry gets its own line in multi-line dicts.
+each_dict_entry_on_separate_line = true
+# Disallow lambdas that span multiple lines.
+allow_multiline_lambdas = false
+# Disallow dict keys that span multiple lines.
+allow_multiline_dictionary_keys = false
+# Penalty score for splitting at import names — 0 means free to split.
+split_penalty_import_names = 0
+# Don't try to fit an already-broken statement back onto fewer lines.
+join_multiple_lines = false
+# Closing bracket lines up with the visual indent of the opener.
+align_closing_bracket_with_visual_indent = true
+# Don't add parentheses to clarify operator precedence automatically.
+arithmetic_precedence_indication = false
+# Penalty for splitting after each line — yapf weighs this against rules.
+split_penalty_for_added_line_split = 275
+# Use spaces for indentation, never tabs (PEP 8).
+use_tabs = false
+# Don't split on a `.` when wrapping (so `obj.method()` stays together).
+split_before_dot = false
+# When the last argument in a call has a trailing comma, force a split.
+split_arguments_when_comma_terminated = true
+# Function names yapf treats as i18n calls — leave their args alone.
+i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
+# Comment markers yapf treats as i18n metadata.
+i18n_comment = ['# Translators:', '# i18n:']
+# Penalty for splitting comprehension iterables.
+split_penalty_comprehension = 80
+# Penalty for splitting right after an opening bracket.
+split_penalty_after_opening_bracket = 280
+# Penalty for splitting before an `if` in a ternary expression.
+split_penalty_before_if_expr = 0
+# Penalty for splitting before a bitwise operator.
+split_penalty_bitwise_operator = 290
+# Penalty for splitting before a logical operator (already lowered).
+split_penalty_logical_operator = 0
diff --git a/PROJECTS/foundations/http-headers-scanner/LICENSE b/PROJECTS/foundations/http-headers-scanner/LICENSE
new file mode 100644
index 00000000..0ad25db4
--- /dev/null
+++ b/PROJECTS/foundations/http-headers-scanner/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/PROJECTS/foundations/http-headers-scanner/README.md b/PROJECTS/foundations/http-headers-scanner/README.md
new file mode 100644
index 00000000..e784d132
--- /dev/null
+++ b/PROJECTS/foundations/http-headers-scanner/README.md
@@ -0,0 +1,170 @@
+```ruby
+██╗ ██╗███████╗ █████╗ ██████╗ ███████╗██████╗ ███████╗
+██║ ██║██╔════╝██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝
+███████║█████╗ ███████║██║ ██║█████╗ ██████╔╝███████╗
+██╔══██║██╔══╝ ██╔══██║██║ ██║██╔══╝ ██╔══██╗╚════██║
+██║ ██║███████╗██║ ██║██████╔╝███████╗██║ ██║███████║
+╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝
+```
+
+[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/http-headers-scanner)
+[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
+[](https://www.python.org)
+[](https://www.gnu.org/licenses/agpl-3.0)
+[](https://pytest.org)
+[](https://github.com/astral-sh/ruff)
+[](https://www.python-httpx.org/)
+
+> Fetch a URL once and grade its HTTP security headers A through F using the same weighted-rubric model as Mozilla Observatory.
+
+*This is a quick overview, security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
+
+> [!NOTE]
+> **Foundations tier**, this project is built for someone who has never written Python before. The source code is heavily commented as a teaching aid, the `learn/` folder explains every concept from zero, and the whole tool is one readable file. If you already know Python, the natural next step is [`PROJECTS/foundations/password-manager`](../password-manager), the hardest foundations project, which adds Argon2id, AES-GCM, and on-disk state.
+
+## What It Does
+
+- Performs one polite HTTPS request to the URL you provide and inspects the response headers
+- Grades six security-critical headers against a weighted rubric (high = 30 pts, medium = 15 pts, low = 5 pts)
+- Reports each finding as `ok`, `weak`, or `missing` with a one-line explanation of why
+- Computes a 0 to 100 score and maps it to an A through F letter grade (90+ = A, 80+ = B, etc.)
+- Catches subtly broken values like `Strict-Transport-Security: max-age=0` (header present, actively disabled) and flags them as `weak`, not `ok`
+- Follows redirects and grades the **final** URL, the one your browser would actually land on
+- Prints a colored Rich table plus a grade panel plus a recommendation list for every non-`ok` finding
+- Returns meaningful exit codes: `0` for A/B, `1` for C/D, `2` for F or network error, useful in CI pipelines
+
+## The Headers It Grades
+
+| Header | Severity | What it stops |
+|---|---|---|
+| `Strict-Transport-Security` | high | SSL stripping on coffee-shop wifi |
+| `Content-Security-Policy` | high | XSS via injected `
+Site displays it verbatim.
+Now every user who loads the comment runs the attacker's JS in their session.
+```
+
+That JS runs with the full trust of the website, so it can read cookies, read the page's session token, send requests to the API as the user, and so on. This is bug class number 7 in the OWASP Top 10 for years.
+
+**How CSP stops it.** The website tells the browser "scripts may only come from `https://my-site.com` or from `https://cdn.my-site.com`." When the browser sees `` embedded inline in the HTML, it goes "that script does not come from one of the allowed origins, I refuse to run it." The injected XSS just becomes inert text.
+
+**What the value looks like**
+
+```
+Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
+```
+
+- `default-src 'self'`: for anything not specifically listed below, only allow content from this exact site.
+- `script-src 'self' https://cdn.example.com`: JavaScript can come from this site or from cdn.example.com.
+
+A real CSP is much longer because real sites pull in fonts, analytics, embeds, etc. A good CSP **never** contains `'unsafe-inline'` for scripts (which would allow the inline `` further in. Server stores it. Server later serves it back to other users with `Content-Type: image/gif`. IE looks at the bytes, sees the `