aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_idbpath.py
blob: 8c39349cdec7b0ba14b4367c60b2477ca1d36529 (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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
#!/usr/bin/env python3
"""Deciding where a binary's database can be written.

The case: ``ida-tui /bin/ls`` asks IDA for ``/bin/ls.i64``, a normal user cannot
write ``/bin``, and the backend reports "idalib worker launcher NNN exited with
status 1" after a full analysis wait. These checks pin the pre-flight answer that
replaces that, and the two properties the proposed location must have — stable
per binary (so a session's names come back) and collision-free.

Pure: no IDA, no IDA Nexus, no Textual.
"""

from __future__ import annotations

import os
import stat
import sys
import tempfile

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

#: pure: path arithmetic plus os.access against real temp dirs.
#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
NEEDS_IDA = False

from idatui import idbpath  # noqa: E402

PASS = FAIL = 0


def check(name, ok, detail=""):
    global PASS, FAIL
    if ok:
        PASS += 1
        print(f"  ok   {name}")
    else:
        FAIL += 1
        print(f"  FAIL {name}   {detail}")


def touch(path, mode=0o644):
    with open(path, "wb") as fh:
        fh.write(b"\x7fELF")
    os.chmod(path, mode)
    return path


def t_expected_idb():
    """Must agree with ida_nexus's own derivation, or we check the wrong file."""
    check(
        "a binary gets .i64 appended",
        idbpath.expected_idb("/bin/ls") == "/bin/ls.i64",
        idbpath.expected_idb("/bin/ls"),
    )
    check(
        "a path that IS a database is left alone",
        idbpath.expected_idb("/tmp/x/foo.i64") == "/tmp/x/foo.i64",
        idbpath.expected_idb("/tmp/x/foo.i64"),
    )
    check(
        "the suffix check is case-insensitive, like the resolver's",
        idbpath.expected_idb("/tmp/x/FOO.I64") == "/tmp/x/FOO.I64",
        idbpath.expected_idb("/tmp/x/FOO.I64"),
    )
    check(
        "a relative path is absolute afterwards",
        os.path.isabs(idbpath.expected_idb("ls")),
    )


def t_blocked_reason(tmp):
    """The pre-flight answer: None for a writable directory, a reason otherwise."""
    good = touch(os.path.join(tmp, "app"))
    check("a writable directory is not blocked", idbpath.blocked_reason(good) is None)

    ro = os.path.join(tmp, "ro")
    os.mkdir(ro, 0o700)
    binary = touch(os.path.join(ro, "ls"))  # the binary first: /bin/ls exists
    os.chmod(ro, 0o500)
    reason = idbpath.blocked_reason(binary)
    check("an unwritable directory is blocked", reason is not None, repr(reason))
    check("the reason names the directory", ro in (reason or ""), repr(reason))

    # A read-only database in a writable directory is also a dead end: IDA
    # rewrites the .i64 on save, so it cannot be treated as a read-only view.
    b2 = touch(os.path.join(tmp, "app2"))
    touch(b2 + ".i64", mode=0o444)
    reason2 = idbpath.blocked_reason(b2)
    check(
        "an unwritable existing database is blocked", reason2 is not None, repr(reason2)
    )
    check(
        "...and the reason names the database, not the directory",
        (reason2 or "").startswith(b2 + ".i64"),
        repr(reason2),
    )
    os.chmod(b2 + ".i64", 0o644)
    check(
        "a writable existing database is not blocked",
        idbpath.blocked_reason(b2) is None,
        repr(idbpath.blocked_reason(b2)),
    )

    # Not our question to answer: launch.py already rejects a path that is
    # neither a file nor a registered database.
    check(
        "a file that does not exist is not reported as blocked",
        idbpath.blocked_reason(os.path.join(tmp, "nope")) is None,
    )
    os.chmod(ro, 0o700)


def t_no_side_effects(tmp):
    """The pre-flight check must not touch the user's directory."""
    binary = touch(os.path.join(tmp, "probe-target"))
    before = sorted(os.listdir(tmp))
    for _ in range(3):
        idbpath.blocked_reason(binary)
    check("blocked_reason creates nothing", sorted(os.listdir(tmp)) == before)


def t_relocated(tmp):
    """Stable per binary, unique between binaries, and named like the binary."""
    os.environ[idbpath.DB_DIR_ENV] = os.path.join(tmp, "dbs")
    try:
        a = os.path.join(tmp, "a")
        os.makedirs(a, exist_ok=True)
        b = os.path.join(tmp, "b")
        os.makedirs(b, exist_ok=True)
        one, two = touch(os.path.join(a, "busybox")), touch(os.path.join(b, "busybox"))
        p1, p2 = idbpath.relocated_idb(one), idbpath.relocated_idb(two)
        check(
            "the proposal is stable across calls (yesterday's work comes back)",
            p1 == idbpath.relocated_idb(one),
        )
        check("two binaries with the same name do not collide", p1 != p2, f"{p1} {p2}")
        check("it is recognisable", os.path.basename(p1).startswith("busybox-"), p1)
        check("it is an .i64", p1.endswith(".i64"), p1)
        check(
            "it lands under the configured root",
            p1.startswith(os.path.join(tmp, "dbs")),
            p1,
        )
        check(
            "the scratch stem is unique too (IDA unpacks beside the .i64)",
            os.path.splitext(os.path.basename(p1))[0]
            != os.path.splitext(os.path.basename(p2))[0],
        )
        # The relocated path is the one thing that must never need the original
        # directory to be writable.
        check("nothing is created just by proposing", not os.path.isdir(p1))
        idbpath.ensure_parent(p1)
        check("ensure_parent creates the directory", os.path.isdir(os.path.dirname(p1)))
        check(
            "...privately (a database exposes the binary and the session)",
            stat.S_IMODE(os.stat(os.path.dirname(p1)).st_mode) == 0o700,
            oct(stat.S_IMODE(os.stat(os.path.dirname(p1)).st_mode)),
        )
        check(
            "a database path relocates by its own name, without a doubled suffix",
            os.path.basename(idbpath.relocated_idb(os.path.join(a, "fw.i64"))).count(
                ".i64"
            )
            == 1,
            idbpath.relocated_idb(os.path.join(a, "fw.i64")),
        )
    finally:
        os.environ.pop(idbpath.DB_DIR_ENV, None)


def t_db_root(tmp):
    """Databases are user DATA, not cache: cleaners may empty a cache directory."""
    saved = {k: os.environ.get(k) for k in (idbpath.DB_DIR_ENV, "XDG_DATA_HOME")}
    try:
        os.environ.pop(idbpath.DB_DIR_ENV, None)
        os.environ["XDG_DATA_HOME"] = os.path.join(tmp, "share")
        root = idbpath.db_root()
        check(
            "XDG_DATA_HOME is honoured",
            root == os.path.join(tmp, "share", "idatui", "db"),
            root,
        )
        os.environ[idbpath.DB_DIR_ENV] = os.path.join(tmp, "explicit")
        check(
            f"{idbpath.DB_DIR_ENV} overrides it",
            idbpath.db_root() == os.path.join(tmp, "explicit"),
            idbpath.db_root(),
        )
        os.environ.pop("XDG_DATA_HOME")
        os.environ.pop(idbpath.DB_DIR_ENV)
        check("the default is not a cache directory", ".cache" not in idbpath.db_root())
    finally:
        for k, v in saved.items():
            if v is None:
                os.environ.pop(k, None)
            else:
                os.environ[k] = v


def t_can_create(tmp):
    """The truthful probe, used only after an open has already failed."""
    check(
        "a writable directory can hold a database",
        idbpath.can_create(os.path.join(tmp, "new.i64")),
    )
    check(
        "...and the probe cleans up after itself",
        not os.path.exists(os.path.join(tmp, "new.i64")),
    )
    ro = os.path.join(tmp, "ro2")
    os.mkdir(ro, 0o500)
    check(
        "an unwritable directory cannot",
        not idbpath.can_create(os.path.join(ro, "x.i64")),
    )
    os.chmod(ro, 0o700)
    existing = touch(os.path.join(tmp, "keep.i64"))
    check("an existing database counts as creatable", idbpath.can_create(existing))
    with open(existing, "rb") as fh:
        check(
            "...and is NOT truncated by the probe",
            fh.read() == b"\x7fELF",
            "the probe destroyed a database",
        )


def t_describe(tmp):
    """The dialog's one line has to distinguish resume from start-fresh."""
    target = os.path.join(tmp, "d", "ls-1234abcd.i64")
    os.makedirs(os.path.dirname(target), exist_ok=True)
    fresh = idbpath.describe("/bin/ls", target)
    check(
        "a new database says the original is untouched", "/bin/ls.i64" in fresh, fresh
    )
    touch(target)
    resumed = idbpath.describe("/bin/ls", target)
    check(
        "an existing one says the work comes back", "resum" in resumed.lower(), resumed
    )


def main() -> int:
    with tempfile.TemporaryDirectory() as tmp:
        for fn in (
            t_expected_idb,
            t_blocked_reason,
            t_no_side_effects,
            t_relocated,
            t_db_root,
            t_can_create,
            t_describe,
        ):
            print(f"\n{fn.__name__}")
            sub = os.path.join(tmp, fn.__name__)
            os.makedirs(sub, exist_ok=True)
            try:
                fn(sub) if fn is not t_expected_idb else fn()
            except Exception as e:  # noqa: BLE001
                import traceback

                check(f"{fn.__name__} did not crash", False, f"{type(e).__name__}: {e}")
                traceback.print_exc()
    print(f"\n{PASS} passed, {FAIL} failed")
    return 1 if FAIL else 0


if __name__ == "__main__":
    raise SystemExit(main())