aboutsummaryrefslogtreecommitdiffstats
path: root/tests/_fixtures.py
blob: c7a45d351330fb2e4852b5c5e212238f9622829d (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
"""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)


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