aboutsummaryrefslogtreecommitdiffstats
path: root/tests/_fixtures.py
blob: 0b72856d3f293b52e971c1b97c59f615be4baa48 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""Shared setup for the suites that need a real analysed binary.

Two things every IDA suite has to get right, and only test_scenarios did:

**Work on a scratch copy.** Opening a binary writes a `.i64` beside it, and the
suites EDIT it -- they define code, undefine items, rename and comment, and IDA
saves that. Run against the tracked target and each run inherits the last one's
damage: a scenario started failing with no code change because an earlier one
had undefined an instruction. A suite whose result depends on its own history
can't be trusted to accuse the code.

**Seed from a golden database.** Auto-analysis is the bulk of a suite's runtime
(`targets/echo` is ~30s of it) and it produces the same answer every time. Doing
it once and keeping the result as `<binary>.pristine.i64` -- which nothing ever
writes back to -- turns that into a file copy.

    with staged("targets/echo") as target:
        app = IdaTui(open_path=target, ...)

The cache is rebuilt whenever it is older than the binary, so editing a target
doesn't silently test the previous one. `.pristine.i64` is gitignored.
"""
from __future__ import annotations

import contextlib
import os
import shutil
import tempfile


def cache_path(binary: str) -> str:
    return binary + ".pristine.i64"


def cache_is_fresh(binary: str) -> bool:
    c = cache_path(binary)
    return os.path.exists(c) and os.path.getmtime(c) >= os.path.getmtime(binary)


#: Generated targets live here so their pristine caches survive between runs.
#: Gitignored; safe to delete (the next run rebuilds both).
SYNTHETIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".synthetic")


def synthetic(name: str, build) -> str:
    """A generated binary at a STABLE path, rebuilt only when its bytes change.

    Generated targets used to be written into a fresh TemporaryDirectory on
    every run, which quietly defeated the whole pristine-cache scheme: a new
    path with new bytes every time means auto-analysis is paid in full, every
    run, forever. `test_blob_ui`'s 64KB blob cost ~40s a run that way.

    ``build()`` must be DETERMINISTIC and return bytes. That is also what makes
    the suites reproducible: a blob built from os.urandom can, by luck, contain
    something IDA reads as a function, and then a test asserting "no functions"
    fails for reasons no one can reproduce.
    """
    os.makedirs(SYNTHETIC_DIR, exist_ok=True)
    path = os.path.join(SYNTHETIC_DIR, name)
    data = build()
    if not os.path.exists(path) or open(path, "rb").read() != data:
        with open(path, "wb") as fh:          # content changed -> cache is stale
            fh.write(data)
        for stale in (cache_path(path), path + ".i64"):
            if os.path.exists(stale):
                os.remove(stale)
    return path


async def build_pristine(binary: str, cache: str, app_factory) -> None:
    """Analyse ``binary`` once and keep the database as a golden copy.

    ``app_factory(path)`` builds the IdaTui -- passed in so this module needs no
    import of the app (and so a suite can hand over its own load options).
    """
    print(f"  (building pristine database for {os.path.basename(binary)}\u2026)")
    app = app_factory(binary)
    async with app.run_test(size=(140, 44)) as pilot:
        for _ in range(6000):
            await pilot.pause(0.05)
            if app._func_index is not None and app._func_index.complete:
                break
        app.program.client.save_database()
    # Textual's headless run_test context does not reliably emit App.Unmount on
    # every platform/version; release the Code Mode lease explicitly.
    if app.program is not None:
        app.program.close()
    if app.client is not None:
        app.client.close()
    db = binary + ".i64"
    if os.path.exists(db):
        shutil.copy2(db, cache)


@contextlib.contextmanager
def scratch_copy(binary: str, prefix: str = "idatui-test-"):
    """A temp-dir copy of ``binary``, seeded from the pristine cache if there is
    a fresh one. Yields the copy's path; the directory goes away after.

    Does NOT build the cache (that needs an app and an event loop) -- a suite
    that wants one calls :func:`build_pristine` first. Without a cache this is
    still correct, just slow: the worker analyses from scratch.
    """
    with tempfile.TemporaryDirectory(prefix=prefix) as d:
        target = os.path.join(d, os.path.basename(binary))
        shutil.copy2(binary, target)
        if cache_is_fresh(binary):
            shutil.copy2(cache_path(binary), target + ".i64")
        yield target


@contextlib.asynccontextmanager
async def staged(binary: str, app_factory=None, prefix: str = "idatui-test-"):
    """:func:`scratch_copy`, building the pristine cache first if it's missing.

    This is what a suite wants: one call, and the analysis is paid once ever
    rather than once per run.
    """
    if app_factory is not None and not cache_is_fresh(binary):
        with tempfile.TemporaryDirectory(prefix=prefix) as seed_dir:
            seed = os.path.join(seed_dir, os.path.basename(binary))
            shutil.copy2(binary, seed)
            await build_pristine(seed, cache_path(binary), app_factory)
    with scratch_copy(binary, prefix) as target:
        yield target