Skip to content

Latest commit

Β 

History

History
616 lines (430 loc) Β· 34.2 KB

File metadata and controls

616 lines (430 loc) Β· 34.2 KB

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

"""
Β©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

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 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 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 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

3. The Literal type

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.)

4. The HashCandidate dataclass

@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:

    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

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.

6. The HEX_LENGTH_RULES table

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

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

_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 False

vs:

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

_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 {}.

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.

10a. The pylint silencing comment

# 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

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

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

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.

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.

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

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

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

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

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

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 <hash>. 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

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

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:

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 -- <hash> 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 for harder extension ideas.