hashident 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hashident/__init__.py +3 -0
- hashident/cli.py +228 -0
- hashident/commands.py +39 -0
- hashident/context.py +56 -0
- hashident/engine.py +157 -0
- hashident/inputs.py +154 -0
- hashident/json_output.py +101 -0
- hashident/models.py +112 -0
- hashident/output.py +358 -0
- hashident/signatures.py +207 -0
- hashident/style.py +73 -0
- hashident/terminal.py +85 -0
- hashident/tips.py +45 -0
- hashident-0.1.0.dist-info/METADATA +422 -0
- hashident-0.1.0.dist-info/RECORD +19 -0
- hashident-0.1.0.dist-info/WHEEL +5 -0
- hashident-0.1.0.dist-info/entry_points.txt +2 -0
- hashident-0.1.0.dist-info/licenses/LICENSE +21 -0
- hashident-0.1.0.dist-info/top_level.txt +1 -0
hashident/__init__.py
ADDED
hashident/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Command-line interface for hashident."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import functools
|
|
7
|
+
import itertools
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import textwrap
|
|
11
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, NoReturn
|
|
14
|
+
|
|
15
|
+
from hashident import __version__, json_output
|
|
16
|
+
from hashident.engine import identify
|
|
17
|
+
from hashident.inputs import InputError, InputItem, gather
|
|
18
|
+
from hashident.models import Context, Result
|
|
19
|
+
from hashident.output import WIDTH, CompactTable, Positions, format_result, report_rule
|
|
20
|
+
from hashident.style import Styler
|
|
21
|
+
from hashident.terminal import COLOR_MODES, use_color
|
|
22
|
+
from hashident.tips import ContextTip, context_tips
|
|
23
|
+
|
|
24
|
+
_EPILOG = """\
|
|
25
|
+
Input: hashes given as arguments are reported first, in order, then the
|
|
26
|
+
lines of -f PATH. With neither, hashes are read from standard input when it
|
|
27
|
+
is piped or redirected. In files and piped input, blank lines and lines
|
|
28
|
+
starting with # are skipped. UTF-8 and UTF-16 (with a BOM) are understood.
|
|
29
|
+
|
|
30
|
+
Output: one hash gets a full report with reasons and crack commands.
|
|
31
|
+
Several hashes get a compact table, one row each (argument index or line
|
|
32
|
+
number, best format, confidence, Hashcat mode), then a count per format.
|
|
33
|
+
With -v, every hash gets a full report instead, under a rule naming it,
|
|
34
|
+
and low-confidence candidates get details as well.
|
|
35
|
+
|
|
36
|
+
JSON: --json prints one JSON object per hash, each on its own line (JSON
|
|
37
|
+
Lines), never colored. --json ignores -v: JSON always includes everything.
|
|
38
|
+
Keys, in schema_version 1: schema_version, input, source, position,
|
|
39
|
+
context, matched, candidates, tips. Each candidate: rank, format, score,
|
|
40
|
+
confidence, hashcat_mode, john_format, strength, strength_note, reasons,
|
|
41
|
+
commands, notes. Each tip: context, top_format. Absent values are null.
|
|
42
|
+
|
|
43
|
+
Quoting: hashes that contain $ (bcrypt, md5crypt, sha512crypt) or start
|
|
44
|
+
with * (MySQL) must be wrapped in single quotes, in PowerShell and bash
|
|
45
|
+
alike. Otherwise the shell expands them before hashident sees them:
|
|
46
|
+
hashident '$2a$05$LhayLxezLhK1LhWvKxCyLOj0j1u.Kj0jZ0pEmm134uzrQlFvQJLF6'
|
|
47
|
+
|
|
48
|
+
Color: --color auto (the default) colors output only when it goes to a
|
|
49
|
+
terminal and the NO_COLOR environment variable is unset or empty. Order of
|
|
50
|
+
precedence: --color/--no-color, then NO_COLOR, then terminal detection.
|
|
51
|
+
Piped or redirected output is plain unless you pass --color always.
|
|
52
|
+
|
|
53
|
+
Exit status:
|
|
54
|
+
0 every hash matched a known format
|
|
55
|
+
1 none did
|
|
56
|
+
3 some did and some did not
|
|
57
|
+
2 invalid arguments, or input that cannot be read or has no hashes"""
|
|
58
|
+
|
|
59
|
+
EXIT_ALL_MATCHED = 0
|
|
60
|
+
EXIT_NONE_MATCHED = 1
|
|
61
|
+
EXIT_ERROR = 2
|
|
62
|
+
EXIT_SOME_MATCHED = 3
|
|
63
|
+
|
|
64
|
+
# Help and errors are always plain: argparse decides on its own color (3.14+)
|
|
65
|
+
# before --color is parsed, so it could not honour our flags anyway.
|
|
66
|
+
_PARSER_OPTIONS: dict[str, Any] = {"color": False} if sys.version_info >= (3, 14) else {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Parser(argparse.ArgumentParser):
|
|
70
|
+
"""ArgumentParser whose error messages are wrapped to 80 columns."""
|
|
71
|
+
|
|
72
|
+
def error(self, message: str) -> NoReturn:
|
|
73
|
+
self.print_usage(sys.stderr)
|
|
74
|
+
text = textwrap.fill(f"{self.prog}: error: {message}", width=WIDTH, subsequent_indent=" ")
|
|
75
|
+
self.exit(2, text + "\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
79
|
+
parser = _Parser(
|
|
80
|
+
prog="hashident",
|
|
81
|
+
description="Identify likely hash algorithms from a hash string.",
|
|
82
|
+
epilog=_EPILOG,
|
|
83
|
+
formatter_class=functools.partial(argparse.RawDescriptionHelpFormatter, width=WIDTH),
|
|
84
|
+
**_PARSER_OPTIONS,
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--version",
|
|
88
|
+
action="version",
|
|
89
|
+
version=f"%(prog)s {__version__}",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--from",
|
|
93
|
+
dest="context",
|
|
94
|
+
type=str.lower,
|
|
95
|
+
choices=[c.value for c in Context],
|
|
96
|
+
help="where the hash came from; ranks formats common there higher",
|
|
97
|
+
)
|
|
98
|
+
color = parser.add_mutually_exclusive_group()
|
|
99
|
+
color.add_argument(
|
|
100
|
+
"--color",
|
|
101
|
+
type=str.lower,
|
|
102
|
+
choices=COLOR_MODES,
|
|
103
|
+
help="when to use color (default: auto)",
|
|
104
|
+
)
|
|
105
|
+
color.add_argument(
|
|
106
|
+
"--no-color",
|
|
107
|
+
dest="color",
|
|
108
|
+
action="store_const",
|
|
109
|
+
const="never",
|
|
110
|
+
help="same as --color never",
|
|
111
|
+
)
|
|
112
|
+
parser.set_defaults(color="auto")
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"-f",
|
|
115
|
+
"--file",
|
|
116
|
+
metavar="PATH",
|
|
117
|
+
help="read hashes from PATH, one per line; - means standard input",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"-v",
|
|
121
|
+
"--verbose",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="full report for every hash, with details of low-confidence candidates too",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--json",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help="print JSON, one object per hash per line (see JSON below)",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument("hashes", nargs="*", metavar="hash", help="a hash to identify")
|
|
131
|
+
return parser
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
135
|
+
parser = build_parser()
|
|
136
|
+
args = parser.parse_args(argv)
|
|
137
|
+
try:
|
|
138
|
+
items = gather(args.hashes, args.file)
|
|
139
|
+
if items is None:
|
|
140
|
+
parser.print_help()
|
|
141
|
+
return EXIT_ALL_MATCHED
|
|
142
|
+
|
|
143
|
+
first, second = next(items, None), next(items, None)
|
|
144
|
+
if first is None:
|
|
145
|
+
return _fail(parser, "no hashes found in the input; see hashident --help")
|
|
146
|
+
several = second is not None
|
|
147
|
+
values = itertools.chain((first, second) if several else (first,), items)
|
|
148
|
+
|
|
149
|
+
tally = _Tally()
|
|
150
|
+
analysed = _analyse(values, args.context, tally)
|
|
151
|
+
if args.json:
|
|
152
|
+
# Checked before use_color, which may change the console's mode.
|
|
153
|
+
_print_json(analysed)
|
|
154
|
+
else:
|
|
155
|
+
style = Styler(enabled=use_color(args.color, sys.stdout, os.environ))
|
|
156
|
+
if several and not args.verbose:
|
|
157
|
+
_print_compact(analysed, CompactTable(_positions(args), style))
|
|
158
|
+
else:
|
|
159
|
+
_print_reports(analysed, style, several=several, show_low=args.verbose)
|
|
160
|
+
return tally.exit_status()
|
|
161
|
+
except InputError as exc:
|
|
162
|
+
return _fail(parser, str(exc))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_Analysed = Iterator[tuple[InputItem, Result, tuple[ContextTip, ...]]]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class _Tally:
|
|
170
|
+
"""Counts of values with and without a match, for the exit status."""
|
|
171
|
+
|
|
172
|
+
matched: int = 0
|
|
173
|
+
unmatched: int = 0
|
|
174
|
+
|
|
175
|
+
def exit_status(self) -> int:
|
|
176
|
+
if not self.unmatched:
|
|
177
|
+
return EXIT_ALL_MATCHED
|
|
178
|
+
return EXIT_SOME_MATCHED if self.matched else EXIT_NONE_MATCHED
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _analyse(items: Iterable[InputItem], context: str | None, tally: _Tally) -> _Analysed:
|
|
182
|
+
"""Identify each value as it arrives, counting matches into ``tally``."""
|
|
183
|
+
for item in items:
|
|
184
|
+
result = identify(item.value, context=context)
|
|
185
|
+
if result.candidates:
|
|
186
|
+
tally.matched += 1
|
|
187
|
+
else:
|
|
188
|
+
tally.unmatched += 1
|
|
189
|
+
yield item, result, context_tips(result)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _print_json(analysed: _Analysed) -> None:
|
|
193
|
+
for item, result, tips in analysed:
|
|
194
|
+
print(json_output.dumps(json_output.to_record(item, result, tips)))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _print_reports(analysed: _Analysed, style: Styler, *, several: bool, show_low: bool) -> None:
|
|
198
|
+
"""Full reports; with several values, each under a rule naming it."""
|
|
199
|
+
for index, (item, result, tips) in enumerate(analysed):
|
|
200
|
+
if several:
|
|
201
|
+
if index:
|
|
202
|
+
print()
|
|
203
|
+
print(report_rule(item))
|
|
204
|
+
print(format_result(result, tips=tips, style=style, show_low=show_low))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _print_compact(analysed: _Analysed, table: CompactTable) -> None:
|
|
208
|
+
print("\n".join(table.header()))
|
|
209
|
+
for item, result, tips in analysed:
|
|
210
|
+
print(table.row(item, result, tips))
|
|
211
|
+
print("\n".join(table.footer()))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _positions(args: argparse.Namespace) -> Positions:
|
|
215
|
+
"""What the compact table's first column counts, given the inputs used."""
|
|
216
|
+
if not args.hashes:
|
|
217
|
+
return Positions.LINE # -f, or piped stdin
|
|
218
|
+
return Positions.ARGUMENT if args.file is None else Positions.MIXED
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _fail(parser: argparse.ArgumentParser, message: str) -> int:
|
|
222
|
+
"""Print a one-line error (never wrapped: it may hold a path)."""
|
|
223
|
+
print(f"{parser.prog}: error: {message}", file=sys.stderr)
|
|
224
|
+
return EXIT_ERROR
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
hashident/commands.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Ready-to-run cracking commands for a signature.
|
|
2
|
+
|
|
3
|
+
Kept separate from text formatting so other output formats (e.g. JSON)
|
|
4
|
+
can reuse the same commands and notes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from hashident.models import Signature
|
|
10
|
+
from hashident.signatures import MYSQL41
|
|
11
|
+
|
|
12
|
+
HASH_FILE = "hash.txt"
|
|
13
|
+
WORDLIST = "wordlist.txt"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def crack_commands(sig: Signature) -> tuple[str, ...]:
|
|
17
|
+
"""Hashcat and John wordlist commands, for the tools that support ``sig``."""
|
|
18
|
+
commands = []
|
|
19
|
+
if sig.hashcat_mode is not None:
|
|
20
|
+
commands.append(f"hashcat -m {sig.hashcat_mode} -a 0 {HASH_FILE} {WORDLIST}")
|
|
21
|
+
if sig.john_format is not None:
|
|
22
|
+
commands.append(f"john --format={sig.john_format} --wordlist={WORDLIST} {HASH_FILE}")
|
|
23
|
+
return tuple(commands)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tool_notes(sig: Signature, value: str) -> tuple[str, ...]:
|
|
27
|
+
"""Notes on preparing ``value`` for the tools, when it needs changes."""
|
|
28
|
+
if sig is MYSQL41:
|
|
29
|
+
if value.startswith("*"):
|
|
30
|
+
return (
|
|
31
|
+
"For hashcat, save the hash without the leading '*' (mode 300 "
|
|
32
|
+
"expects exactly 40 hex characters). John's mysql-sha1 format "
|
|
33
|
+
"needs the '*', so keep it for john.",
|
|
34
|
+
)
|
|
35
|
+
return (
|
|
36
|
+
"For john, add a leading '*' (its mysql-sha1 format expects '*' "
|
|
37
|
+
"plus 40 hex characters). Hashcat mode 300 takes the hash as is.",
|
|
38
|
+
)
|
|
39
|
+
return ()
|
hashident/context.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Context hints: where the hash was found (the --from option).
|
|
2
|
+
|
|
3
|
+
A hint boosts signatures that are typical for that environment. It never
|
|
4
|
+
penalises the others; it only changes which plausible format is preferred.
|
|
5
|
+
|
|
6
|
+
Boost size
|
|
7
|
+
----------
|
|
8
|
+
``CONTEXT_BOOST`` is 50 points on the 0-100 popularity scale, where tiers
|
|
9
|
+
are 20 points apart (see ``hashident.signatures``):
|
|
10
|
+
|
|
11
|
+
* It overturns a popularity gap of up to two tiers (40 points) but not
|
|
12
|
+
three (60). NTLM (60) beats MD5 (100) with --from windows (110 vs 100),
|
|
13
|
+
but a rare format (20) cannot overtake a ubiquitous one (100) on a hint
|
|
14
|
+
alone (70 vs 100).
|
|
15
|
+
* After such a flip the two are 10 points apart, well within the engine's
|
|
16
|
+
``MEDIUM_MAX_GAP`` of 20, so both stay MEDIUM. A hint changes the best
|
|
17
|
+
guess; it does not turn an ambiguous match into a certain one.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from hashident.models import Adjustment, Context, Signature
|
|
23
|
+
|
|
24
|
+
CONTEXT_BOOST = 50.0
|
|
25
|
+
"""Points added to a signature whose contexts include the hint."""
|
|
26
|
+
|
|
27
|
+
_DESCRIPTIONS: dict[Context, str] = {
|
|
28
|
+
Context.WINDOWS: "common in Windows environments",
|
|
29
|
+
Context.LINUX: "common in Linux environments",
|
|
30
|
+
Context.WEB: "common in web applications",
|
|
31
|
+
Context.DB: "common in databases",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_context(value: Context | str | None) -> Context | None:
|
|
36
|
+
"""Turn a hint like ``"Windows"`` into a ``Context``; None means no hint.
|
|
37
|
+
|
|
38
|
+
Raises ValueError, listing the valid options, for an unknown string.
|
|
39
|
+
"""
|
|
40
|
+
if value is None or isinstance(value, Context):
|
|
41
|
+
return value
|
|
42
|
+
if not isinstance(value, str):
|
|
43
|
+
raise TypeError(f"context must be a Context, str or None, not {type(value).__name__}")
|
|
44
|
+
try:
|
|
45
|
+
return Context(value.strip().casefold())
|
|
46
|
+
except ValueError:
|
|
47
|
+
valid = ", ".join(c.value for c in Context)
|
|
48
|
+
raise ValueError(f"Invalid context {value!r}; choose one of: {valid}") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def context_adjustments(sig: Signature, context: Context | None) -> list[Adjustment]:
|
|
52
|
+
"""The boost for ``sig`` under ``context``, if it fits; otherwise none."""
|
|
53
|
+
if context is None or context not in sig.contexts:
|
|
54
|
+
return []
|
|
55
|
+
reason = f"{_DESCRIPTIONS[context]} (--from {context.value})"
|
|
56
|
+
return [Adjustment(delta=CONTEXT_BOOST, reason=reason)]
|
hashident/engine.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Identification engine: turns a raw string into ranked candidates.
|
|
2
|
+
|
|
3
|
+
Pipeline for one input:
|
|
4
|
+
|
|
5
|
+
1. Normalize (``Result`` strips surrounding whitespace).
|
|
6
|
+
2. Match the normalized value against every signature.
|
|
7
|
+
3. Score each match: popularity, then any score adjustments
|
|
8
|
+
(currently the context hint, see ``hashident.context``).
|
|
9
|
+
4. Rank: score descending, then name, so output is deterministic.
|
|
10
|
+
5. Label confidence and add reasons that depend on the other matches.
|
|
11
|
+
|
|
12
|
+
Confidence thresholds
|
|
13
|
+
---------------------
|
|
14
|
+
* Exactly one signature matches: HIGH. The shape alone pins down the format.
|
|
15
|
+
* Several match: the input is genuinely ambiguous, so nothing is HIGH,
|
|
16
|
+
with or without a context hint.
|
|
17
|
+
A candidate within ``MEDIUM_MAX_GAP`` points of the top score is MEDIUM
|
|
18
|
+
(the top candidate always is); anything further behind is LOW.
|
|
19
|
+
|
|
20
|
+
``MEDIUM_MAX_GAP`` is 20 because popularity tiers are 20 points apart
|
|
21
|
+
(see ``hashident.signatures``): a candidate within one tier of the leader
|
|
22
|
+
is still a serious contender, while one two or more tiers behind is a
|
|
23
|
+
long shot. MD5 (100) vs NTLM (60) gives MD5 MEDIUM and NTLM LOW.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from hashident.context import context_adjustments, parse_context
|
|
33
|
+
from hashident.models import Adjustment, Candidate, Confidence, Context, Result, Signature
|
|
34
|
+
from hashident.signatures import SIGNATURES
|
|
35
|
+
|
|
36
|
+
MEDIUM_MAX_GAP = 20.0
|
|
37
|
+
"""Max points behind the top score for a candidate to still be MEDIUM."""
|
|
38
|
+
|
|
39
|
+
_HEX_RE = re.compile(r"[0-9a-fA-F]+")
|
|
40
|
+
|
|
41
|
+
# Modular crypt format identifiers whose first parameter is a cost factor.
|
|
42
|
+
_MCF_COST_IDS = frozenset({"2a", "2b", "2y"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _Scored:
|
|
47
|
+
signature: Signature
|
|
48
|
+
score: float
|
|
49
|
+
reasons: tuple[str, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def identify(
|
|
53
|
+
raw: str,
|
|
54
|
+
context: Context | str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
signatures: Sequence[Signature] = SIGNATURES,
|
|
57
|
+
) -> Result:
|
|
58
|
+
"""Identify which hash formats ``raw`` could be, best guess first.
|
|
59
|
+
|
|
60
|
+
``context`` is an optional hint about where the hash came from, as a
|
|
61
|
+
``Context`` or its name (e.g. ``"windows"``, case-insensitive). An
|
|
62
|
+
unknown name raises ValueError listing the valid options.
|
|
63
|
+
|
|
64
|
+
Returns a ``Result`` with no candidates for empty input or input that
|
|
65
|
+
matches no known format; it does not raise for those cases.
|
|
66
|
+
"""
|
|
67
|
+
hint = parse_context(context)
|
|
68
|
+
result = Result(raw=raw, context=hint)
|
|
69
|
+
value = result.normalized
|
|
70
|
+
if not value:
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
matched = [sig for sig in signatures if sig.matches(value)]
|
|
74
|
+
scored = [_score(sig, value, hint) for sig in matched]
|
|
75
|
+
scored.sort(key=lambda s: (-s.score, s.signature.name))
|
|
76
|
+
|
|
77
|
+
candidates = tuple(_to_candidate(s, scored, value) for s in scored)
|
|
78
|
+
return Result(raw=raw, candidates=candidates, context=hint)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _score(sig: Signature, value: str, context: Context | None) -> _Scored:
|
|
82
|
+
"""Score one matching signature: popularity plus every adjustment."""
|
|
83
|
+
score = float(sig.popularity)
|
|
84
|
+
reasons = [_shape_reason(sig, value)]
|
|
85
|
+
for adj in _adjustments(sig, value, context):
|
|
86
|
+
score += adj.delta
|
|
87
|
+
reasons.append(adj.reason)
|
|
88
|
+
return _Scored(signature=sig, score=score, reasons=tuple(reasons))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _adjustments(sig: Signature, value: str, context: Context | None) -> list[Adjustment]:
|
|
92
|
+
"""Score adjustments for one candidate.
|
|
93
|
+
|
|
94
|
+
This is the single place where scores are changed beyond popularity.
|
|
95
|
+
"""
|
|
96
|
+
return context_adjustments(sig, context)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _to_candidate(item: _Scored, ranked: Sequence[_Scored], value: str) -> Candidate:
|
|
100
|
+
"""Label confidence and add reasons that depend on the other matches."""
|
|
101
|
+
top = ranked[0]
|
|
102
|
+
others = [s.signature.name for s in ranked if s is not item]
|
|
103
|
+
reasons = list(item.reasons)
|
|
104
|
+
|
|
105
|
+
if not others:
|
|
106
|
+
confidence = Confidence.HIGH
|
|
107
|
+
reasons.append("no other known format has this shape")
|
|
108
|
+
else:
|
|
109
|
+
gap = top.score - item.score
|
|
110
|
+
confidence = Confidence.MEDIUM if gap <= MEDIUM_MAX_GAP else Confidence.LOW
|
|
111
|
+
reasons.append(_ranking_reason(item, top, ranked))
|
|
112
|
+
why = "same length and charset" if _HEX_RE.fullmatch(value) else "same format"
|
|
113
|
+
reasons.append(f"also matches {', '.join(others)}: {why}")
|
|
114
|
+
|
|
115
|
+
return Candidate(
|
|
116
|
+
signature=item.signature,
|
|
117
|
+
score=item.score,
|
|
118
|
+
confidence=confidence,
|
|
119
|
+
reasons=tuple(reasons),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ranking_reason(item: _Scored, top: _Scored, ranked: Sequence[_Scored]) -> str:
|
|
124
|
+
"""Explain this candidate's place in the final ranking.
|
|
125
|
+
|
|
126
|
+
Popularity comparisons are always true statements about popularity;
|
|
127
|
+
when an adjustment reversed the popularity order, the reason says so.
|
|
128
|
+
"""
|
|
129
|
+
pop = item.signature.popularity
|
|
130
|
+
if item is top:
|
|
131
|
+
more_popular = [s.signature.name for s in ranked if s.signature.popularity > pop]
|
|
132
|
+
if not more_popular:
|
|
133
|
+
return "most common format with this shape"
|
|
134
|
+
return f"ranked above {', '.join(more_popular)} by the context hint"
|
|
135
|
+
|
|
136
|
+
leader, top_pop = top.signature.name, top.signature.popularity
|
|
137
|
+
if pop < top_pop:
|
|
138
|
+
return f"less common than {leader} with this shape"
|
|
139
|
+
if pop == top_pop and item.score == top.score:
|
|
140
|
+
return f"as common as {leader} with this shape"
|
|
141
|
+
comparison = f"more common than {leader}" if pop > top_pop else f"as common as {leader}"
|
|
142
|
+
return f"{comparison} with this shape, but {leader} matches the context hint"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _shape_reason(sig: Signature, value: str) -> str:
|
|
146
|
+
"""Describe what about the input's shape matched this signature."""
|
|
147
|
+
if _HEX_RE.fullmatch(value):
|
|
148
|
+
return f"{len(value)} hexadecimal characters"
|
|
149
|
+
if value.startswith("*") and _HEX_RE.fullmatch(value[1:]):
|
|
150
|
+
return f"'*' followed by {len(value) - 1} hexadecimal characters"
|
|
151
|
+
if value.startswith("$") and value.count("$") >= 2:
|
|
152
|
+
parts = value.split("$")
|
|
153
|
+
reason = f"starts with {sig.name} prefix ${parts[1]}$"
|
|
154
|
+
if parts[1] in _MCF_COST_IDS and len(parts) > 2 and parts[2].isdigit():
|
|
155
|
+
reason += f" (cost {int(parts[2])})"
|
|
156
|
+
return reason
|
|
157
|
+
return f"matches the {sig.name} format ({len(value)} characters)"
|
hashident/inputs.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Collecting the values to identify: arguments, a file, or standard input.
|
|
2
|
+
|
|
3
|
+
Order of the values, and where they come from:
|
|
4
|
+
|
|
5
|
+
1. Positional arguments, in the order given, exactly as typed.
|
|
6
|
+
2. Then the lines of ``-f PATH`` (``-f -`` reads standard input).
|
|
7
|
+
3. With neither, standard input is read if it is piped or redirected. On a
|
|
8
|
+
terminal there is nothing to read, and ``gather`` returns None.
|
|
9
|
+
|
|
10
|
+
Lines (but not arguments) are cleaned up: surrounding whitespace and the
|
|
11
|
+
``\\r`` of Windows line endings are stripped, and blank lines and lines
|
|
12
|
+
starting with ``#`` are skipped.
|
|
13
|
+
|
|
14
|
+
Bytes are decoded as UTF-8 unless they start with a UTF-16 byte order mark,
|
|
15
|
+
which is what ``>`` redirection writes in Windows PowerShell 5.1. A UTF-8
|
|
16
|
+
BOM is dropped. Invalid bytes become U+FFFD instead of raising.
|
|
17
|
+
|
|
18
|
+
Input is decoded in chunks and yielded one line at a time, so memory use
|
|
19
|
+
depends on the longest line, not on the size of the input.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import codecs
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Iterator, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import BinaryIO, TextIO
|
|
29
|
+
|
|
30
|
+
STDIN = "-"
|
|
31
|
+
ARGUMENT_SOURCE = "argument"
|
|
32
|
+
STDIN_SOURCE = "<stdin>"
|
|
33
|
+
|
|
34
|
+
_COMMENT = "#"
|
|
35
|
+
_CHUNK_SIZE = 64 * 1024
|
|
36
|
+
_TEXT_BOM = ""
|
|
37
|
+
# (byte order mark, encoding of what follows it)
|
|
38
|
+
_BOMS = (
|
|
39
|
+
(codecs.BOM_UTF8, "utf-8"),
|
|
40
|
+
(codecs.BOM_UTF16_LE, "utf-16-le"),
|
|
41
|
+
(codecs.BOM_UTF16_BE, "utf-16-be"),
|
|
42
|
+
)
|
|
43
|
+
_LONGEST_BOM = max(len(bom) for bom, _ in _BOMS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InputError(Exception):
|
|
47
|
+
"""Input could not be read. The message is short and meant for the user."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class InputItem:
|
|
52
|
+
"""One value to identify, and where it came from."""
|
|
53
|
+
|
|
54
|
+
value: str
|
|
55
|
+
source: str
|
|
56
|
+
"""``ARGUMENT_SOURCE``, ``STDIN_SOURCE``, or the path given to ``-f``."""
|
|
57
|
+
position: int
|
|
58
|
+
"""1-based argument index, or 1-based line number within ``source``."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def gather(values: Sequence[str], file: str | None = None) -> Iterator[InputItem] | None:
|
|
62
|
+
"""The values to identify, in order, or None if no input was given at all.
|
|
63
|
+
|
|
64
|
+
``file`` is opened here rather than on first iteration, so a missing or
|
|
65
|
+
unreadable file raises ``InputError`` before anything has been yielded.
|
|
66
|
+
Errors while reading raise ``InputError`` during iteration.
|
|
67
|
+
"""
|
|
68
|
+
if file is None and not values:
|
|
69
|
+
if not _is_piped(sys.stdin):
|
|
70
|
+
return None
|
|
71
|
+
file = STDIN
|
|
72
|
+
if file is None:
|
|
73
|
+
return _items(values, "", iter(()))
|
|
74
|
+
source, lines = _open_lines(file)
|
|
75
|
+
return _items(values, source, lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _items(values: Sequence[str], source: str, lines: Iterator[str]) -> Iterator[InputItem]:
|
|
79
|
+
for index, value in enumerate(values, start=1):
|
|
80
|
+
yield InputItem(value, ARGUMENT_SOURCE, index)
|
|
81
|
+
try:
|
|
82
|
+
for number, line in enumerate(lines, start=1):
|
|
83
|
+
value = line.strip()
|
|
84
|
+
if value and not value.startswith(_COMMENT):
|
|
85
|
+
yield InputItem(value, source, number)
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise InputError(_cannot_read(source, exc)) from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_piped(stream: TextIO | None) -> bool:
|
|
91
|
+
"""Whether ``stream`` exists and is not a terminal (a pipe or a file)."""
|
|
92
|
+
if stream is None:
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
return not stream.isatty()
|
|
96
|
+
except (AttributeError, ValueError): # no isatty, or stream closed
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _open_lines(file: str) -> tuple[str, Iterator[str]]:
|
|
101
|
+
"""``(source name, raw lines)`` for a path, or for stdin if it is ``-``."""
|
|
102
|
+
if file == STDIN:
|
|
103
|
+
stream = sys.stdin
|
|
104
|
+
if stream is None or stream.closed:
|
|
105
|
+
raise InputError("cannot read standard input: it is not available")
|
|
106
|
+
binary = getattr(stream, "buffer", None)
|
|
107
|
+
# A replacement stdin such as io.StringIO has text only, no bytes.
|
|
108
|
+
lines = _text_lines(stream) if binary is None else _decoded_lines(binary)
|
|
109
|
+
return STDIN_SOURCE, lines
|
|
110
|
+
try:
|
|
111
|
+
handle = open(file, "rb")
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise InputError(_cannot_read(file, exc)) from exc
|
|
114
|
+
return file, _file_lines(handle)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _file_lines(handle: BinaryIO) -> Iterator[str]:
|
|
118
|
+
with handle:
|
|
119
|
+
yield from _decoded_lines(handle)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _text_lines(stream: TextIO) -> Iterator[str]:
|
|
123
|
+
for number, line in enumerate(stream):
|
|
124
|
+
yield line.removeprefix(_TEXT_BOM) if number == 0 else line
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _decoded_lines(binary: BinaryIO) -> Iterator[str]:
|
|
128
|
+
"""Decode ``binary`` chunk by chunk and yield its lines, without the ``\\n``."""
|
|
129
|
+
chunk = binary.read(_LONGEST_BOM)
|
|
130
|
+
encoding = "utf-8"
|
|
131
|
+
for bom, bom_encoding in _BOMS:
|
|
132
|
+
if chunk.startswith(bom):
|
|
133
|
+
encoding, chunk = bom_encoding, chunk[len(bom) :]
|
|
134
|
+
break
|
|
135
|
+
decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
|
|
136
|
+
|
|
137
|
+
pending = ""
|
|
138
|
+
while True:
|
|
139
|
+
pending += decoder.decode(chunk)
|
|
140
|
+
*complete, pending = pending.split("\n")
|
|
141
|
+
yield from complete
|
|
142
|
+
# read1 returns what is available, so piped lines are not held back
|
|
143
|
+
# until a whole chunk has arrived.
|
|
144
|
+
chunk = binary.read1(_CHUNK_SIZE)
|
|
145
|
+
if not chunk:
|
|
146
|
+
break
|
|
147
|
+
pending += decoder.decode(b"", final=True)
|
|
148
|
+
if pending:
|
|
149
|
+
yield pending
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cannot_read(source: str, exc: OSError) -> str:
|
|
153
|
+
name = "standard input" if source == STDIN_SOURCE else source
|
|
154
|
+
return f"cannot read {name}: {exc.strerror or exc}"
|