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, ornano hash_identifier.py, or whatever editor you use.
"""
Β©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
# commentis 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."
import argparse
import sys
from dataclasses import dataclass
from typing import Literal
from rich.console import Console
from rich.table import TableAn 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 using uv add <package>.
There are two import shapes:
import argparseβ imports the whole module under its own name. You then refer to things inside it asargparse.ArgumentParser.from dataclasses import dataclassβ imports just one thing out of the module, so you can use it bare:dataclassinstead ofdataclasses.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 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 library |
The thing that draws colored text to the terminal |
Table |
Also from rich |
Builds the colored ASCII table we print to the user |
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:
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.)
@dataclass(frozen=True, slots=True)
class HashCandidate:
"""One possible identification of a hash string ..."""
algorithm: str
confidence: Confidence
reason: strThis is the data record the brain returns. Let's unpack what's happening:
-
class HashCandidate:defines a new type calledHashCandidate. A class is a blueprint for objects. -
algorithm: strdeclares an attribute namedalgorithmof typestr(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@dataclassdecorator 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: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): ...
@dataclasswrites all that for you. -
frozen=Truemakes instances immutable. After the object is built,candidate.algorithm = "different"raisesFrozenInstanceError. This is what makesHashCandidatea value object β like an integer or a tuple, it doesn't change after creation. -
slots=Trueis 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=Truetells Python to allocate a fixed array for the three fields, skipping the dict. Faster, smaller, andobj.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."
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 tuples. 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.
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:
- We never need to add/remove characters from the hex alphabet β it's known and fixed.
- Lookup (
c in HEX_CHARSET) is O(1) β constant time. Faster thanc in "0123456789abcdef..."which would scan the string character by character. - Marking it
frozensetsignals 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.
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_hexthat takes one argumenttextof typestrand returns abool(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 charactercintext, yieldTrueifcis in our hex charset, elseFalse.all(...)takes that sequence and returnsTrueonly if every yielded value is True. Equivalent to "every character of text is a hex digit."bool(text)evaluates toFalseiftextis empty,Trueotherwise. We need this guard becauseall([])of an empty sequence returns True (mathematically reasonable, practically annoying β an empty string is not a valid hex string).... and ...short-circuits: ifbool(text)is False, we don't bother checkingall(...).
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.
_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:
if len(text) != 41 or not text.startswith("*"):
return Falsevs:
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
return FalseBoth 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:
- Check the total length is exactly 41 and the first character is
*. If not, bail with False. - Slice off the leading
*usingtext[1:]. Python slicing:text[start:stop]gives you the substring from indexstart(inclusive) tostop(exclusive). Omittingstopmeans "to the end." Sotext[1:]means "everything from index 1 onwards" β i.e. drop the first character. - 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.
_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:
"./0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"is exactly the same as:
"./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 {}.
This is the main attraction. It's about 100 lines long and follows the six-step pipeline from 02-ARCHITECTURE.md.
# pylint: disable=too-many-return-statements,too-many-branchesThis 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.
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.
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.
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 \. 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).
NetNTLMv2, NetNTLMv1, MySQL5, DES crypt. Each gets its own block.
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 textreturns True if the substring"::"appears anywhere intext. Theinoperator 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, soparts[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.
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.
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 candidatesThree new Python features here:
dict.get(key, default)returns the value if the key exists, ordefaultif not. SoHEX_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 aKeyErrorexception that would happen withHEX_LENGTH_RULES[len(text)]on an unknown length.enumerate(iterable)is a built-in that wraps an iterable and yields(index, value)pairs. Sofor 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 ofcond ? a : bin 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.
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 tosplitis the max number of splits. So"argon2id$v=19$...".split("$", 1)returns["argon2id", "v=19$..."]. Without the1, it would split on every$and we'd lose information. -
[0]takes the first element of the resulting list. -
c.isalnum()is astrmethod returning True ifcis 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.
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.
return []Empty list. The CLI prints "could not identify" when it sees this. Always better to admit defeat than to lie with confidence.
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 parserargparse.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 typeshashid <hash>. Required. After parsing,args.hashholds the string."--top", "-n"β an optional flag with both a long form (--top 3) and short form (-n 3).type=intconverts the string the user typed into an integer.default=5is 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.
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.
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:
- Build the parser.
- Parse
sys.argv(parser.parse_args()defaults to usingsys.argv). - Build a Console.
- Run the brain on the user's input.
- If no candidates, print an error and return exit code 1.
- Trim to the top-N results (
candidates[:args.top]is slicing β same as before). - Render the table.
- If the top candidate is HIGH confidence, print a hint about what to do next.
- 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:
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_identifierfrom 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.
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.
Open test_hash_identifier.py if you haven't yet. It's structured as ~25 small test_* functions. Each one:
- Builds a known input.
- Calls
identify(input)or_is_mysql5(input)etc. - 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 overPREFIX_RULESand 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) β usespytest.raisesto confirm that mutating a frozen dataclass raisesFrozenInstanceError.
Run them: just test. The whole suite is ~30 tests and finishes in under a second.
You've read the file. To make the knowledge stick:
- Try
just run -- <hash>with weird inputs β empty string (after quoting), pure digits, super long strings, hashes with trailing whitespace, JWTs, base64. - Open
hash_identifier.pyand add aprint()statement insideidentify()to see which step matches for each input. Then remove it before committing. - Add a new prefix to
PREFIX_RULESβ for instance, scrypt sometimes shows up with$scrypt$prefix. Add it, add a test, runjust test. - Read 04-CHALLENGES.md for harder extension ideas.