tentod 0.1.0__tar.gz

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.
tentod-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: tentod
3
+ Version: 0.1.0
4
+ Summary: tentod
5
+ Home-page: https://github.com/rijulg/tentod
6
+ Author: Rijul Gupta
7
+ Author-email: rijulg@gmail.com
8
+ License: MIT
9
+ Keywords: dotnet
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.13
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: class-argparse==0.1.4
19
+ Requires-Dist: pythonnet==3.1.0
20
+ Dynamic: author
21
+ Dynamic: author-email
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: home-page
26
+ Dynamic: keywords
27
+ Dynamic: license
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
31
+
32
+ # tentod
33
+
34
+ ```bash
35
+ python -m tentod recover <assembly.dll>
36
+ python -m tentod guide # workflow for automated/LLM use
37
+ ```
tentod-0.1.0/README.md ADDED
@@ -0,0 +1,6 @@
1
+ # tentod
2
+
3
+ ```bash
4
+ python -m tentod recover <assembly.dll>
5
+ python -m tentod guide # workflow for automated/LLM use
6
+ ```
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
tentod-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
tentod-0.1.0/setup.py ADDED
@@ -0,0 +1,33 @@
1
+ import os
2
+ import setuptools
3
+
4
+ with open("README.md", "r") as readme:
5
+ long_description = readme.read()
6
+
7
+ setuptools.setup(
8
+ name="tentod",
9
+ packages=setuptools.find_namespace_packages(include=["tentod", "tentod.*"]),
10
+ package_data={"tentod": ["GUIDE.md"]},
11
+ version=os.environ.get("RELEASE_VERSION", "0.0.0"),
12
+ license="MIT",
13
+ description="tentod",
14
+ author="Rijul Gupta",
15
+ author_email="rijulg@gmail.com",
16
+ url="https://github.com/rijulg/tentod",
17
+ keywords=["dotnet"],
18
+ long_description=long_description,
19
+ long_description_content_type="text/markdown",
20
+ python_requires=">=3.13",
21
+ install_requires=[
22
+ "class-argparse==0.1.4",
23
+ "pythonnet==3.1.0",
24
+ ],
25
+ classifiers=[
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: POSIX :: Linux",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: Utilities",
32
+ ],
33
+ )
@@ -0,0 +1,53 @@
1
+ # tentod — guide for automated (LLM) use
2
+
3
+ tentod recovers readable Python from .NET assemblies in stages. The
4
+ deterministic stages are commands; the one creative stage (writing the Python)
5
+ is done by you, the model, against the contract below. Print this any time with
6
+ `python -m tentod guide`.
7
+
8
+ ## Turnkey workflow
9
+
10
+ Given one assembly, or a list/folder of them, process **each assembly
11
+ independently** — they share nothing, so fan out one worker per assembly and run
12
+ these steps for each:
13
+
14
+ 1. **Recover.** `python -m tentod recover <assembly.dll>`
15
+ - If it prints `SKIP not a managed assembly`, the file is native or not a
16
+ .NET assembly — move on, it is not yours to process.
17
+ - Otherwise it prints the paths of the decompiled C#, the **deobfuscated C#**,
18
+ and `facts.json`, plus counts (`types`/`strings`/`routes`/`inlined`).
19
+ 2. **Read** the deobfuscated C# and `facts.json`. The deobfuscated C# is the
20
+ source of truth for control flow and every literal (routes, keys, headers).
21
+ 3. **Write** the Python module (see the contract below). Map the assembly's
22
+ namespace to directories: `A.B.C` → `A/B/C.py`.
23
+ 4. **Verify.** `python -m tentod verify <assembly.dll> <your_output.py>`
24
+ - Resolve every entry under `missing` (a required literal your Python lacks).
25
+ - Review every entry under `omitted`: each must be something you dropped on
26
+ purpose (an internal log line, a diagnostic message) — never a route, query
27
+ key, header, or secret name.
28
+ 5. **Report** the coverage line and any deliberate omissions for that assembly.
29
+
30
+ Work on independent assemblies concurrently; each worker owns one assembly end
31
+ to end (recover → write → verify).
32
+
33
+ ## Style contract for the Python you write
34
+
35
+ Faithful, idiomatic Python — not a mechanical transliteration of the C#.
36
+
37
+ 1. **One module** at the namespace-mapped path.
38
+ 2. **Class-based.** No module-level functions. The public surface is a single
39
+ class; each user-facing operation is one public method.
40
+ 3. **Private helpers are `__`-prefixed** (name-mangled) — request building, key
41
+ resolution, response handling.
42
+ 4. **Module-level `CONSTANTS`** (base URLs, defaults) are fine.
43
+ 5. **Faithful.** Use the exact literals from the C#/facts. Invent nothing.
44
+ Reproduce behavior, not the C# class hierarchy — skip DTOs, DI, and
45
+ async-state-machine scaffolding a client does not need.
46
+ 6. **Type hints throughout.** Prefer the standard library.
47
+ 7. **Docstring states provenance** and lists any endpoints/behavior you
48
+ intentionally left out, so the omission is a decision, not an oversight.
49
+
50
+ ## Done
51
+
52
+ `python -m tentod verify` reports `OK` (nothing under `missing`) and every
53
+ `omitted` entry is one you can justify.
@@ -0,0 +1 @@
1
+ """tentod."""
@@ -0,0 +1,81 @@
1
+ """CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ from class_argparse import ClassArgParser
6
+
7
+ from .assembly import DotNetAssembly, NotManagedAssembly
8
+ from .pipeline import DotNetRecoveryPipeline
9
+
10
+ DEFAULT_OUT_ROOT = "out"
11
+ GUIDE = Path(__file__).with_name("GUIDE.md")
12
+
13
+
14
+ class Main(ClassArgParser):
15
+ """tentod."""
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__(name="tentod")
19
+
20
+ def __resolve(self, assembly: str) -> str:
21
+ p = Path(assembly)
22
+ if p.exists():
23
+ return str(p)
24
+ if not p.suffix:
25
+ withdll = p.with_suffix(".dll")
26
+ if withdll.exists():
27
+ return str(withdll)
28
+ raise SystemExit(f"not found: {assembly}")
29
+
30
+ def guide(self):
31
+ print(GUIDE.read_text())
32
+
33
+ def recover(
34
+ self,
35
+ assembly: str,
36
+ out_dir: str = DEFAULT_OUT_ROOT,
37
+ force: bool = False,
38
+ ):
39
+ dll = self.__resolve(assembly)
40
+ try:
41
+ result = DotNetRecoveryPipeline(dll, out_dir).recover(force=force)
42
+ except NotManagedAssembly:
43
+ print(f"SKIP not a managed assembly: {dll}")
44
+ return
45
+ print(result.csharp)
46
+ print(result.deobfuscated)
47
+ print(result.facts_path)
48
+ print(
49
+ f"types={len(result.facts.types)} "
50
+ f"strings={len(result.facts.flat_strings())} "
51
+ f"routes={len(result.facts.routes)} "
52
+ f"inlined={result.replacements}"
53
+ )
54
+
55
+ def verify(
56
+ self,
57
+ assembly: str,
58
+ python: str,
59
+ out_dir: str = DEFAULT_OUT_ROOT,
60
+ ):
61
+ dll = self.__resolve(assembly)
62
+ report = DotNetRecoveryPipeline(dll, out_dir).verify(python)
63
+ print(report.render())
64
+ if not report.ok:
65
+ raise SystemExit(1)
66
+
67
+ def strings(self, assembly: str):
68
+ try:
69
+ asm = DotNetAssembly.load(self.__resolve(assembly))
70
+ except NotManagedAssembly:
71
+ print(f"SKIP not a managed assembly: {assembly}")
72
+ return
73
+ for bank, entries in asm.decrypt_strings().items():
74
+ print(f"### {bank}")
75
+ for name, value in entries.items():
76
+ print(f" {name}() = {value!r}")
77
+ print()
78
+
79
+
80
+ if __name__ == "__main__": # pragma: no cover
81
+ Main()()
@@ -0,0 +1,21 @@
1
+ """Runtime bootstrap."""
2
+
3
+ import functools
4
+ import os
5
+
6
+
7
+ @functools.lru_cache(maxsize=1)
8
+ def runtime(): # pragma: no cover
9
+ os.environ.setdefault("PYTHONNET_RUNTIME", "mono")
10
+ import clr # noqa: F401
11
+
12
+ from System.Reflection import Assembly, BindingFlags
13
+
14
+ flags = (
15
+ BindingFlags.Static
16
+ | BindingFlags.Instance
17
+ | BindingFlags.Public
18
+ | BindingFlags.NonPublic
19
+ | BindingFlags.DeclaredOnly
20
+ )
21
+ return Assembly, flags
@@ -0,0 +1,134 @@
1
+ """Assembly reflection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from ._runtime import runtime
8
+
9
+
10
+ class NotManagedAssembly(Exception):
11
+ """Raised when a file is not a managed assembly (e.g. a native DLL)."""
12
+
13
+
14
+ @dataclass
15
+ class MethodInfo:
16
+ name: str
17
+ static: bool
18
+ returns: str
19
+ params: list[str] = field(default_factory=list)
20
+
21
+
22
+ @dataclass
23
+ class TypeInfo:
24
+ fullname: str
25
+ kind: str
26
+ methods: list[MethodInfo] = field(default_factory=list)
27
+ enum_members: list[str] = field(default_factory=list)
28
+
29
+
30
+ class DotNetAssembly:
31
+ """A loaded assembly queried through reflection."""
32
+
33
+ __STRING_BANK_MIN = 3
34
+
35
+ def __init__(self, asm, flags=None) -> None:
36
+ self.__asm = asm
37
+ self.__flags = flags
38
+
39
+ @classmethod
40
+ def load(cls, dll_path: str) -> "DotNetAssembly":
41
+ assembly, flags = runtime()
42
+ try:
43
+ obj = assembly.LoadFrom(str(dll_path))
44
+ except Exception as e:
45
+ if "BadImageFormat" in type(e).__name__:
46
+ raise NotManagedAssembly(str(dll_path)) from e
47
+ raise
48
+ return cls(obj, flags)
49
+
50
+ @property
51
+ def full_name(self) -> str:
52
+ return str(self.__asm.FullName)
53
+
54
+ def __types(self):
55
+ try:
56
+ return list(self.__asm.GetTypes())
57
+ except Exception as e:
58
+ loaded = getattr(e, "Types", None)
59
+ return [t for t in loaded if t is not None] if loaded else []
60
+
61
+ def __is_accessor(self, m) -> bool:
62
+ return (
63
+ m.IsStatic
64
+ and m.ReturnType.Name == "String"
65
+ and len(m.GetParameters()) == 0
66
+ and not m.Name.startswith("get_")
67
+ )
68
+
69
+ def __string_banks(self):
70
+ banks = []
71
+ for t in self.__types():
72
+ accessors = [m for m in t.GetMethods(self.__flags) if self.__is_accessor(m)]
73
+ if len(accessors) >= self.__STRING_BANK_MIN:
74
+ banks.append((t, accessors))
75
+ return banks
76
+
77
+ def decrypt_strings(self) -> dict[str, dict[str, str]]:
78
+ out: dict[str, dict[str, str]] = {}
79
+ for t, accessors in self.__string_banks():
80
+ bank: dict[str, str] = {}
81
+ for m in accessors:
82
+ try:
83
+ bank[m.Name] = str(m.Invoke(None, None))
84
+ except Exception:
85
+ continue
86
+ if bank:
87
+ out[t.Name] = bank
88
+ return out
89
+
90
+ def enums(self) -> dict[str, list[str]]:
91
+ out: dict[str, list[str]] = {}
92
+ for t in self.__types():
93
+ if t.IsEnum:
94
+ try:
95
+ out[t.FullName] = list(t.GetEnumNames())
96
+ except Exception:
97
+ out[t.FullName] = []
98
+ return out
99
+
100
+ def __kind(self, t) -> str:
101
+ if t.IsEnum:
102
+ return "enum"
103
+ if t.IsInterface:
104
+ return "interface"
105
+ if t.IsValueType:
106
+ return "struct"
107
+ return "class"
108
+
109
+ def types(self, include_generated: bool = False) -> list[TypeInfo]:
110
+ bank_names = {t.Name for t, _ in self.__string_banks()}
111
+ result: list[TypeInfo] = []
112
+ for t in self.__types():
113
+ if not include_generated and (
114
+ t.Name in bank_names or "PrivateImplementationDetails" in t.FullName
115
+ ):
116
+ continue
117
+ ti = TypeInfo(fullname=t.FullName, kind=self.__kind(t))
118
+ if t.IsEnum:
119
+ try:
120
+ ti.enum_members = list(t.GetEnumNames())
121
+ except Exception:
122
+ pass
123
+ else:
124
+ for m in t.GetMethods(self.__flags):
125
+ ti.methods.append(
126
+ MethodInfo(
127
+ name=m.Name,
128
+ static=m.IsStatic,
129
+ returns=m.ReturnType.Name,
130
+ params=[p.ParameterType.Name for p in m.GetParameters()],
131
+ )
132
+ )
133
+ result.append(ti)
134
+ return result
@@ -0,0 +1,59 @@
1
+ """Decompilation stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+
10
+
11
+ class IlspyDecompiler:
12
+ """Wrapper over the ``ilspycmd`` executable."""
13
+
14
+ __TOOL_CANDIDATES = ("ilspycmd", "~/.dotnet/tools/ilspycmd")
15
+
16
+ def __init__(self, ilspycmd: str | None = None) -> None:
17
+ self.__exe = ilspycmd or self.__discover()
18
+
19
+ def __discover(self) -> str:
20
+ for cand in self.__TOOL_CANDIDATES:
21
+ found = shutil.which(cand) or (
22
+ str(Path(cand).expanduser())
23
+ if Path(cand).expanduser().exists()
24
+ else None
25
+ )
26
+ if found:
27
+ return found
28
+ raise RuntimeError("ilspycmd not found")
29
+
30
+ def __env(self) -> dict[str, str]:
31
+ env = dict(os.environ)
32
+ env.setdefault("DOTNET_ROLL_FORWARD", "Major")
33
+ env.setdefault("DOTNET_CLI_TELEMETRY_OPTOUT", "1")
34
+ env.setdefault("DOTNET_NOLOGO", "1")
35
+ return env
36
+
37
+ def decompile(self, dll_path: str, out_dir: str, force: bool = False) -> Path:
38
+ dll = Path(dll_path)
39
+ out = Path(out_dir)
40
+ out.mkdir(parents=True, exist_ok=True)
41
+ expected = out / f"{dll.stem}.decompiled.cs"
42
+ if expected.exists() and not force:
43
+ return expected
44
+ proc = subprocess.run(
45
+ [self.__exe, str(dll), "-o", str(out)],
46
+ env=self.__env(),
47
+ capture_output=True,
48
+ text=True,
49
+ )
50
+ if proc.returncode != 0:
51
+ raise RuntimeError(
52
+ f"ilspycmd failed ({proc.returncode}):\n{proc.stderr or proc.stdout}"
53
+ )
54
+ if expected.exists():
55
+ return expected
56
+ produced = sorted(out.glob("*.cs"))
57
+ if not produced:
58
+ raise RuntimeError(f"no .cs produced in {out}")
59
+ return produced[0]
@@ -0,0 +1,50 @@
1
+ """Deobfuscation stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import re
7
+
8
+
9
+ class StringDeobfuscator:
10
+ """Rewrites accessor calls to their decrypted literals."""
11
+
12
+ def __init__(self, string_banks: dict[str, dict[str, str]]) -> None:
13
+ self.__banks = string_banks
14
+
15
+ @staticmethod
16
+ def __csharp_literal(value: str) -> str:
17
+ esc = (
18
+ value.replace("\\", "\\\\")
19
+ .replace('"', '\\"')
20
+ .replace("\n", "\\n")
21
+ .replace("\r", "\\r")
22
+ .replace("\t", "\\t")
23
+ )
24
+ return f'"{esc}"'
25
+
26
+ def __bank_pattern(self, qualifier: str, bank: dict[str, str]) -> re.Pattern:
27
+ names = "|".join(re.escape(n) for n in sorted(bank, key=len, reverse=True))
28
+ return re.compile(re.escape(qualifier) + r"\.(" + names + r")\(\)")
29
+
30
+ def deobfuscate_text(self, source: str) -> tuple[str, int]:
31
+ count = 0
32
+ for qualifier, bank in self.__banks.items():
33
+ if not bank:
34
+ continue
35
+ pattern = self.__bank_pattern(qualifier, bank)
36
+
37
+ def repl(m: re.Match) -> str:
38
+ nonlocal count
39
+ count += 1
40
+ return self.__csharp_literal(bank[m.group(1)])
41
+
42
+ source = pattern.sub(repl, source)
43
+ return source, count
44
+
45
+ def deobfuscate_file(self, cs_in: str, cs_out: str) -> tuple[Path, int]:
46
+ rewritten, count = self.deobfuscate_text(Path(cs_in).read_text())
47
+ out = Path(cs_out)
48
+ out.parent.mkdir(parents=True, exist_ok=True)
49
+ out.write_text(rewritten)
50
+ return out, count
@@ -0,0 +1,51 @@
1
+ """Facts stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ import json
7
+
8
+ from .assembly import DotNetAssembly
9
+
10
+
11
+ @dataclass
12
+ class AssemblyFacts:
13
+ assembly: str
14
+ strings: dict[str, dict[str, str]] = field(default_factory=dict)
15
+ enums: dict[str, list[str]] = field(default_factory=dict)
16
+ routes: list[str] = field(default_factory=list)
17
+ types: list[dict] = field(default_factory=list)
18
+
19
+ def flat_strings(self) -> list[str]:
20
+ seen: dict[str, None] = {}
21
+ for bank in self.strings.values():
22
+ for value in bank.values():
23
+ seen.setdefault(value, None)
24
+ return list(seen)
25
+
26
+ def to_json(self) -> str:
27
+ return json.dumps(asdict(self), indent=2, ensure_ascii=False)
28
+
29
+ @classmethod
30
+ def from_json(cls, text: str) -> "AssemblyFacts":
31
+ return cls(**json.loads(text))
32
+
33
+
34
+ class FactsExtractor:
35
+ def extract(self, asm: DotNetAssembly) -> AssemblyFacts:
36
+ strings = asm.decrypt_strings()
37
+ routes = sorted(
38
+ {
39
+ value
40
+ for bank in strings.values()
41
+ for value in bank.values()
42
+ if value.startswith("/")
43
+ }
44
+ )
45
+ return AssemblyFacts(
46
+ assembly=asm.full_name,
47
+ strings=strings,
48
+ enums=asm.enums(),
49
+ routes=routes,
50
+ types=[asdict(t) for t in asm.types()],
51
+ )
@@ -0,0 +1,61 @@
1
+ """Pipeline orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .assembly import DotNetAssembly
9
+ from .decompiler import IlspyDecompiler
10
+ from .deobfuscator import StringDeobfuscator
11
+ from .facts import AssemblyFacts, FactsExtractor
12
+ from .verify import RecoveryVerifier, VerifyReport
13
+
14
+
15
+ @dataclass
16
+ class RecoverResult:
17
+ csharp: Path
18
+ deobfuscated: Path
19
+ facts_path: Path
20
+ facts: AssemblyFacts
21
+ replacements: int
22
+
23
+
24
+ class DotNetRecoveryPipeline:
25
+ def __init__(self, dll_path: str, out_root: str) -> None:
26
+ self.__dll = Path(dll_path)
27
+ self.__root = Path(out_root) / self.__dll.stem
28
+
29
+ @property
30
+ def root(self) -> Path:
31
+ return self.__root
32
+
33
+ @property
34
+ def facts_path(self) -> Path:
35
+ return self.__root / "facts.json"
36
+
37
+ def recover(self, force: bool = False) -> RecoverResult:
38
+ asm = DotNetAssembly.load(str(self.__dll)) # classifies before decompiling
39
+ banks = asm.decrypt_strings()
40
+ csharp = IlspyDecompiler().decompile(
41
+ str(self.__dll),
42
+ str(self.__root / "csharp"),
43
+ force=force,
44
+ )
45
+ deobf, replacements = StringDeobfuscator(banks).deobfuscate_file(
46
+ str(csharp),
47
+ str(self.__root / "deobf" / f"{self.__dll.stem}.deobf.cs"),
48
+ )
49
+ facts = FactsExtractor().extract(asm)
50
+ self.facts_path.write_text(facts.to_json())
51
+ return RecoverResult(csharp, deobf, self.facts_path, facts, replacements)
52
+
53
+ def load_facts(self) -> AssemblyFacts:
54
+ if self.facts_path.exists():
55
+ return AssemblyFacts.from_json(self.facts_path.read_text())
56
+ return FactsExtractor().extract(DotNetAssembly.load(str(self.__dll)))
57
+
58
+ def verify(self, python_path: str) -> VerifyReport:
59
+ facts = self.load_facts()
60
+ source = Path(python_path).read_text()
61
+ return RecoveryVerifier().verify(facts, source)
@@ -0,0 +1,45 @@
1
+ """Verification stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from .facts import AssemblyFacts
8
+
9
+
10
+ @dataclass
11
+ class VerifyReport:
12
+ matched: list[str] = field(default_factory=list)
13
+ omitted: list[str] = field(default_factory=list)
14
+ missing_routes: list[str] = field(default_factory=list)
15
+
16
+ @property
17
+ def ok(self) -> bool:
18
+ return not self.missing_routes
19
+
20
+ def render(self) -> str:
21
+ total = len(self.matched) + len(self.omitted)
22
+ lines = [f"coverage: {len(self.matched)}/{total}"]
23
+ if self.missing_routes:
24
+ lines.append("")
25
+ lines.append(f"missing ({len(self.missing_routes)}):")
26
+ lines += [f" ! {r}" for r in self.missing_routes]
27
+ if self.omitted:
28
+ lines.append("")
29
+ lines.append(f"omitted ({len(self.omitted)}):")
30
+ lines += [f" - {s!r}" for s in self.omitted]
31
+ lines.append("")
32
+ lines.append("OK" if self.ok else "FAIL")
33
+ return "\n".join(lines)
34
+
35
+
36
+ class RecoveryVerifier:
37
+ def verify(self, facts: AssemblyFacts, python_source: str) -> VerifyReport:
38
+ report = VerifyReport()
39
+ for literal in facts.flat_strings():
40
+ if not literal.strip():
41
+ continue
42
+ bucket = report.matched if literal in python_source else report.omitted
43
+ bucket.append(literal)
44
+ report.missing_routes = [r for r in facts.routes if r not in python_source]
45
+ return report
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: tentod
3
+ Version: 0.1.0
4
+ Summary: tentod
5
+ Home-page: https://github.com/rijulg/tentod
6
+ Author: Rijul Gupta
7
+ Author-email: rijulg@gmail.com
8
+ License: MIT
9
+ Keywords: dotnet
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.13
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: class-argparse==0.1.4
19
+ Requires-Dist: pythonnet==3.1.0
20
+ Dynamic: author
21
+ Dynamic: author-email
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: home-page
26
+ Dynamic: keywords
27
+ Dynamic: license
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
31
+
32
+ # tentod
33
+
34
+ ```bash
35
+ python -m tentod recover <assembly.dll>
36
+ python -m tentod guide # workflow for automated/LLM use
37
+ ```
@@ -0,0 +1,25 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ tentod/GUIDE.md
5
+ tentod/__init__.py
6
+ tentod/__main__.py
7
+ tentod/_runtime.py
8
+ tentod/assembly.py
9
+ tentod/decompiler.py
10
+ tentod/deobfuscator.py
11
+ tentod/facts.py
12
+ tentod/pipeline.py
13
+ tentod/verify.py
14
+ tentod.egg-info/PKG-INFO
15
+ tentod.egg-info/SOURCES.txt
16
+ tentod.egg-info/dependency_links.txt
17
+ tentod.egg-info/requires.txt
18
+ tentod.egg-info/top_level.txt
19
+ tests/test_assembly.py
20
+ tests/test_decompiler.py
21
+ tests/test_deobfuscator.py
22
+ tests/test_facts.py
23
+ tests/test_main.py
24
+ tests/test_pipeline.py
25
+ tests/test_verify.py
@@ -0,0 +1,2 @@
1
+ class-argparse==0.1.4
2
+ pythonnet==3.1.0
@@ -0,0 +1 @@
1
+ tentod
@@ -0,0 +1,109 @@
1
+ import pytest
2
+
3
+ from tentod.assembly import DotNetAssembly, NotManagedAssembly
4
+
5
+
6
+ def _sample_types(clr):
7
+ return [
8
+ clr.Type("ns.Bank0", methods=[
9
+ clr.Method("m1", value="/x"),
10
+ clr.Method("m2", value="y"),
11
+ clr.Method("m3", raises=True), # decryptor helper -> skipped
12
+ ]),
13
+ clr.Type("ns.AllRaise", methods=[
14
+ clr.Method("a", raises=True),
15
+ clr.Method("b", raises=True),
16
+ clr.Method("c", raises=True),
17
+ ]),
18
+ clr.Type("ns.<PrivateImplementationDetails>Z", name="Z"),
19
+ clr.Type("ns.EnumGood", enum=["A", "B"], kind="enum"),
20
+ clr.Type("ns.EnumRaise", enum="raise", kind="enum"),
21
+ clr.Type("ns.IFoo", kind="interface"),
22
+ clr.Type("ns.SBar", kind="struct"),
23
+ clr.Type("ns.CBaz", methods=[
24
+ clr.Method("M", static=False, ret="Void", params=("String", "Int32")),
25
+ ]),
26
+ ]
27
+
28
+
29
+ def test_load_wraps_loadfrom(monkeypatch, clr):
30
+ sentinel = clr.Assembly([], fullname="Loaded, Version=9")
31
+
32
+ class Loader:
33
+ @staticmethod
34
+ def LoadFrom(path):
35
+ assert path == "x.dll"
36
+ return sentinel
37
+
38
+ monkeypatch.setattr("tentod.assembly.runtime", lambda: (Loader, None))
39
+ asm = DotNetAssembly.load("x.dll")
40
+ assert asm.full_name == "Loaded, Version=9"
41
+
42
+
43
+ def test_load_skips_native(monkeypatch):
44
+ class BadImageFormatException(Exception):
45
+ pass
46
+
47
+ class Loader:
48
+ @staticmethod
49
+ def LoadFrom(path):
50
+ raise BadImageFormatException("native")
51
+
52
+ monkeypatch.setattr("tentod.assembly.runtime", lambda: (Loader, None))
53
+ with pytest.raises(NotManagedAssembly):
54
+ DotNetAssembly.load("native.dll")
55
+
56
+
57
+ def test_load_reraises_other_errors(monkeypatch):
58
+ class Loader:
59
+ @staticmethod
60
+ def LoadFrom(path):
61
+ raise RuntimeError("disk on fire")
62
+
63
+ monkeypatch.setattr("tentod.assembly.runtime", lambda: (Loader, None))
64
+ with pytest.raises(RuntimeError):
65
+ DotNetAssembly.load("x.dll")
66
+
67
+
68
+ def test_decrypt_strings(clr):
69
+ asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
70
+ banks = asm.decrypt_strings()
71
+ assert banks == {"Bank0": {"m1": "/x", "m2": "y"}} # AllRaise dropped (empty)
72
+
73
+
74
+ def test_enums(clr):
75
+ asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
76
+ assert asm.enums() == {"ns.EnumGood": ["A", "B"], "ns.EnumRaise": []}
77
+
78
+
79
+ def test_types_default_skips_generated_and_covers_kinds(clr):
80
+ asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
81
+ by_name = {t.fullname: t for t in asm.types()}
82
+ assert "ns.Bank0" not in by_name # string bank skipped
83
+ assert "ns.<PrivateImplementationDetails>Z" not in by_name
84
+ assert by_name["ns.EnumGood"].enum_members == ["A", "B"]
85
+ assert by_name["ns.EnumRaise"].enum_members == [] # GetEnumNames raised
86
+ assert by_name["ns.IFoo"].kind == "interface"
87
+ assert by_name["ns.SBar"].kind == "struct"
88
+ cbaz = by_name["ns.CBaz"]
89
+ assert cbaz.kind == "class"
90
+ assert cbaz.methods[0].params == ["String", "Int32"]
91
+
92
+
93
+ def test_types_include_generated(clr):
94
+ asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
95
+ names = {t.fullname for t in asm.types(include_generated=True)}
96
+ assert "ns.Bank0" in names
97
+
98
+
99
+ def test_types_recovers_from_partial_load(clr):
100
+ class Boom:
101
+ FullName = "Boom"
102
+
103
+ def GetTypes(self):
104
+ err = RuntimeError("partial")
105
+ err.Types = [clr.Type("ns.X")]
106
+ raise err
107
+
108
+ asm = DotNetAssembly(Boom())
109
+ assert [t.fullname for t in asm.types()] == ["ns.X"]
@@ -0,0 +1,86 @@
1
+ import types
2
+
3
+ import pytest
4
+
5
+ from tentod.decompiler import IlspyDecompiler
6
+
7
+
8
+ def _proc(returncode=0, stderr="", stdout=""):
9
+ return types.SimpleNamespace(returncode=returncode, stderr=stderr, stdout=stdout)
10
+
11
+
12
+ def test_discover_via_which(monkeypatch):
13
+ monkeypatch.setattr(
14
+ "tentod.decompiler.shutil.which",
15
+ lambda c: "/usr/bin/ilspycmd" if c == "ilspycmd" else None,
16
+ )
17
+ d = IlspyDecompiler()
18
+ assert d._IlspyDecompiler__exe == "/usr/bin/ilspycmd"
19
+
20
+
21
+ def test_discover_via_path(monkeypatch, tmp_path):
22
+ exe = tmp_path / "ilspycmd"
23
+ exe.write_text("")
24
+ monkeypatch.setattr("tentod.decompiler.shutil.which", lambda c: None)
25
+ monkeypatch.setattr(
26
+ IlspyDecompiler, "_IlspyDecompiler__TOOL_CANDIDATES", (str(exe),)
27
+ )
28
+ assert IlspyDecompiler()._IlspyDecompiler__exe == str(exe)
29
+
30
+
31
+ def test_discover_not_found(monkeypatch):
32
+ monkeypatch.setattr("tentod.decompiler.shutil.which", lambda c: None)
33
+ monkeypatch.setattr(
34
+ IlspyDecompiler, "_IlspyDecompiler__TOOL_CANDIDATES", ("/nope/xyz",)
35
+ )
36
+ with pytest.raises(RuntimeError):
37
+ IlspyDecompiler()
38
+
39
+
40
+ def test_decompile_idempotent(monkeypatch, tmp_path):
41
+ out = tmp_path / "out"
42
+ out.mkdir()
43
+ (out / "a.decompiled.cs").write_text("cached")
44
+ called = []
45
+ monkeypatch.setattr("tentod.decompiler.subprocess.run", lambda *a, **k: called.append(1))
46
+ result = IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(out))
47
+ assert result == out / "a.decompiled.cs"
48
+ assert not called # subprocess never invoked
49
+
50
+
51
+ def test_decompile_success(monkeypatch, tmp_path):
52
+ out = tmp_path / "out"
53
+
54
+ def fake_run(cmd, env, capture_output, text):
55
+ (out / "a.decompiled.cs").write_text("cs")
56
+ return _proc()
57
+
58
+ monkeypatch.setattr("tentod.decompiler.subprocess.run", fake_run)
59
+ result = IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(out))
60
+ assert result == out / "a.decompiled.cs"
61
+
62
+
63
+ def test_decompile_nonzero_raises(monkeypatch, tmp_path):
64
+ monkeypatch.setattr(
65
+ "tentod.decompiler.subprocess.run", lambda *a, **k: _proc(1, stderr="boom")
66
+ )
67
+ with pytest.raises(RuntimeError, match="boom"):
68
+ IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(tmp_path / "o"))
69
+
70
+
71
+ def test_decompile_falls_back_to_glob(monkeypatch, tmp_path):
72
+ out = tmp_path / "out"
73
+
74
+ def fake_run(cmd, env, capture_output, text):
75
+ (out / "other.cs").write_text("cs") # not the expected name
76
+ return _proc()
77
+
78
+ monkeypatch.setattr("tentod.decompiler.subprocess.run", fake_run)
79
+ result = IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(out))
80
+ assert result == out / "other.cs"
81
+
82
+
83
+ def test_decompile_no_output_raises(monkeypatch, tmp_path):
84
+ monkeypatch.setattr("tentod.decompiler.subprocess.run", lambda *a, **k: _proc())
85
+ with pytest.raises(RuntimeError, match="no .cs"):
86
+ IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(tmp_path / "o"))
@@ -0,0 +1,46 @@
1
+ from tentod.deobfuscator import StringDeobfuscator
2
+
3
+ BANKS = {
4
+ "Bank0": {
5
+ "p": "/alpha",
6
+ "U": "keyname",
7
+ "u": "value",
8
+ "esc": 'a\\b"c\nd\re\tf',
9
+ }
10
+ }
11
+
12
+
13
+ def test_inlines_qualified_calls_only():
14
+ src = "x = A(z); y = Bank0.p(); d[Bank0.U()] = a;\n"
15
+ out, count = StringDeobfuscator(BANKS).deobfuscate_text(src)
16
+ assert count == 2
17
+ assert '"/alpha"' in out
18
+ assert '["keyname"] = a' in out
19
+ assert "A(z)" in out # unqualified local call untouched
20
+
21
+
22
+ def test_escapes_specials():
23
+ out, _ = StringDeobfuscator(BANKS).deobfuscate_text("Bank0.esc()")
24
+ assert out == '"a\\\\b\\"c\\nd\\re\\tf"'
25
+
26
+
27
+ def test_unknown_accessor_untouched():
28
+ out, count = StringDeobfuscator(BANKS).deobfuscate_text("Bank0.zzz()")
29
+ assert count == 0
30
+ assert "Bank0.zzz()" in out
31
+
32
+
33
+ def test_empty_bank_skipped():
34
+ out, count = StringDeobfuscator({"Empty": {}}).deobfuscate_text("Empty.p()")
35
+ assert count == 0
36
+ assert out == "Empty.p()"
37
+
38
+
39
+ def test_deobfuscate_file_round_trip(tmp_path):
40
+ src = tmp_path / "in.cs"
41
+ src.write_text("v = Bank0.p();")
42
+ dst = tmp_path / "sub" / "out.cs"
43
+ out_path, count = StringDeobfuscator(BANKS).deobfuscate_file(str(src), str(dst))
44
+ assert out_path == dst
45
+ assert count == 1
46
+ assert dst.read_text() == 'v = "/alpha";'
@@ -0,0 +1,44 @@
1
+ from tentod.facts import AssemblyFacts, FactsExtractor
2
+
3
+
4
+ class FakeAsm:
5
+ full_name = "Fake, Version=1.0.0.0"
6
+
7
+ def decrypt_strings(self):
8
+ return {"bank": {"p": "/alpha", "k": "keyname", "n": "not-a-route"}}
9
+
10
+ def enums(self):
11
+ return {"E": ["A", "B"]}
12
+
13
+ def types(self):
14
+ return [type("T", (), {})()]
15
+
16
+
17
+ def test_flat_strings_dedupes_and_preserves_order():
18
+ facts = AssemblyFacts(
19
+ assembly="Fake",
20
+ strings={"a": {"x": "one", "y": "two"}, "b": {"z": "two", "w": "three"}},
21
+ )
22
+ assert facts.flat_strings() == ["one", "two", "three"]
23
+
24
+
25
+ def test_json_round_trip():
26
+ facts = AssemblyFacts(
27
+ assembly="Fake",
28
+ strings={"bank": {"p": "/alpha"}},
29
+ enums={"E": ["A", "B"]},
30
+ routes=["/alpha"],
31
+ types=[{"fullname": "X", "kind": "class", "methods": [], "enum_members": []}],
32
+ )
33
+ assert AssemblyFacts.from_json(facts.to_json()) == facts
34
+
35
+
36
+ def test_extractor_collects_routes_and_surface(monkeypatch):
37
+ monkeypatch.setattr(
38
+ "tentod.facts.asdict", lambda t: {"fullname": "T"}
39
+ )
40
+ facts = FactsExtractor().extract(FakeAsm())
41
+ assert facts.assembly == "Fake, Version=1.0.0.0"
42
+ assert facts.routes == ["/alpha"] # only the "/"-prefixed literal
43
+ assert facts.enums == {"E": ["A", "B"]}
44
+ assert facts.types == [{"fullname": "T"}]
@@ -0,0 +1,115 @@
1
+ import types
2
+
3
+ import pytest
4
+
5
+ import tentod.__main__ as M
6
+
7
+
8
+ def _dll(tmp_path):
9
+ f = tmp_path / "a.dll"
10
+ f.write_text("")
11
+ return f
12
+
13
+
14
+ def test_resolve_existing(tmp_path):
15
+ f = _dll(tmp_path)
16
+ assert M.Main()._Main__resolve(str(f)) == str(f)
17
+
18
+
19
+ def test_resolve_adds_dll_suffix(tmp_path):
20
+ _dll(tmp_path)
21
+ assert M.Main()._Main__resolve(str(tmp_path / "a")) == str(tmp_path / "a.dll")
22
+
23
+
24
+ def test_resolve_missing_raises():
25
+ with pytest.raises(SystemExit):
26
+ M.Main()._Main__resolve("/nope/x") # no suffix -> tries .dll -> raises
27
+ with pytest.raises(SystemExit):
28
+ M.Main()._Main__resolve("/nope/x.dll") # has suffix -> straight to raise
29
+
30
+
31
+ def test_recover(monkeypatch, tmp_path, capsys):
32
+ facts = types.SimpleNamespace(
33
+ types=[1, 2], routes=["/x"], flat_strings=lambda: ["a", "b", "c"]
34
+ )
35
+ result = types.SimpleNamespace(
36
+ csharp="cs.cs", deobfuscated="d.cs", facts_path="f.json",
37
+ facts=facts, replacements=5,
38
+ )
39
+
40
+ class FakePipe:
41
+ def __init__(self, dll, out_dir):
42
+ pass
43
+
44
+ def recover(self, force):
45
+ return result
46
+
47
+ monkeypatch.setattr(M, "DotNetRecoveryPipeline", FakePipe)
48
+ M.Main().recover(str(_dll(tmp_path)))
49
+ out = capsys.readouterr().out
50
+ assert "types=2 strings=3 routes=1 inlined=5" in out
51
+
52
+
53
+ def _pipe_with_report(ok):
54
+ report = types.SimpleNamespace(ok=ok, render=lambda: "REPORT")
55
+
56
+ class FakePipe:
57
+ def __init__(self, dll, out_dir):
58
+ pass
59
+
60
+ def verify(self, python):
61
+ return report
62
+
63
+ return FakePipe
64
+
65
+
66
+ def test_verify_ok(monkeypatch, tmp_path):
67
+ monkeypatch.setattr(M, "DotNetRecoveryPipeline", _pipe_with_report(True))
68
+ M.Main().verify(str(_dll(tmp_path)), "gen.py") # no SystemExit
69
+
70
+
71
+ def test_verify_fail_exits(monkeypatch, tmp_path):
72
+ monkeypatch.setattr(M, "DotNetRecoveryPipeline", _pipe_with_report(False))
73
+ with pytest.raises(SystemExit):
74
+ M.Main().verify(str(_dll(tmp_path)), "gen.py")
75
+
76
+
77
+ def test_guide(capsys):
78
+ M.Main().guide()
79
+ assert "tentod" in capsys.readouterr().out
80
+
81
+
82
+ def test_recover_skips_native(monkeypatch, tmp_path, capsys):
83
+ class FakePipe:
84
+ def __init__(self, dll, out_dir):
85
+ pass
86
+
87
+ def recover(self, force):
88
+ raise M.NotManagedAssembly("native")
89
+
90
+ monkeypatch.setattr(M, "DotNetRecoveryPipeline", FakePipe)
91
+ M.Main().recover(str(_dll(tmp_path)))
92
+ assert "SKIP" in capsys.readouterr().out
93
+
94
+
95
+ def test_strings_skips_native(monkeypatch, tmp_path, capsys):
96
+ def boom(path):
97
+ raise M.NotManagedAssembly("native")
98
+
99
+ monkeypatch.setattr(M, "DotNetAssembly", types.SimpleNamespace(load=boom))
100
+ M.Main().strings(str(_dll(tmp_path)))
101
+ assert "SKIP" in capsys.readouterr().out
102
+
103
+
104
+ def test_strings(monkeypatch, tmp_path, capsys):
105
+ class FakeAsm:
106
+ def decrypt_strings(self):
107
+ return {"bank": {"m": "val"}}
108
+
109
+ monkeypatch.setattr(
110
+ M, "DotNetAssembly", types.SimpleNamespace(load=lambda p: FakeAsm())
111
+ )
112
+ M.Main().strings(str(_dll(tmp_path)))
113
+ out = capsys.readouterr().out
114
+ assert "### bank" in out
115
+ assert "m() = 'val'" in out
@@ -0,0 +1,94 @@
1
+ from pathlib import Path
2
+
3
+ from tentod.facts import AssemblyFacts
4
+ import tentod.pipeline as P
5
+
6
+
7
+ class _FakeAsm:
8
+ full_name = "Fake"
9
+
10
+ def decrypt_strings(self):
11
+ return {"b": {"p": "/x"}}
12
+
13
+
14
+ class _FakeLoader:
15
+ @staticmethod
16
+ def load(path):
17
+ return _FakeAsm()
18
+
19
+
20
+ class _FakeDeobf:
21
+ def __init__(self, banks):
22
+ self.banks = banks
23
+
24
+ def deobfuscate_file(self, cs_in, cs_out):
25
+ Path(cs_out).parent.mkdir(parents=True, exist_ok=True)
26
+ Path(cs_out).write_text("deobf")
27
+ return Path(cs_out), 7
28
+
29
+
30
+ def test_paths():
31
+ pipe = P.DotNetRecoveryPipeline("dir/a.dll", "root")
32
+ assert pipe.root == Path("root/a")
33
+ assert pipe.facts_path == Path("root/a/facts.json")
34
+
35
+
36
+ def test_recover(monkeypatch, tmp_path):
37
+ class FakeDec:
38
+ def decompile(self, dll, out_dir, force):
39
+ assert force is True
40
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
41
+ f = Path(out_dir) / "a.decompiled.cs"
42
+ f.write_text("cs")
43
+ return f
44
+
45
+ facts = AssemblyFacts(assembly="Fake", routes=["/x"])
46
+
47
+ class FakeExtractor:
48
+ def extract(self, asm):
49
+ return facts
50
+
51
+ monkeypatch.setattr(P, "IlspyDecompiler", lambda: FakeDec())
52
+ monkeypatch.setattr(P, "DotNetAssembly", _FakeLoader)
53
+ monkeypatch.setattr(P, "StringDeobfuscator", _FakeDeobf)
54
+ monkeypatch.setattr(P, "FactsExtractor", FakeExtractor)
55
+
56
+ pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
57
+ result = pipe.recover(force=True)
58
+ assert result.replacements == 7
59
+ assert result.facts is facts
60
+ assert result.deobfuscated.read_text() == "deobf"
61
+ assert pipe.facts_path.exists()
62
+
63
+
64
+ def test_load_facts_from_disk(tmp_path):
65
+ pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
66
+ pipe.facts_path.parent.mkdir(parents=True, exist_ok=True)
67
+ facts = AssemblyFacts(assembly="Fake", routes=["/x"])
68
+ pipe.facts_path.write_text(facts.to_json())
69
+ assert pipe.load_facts() == facts
70
+
71
+
72
+ def test_load_facts_extracts_when_absent(monkeypatch, tmp_path):
73
+ facts = AssemblyFacts(assembly="Fake")
74
+
75
+ class FakeExtractor:
76
+ def extract(self, asm):
77
+ return facts
78
+
79
+ monkeypatch.setattr(P, "DotNetAssembly", _FakeLoader)
80
+ monkeypatch.setattr(P, "FactsExtractor", FakeExtractor)
81
+ pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
82
+ assert pipe.load_facts() is facts
83
+
84
+
85
+ def test_verify(tmp_path):
86
+ pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
87
+ pipe.facts_path.parent.mkdir(parents=True, exist_ok=True)
88
+ pipe.facts_path.write_text(
89
+ AssemblyFacts(assembly="Fake", strings={"b": {"p": "/x"}}, routes=["/x"]).to_json()
90
+ )
91
+ py = tmp_path / "gen.py"
92
+ py.write_text('"/x"')
93
+ report = pipe.verify(str(py))
94
+ assert report.ok
@@ -0,0 +1,56 @@
1
+ from tentod.facts import AssemblyFacts
2
+ from tentod.verify import RecoveryVerifier
3
+
4
+
5
+ def _facts():
6
+ return AssemblyFacts(
7
+ assembly="Fake",
8
+ strings={
9
+ "bank": {
10
+ "p": "/alpha",
11
+ "r": "/beta",
12
+ "U": "keyname",
13
+ "log": "chatty internal message",
14
+ "empty": "",
15
+ }
16
+ },
17
+ routes=["/alpha", "/beta"],
18
+ )
19
+
20
+
21
+ def test_all_routes_present_is_ok():
22
+ py = 'BASE = "..."\n"/alpha"\n"/beta"\n{"keyname": x}\n'
23
+ report = RecoveryVerifier().verify(_facts(), py)
24
+ assert report.ok
25
+ assert not report.missing_routes
26
+ assert "chatty internal message" in report.omitted
27
+ assert "/alpha" in report.matched
28
+
29
+
30
+ def test_missing_route_fails():
31
+ py = '"/alpha"\n{"keyname": x}\n' # missing /beta
32
+ report = RecoveryVerifier().verify(_facts(), py)
33
+ assert not report.ok
34
+ assert report.missing_routes == ["/beta"]
35
+
36
+
37
+ def test_empty_strings_ignored():
38
+ report = RecoveryVerifier().verify(_facts(), '"/alpha""/beta""keyname"')
39
+ assert "" not in report.matched
40
+ assert "" not in report.omitted
41
+
42
+
43
+ def test_render_ok_verdict():
44
+ py = '"/alpha""/beta""keyname""chatty internal message"'
45
+ text = RecoveryVerifier().verify(_facts(), py).render()
46
+ assert text.endswith("OK")
47
+ assert "missing" not in text
48
+ assert "omitted" not in text # nothing omitted when everything matched
49
+
50
+
51
+ def test_render_fail_lists_missing_and_omitted():
52
+ text = RecoveryVerifier().verify(_facts(), "nothing here").render()
53
+ assert "missing (2):" in text
54
+ assert " ! /alpha" in text
55
+ assert "omitted (4):" in text
56
+ assert text.endswith("FAIL")