aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/idbpath.py
blob: a9614b39c20f300749fdbb3e014f07ba0a0a1c15 (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
"""Where the database goes when the binary's own directory can't hold it.

IDA puts its database beside the binary: ``/bin/ls`` becomes ``/bin/ls.i64``,
plus ``.id0/.id1/.id2/.nam/.til`` scratch files unpacked into the same directory
while it is open. For anything the user doesn't own -- ``/bin``, ``/usr/lib``, a
read-only mount, a root-owned firmware drop -- that write is denied, and the only
thing to see is the backend's account of it::

    idalib worker launcher 2425195 exited with status 1
    [ida-nexus] Failed to open database /usr/bin/ls

which names neither the file it failed to create nor the reason. Worse, the
failure costs a full analysis wait first. So: check whether the database can be
written BEFORE opening anything, and when it can't, propose a path that works.

IDA Nexus takes ``output_database`` for exactly this, and project mode already
relies on it (everything lands in the project's sidecar). This is the
single-binary equivalent, and the proposed location is *stable per binary* --
reopening ``/bin/ls`` tomorrow finds yesterday's names and comments, which a
temporary directory would not.

Pure stdlib: no IDA, no IDA Nexus, no Textual. Nothing here writes anything.
"""

from __future__ import annotations

import hashlib
import os

#: Overrides where relocated databases are kept.
DB_DIR_ENV = "IDATUI_DB_DIR"

#: Deliberately under XDG_DATA_HOME, not XDG_CACHE_HOME. An .i64 looks like a
#: cache -- it is derived from the binary and can be rebuilt -- but it also holds
#: every name, comment and type the session produced, and cache directories are
#: something cleaners are entitled to empty.
_XDG_DEFAULT = os.path.join("~", ".local", "share", "idatui", "db")


def expected_idb(binary: str) -> str:
    """The database IDA would create for ``binary``, byte for byte as Nexus does.

    Mirrors ``ida_nexus._resolver.expected_idb_path``: a path that is already an
    ``.i64`` is the database, anything else gets ``.i64`` appended (IDA 9 is
    64-bit only, so there is no ``.idb`` case to consider).
    """
    path = os.path.abspath(os.path.expanduser(binary))
    return path if path.lower().endswith(".i64") else path + ".i64"


def db_root() -> str:
    """Directory holding relocated databases."""
    override = os.environ.get(DB_DIR_ENV)
    if override:
        return os.path.abspath(os.path.expanduser(override))
    xdg = os.environ.get("XDG_DATA_HOME")
    if xdg:
        return os.path.join(os.path.abspath(os.path.expanduser(xdg)), "idatui", "db")
    return os.path.abspath(os.path.expanduser(_XDG_DEFAULT))


def _slug(name: str) -> str:
    """``name`` reduced to something safe to paste into a shell command."""
    out = "".join(c if (c.isalnum() or c in "._-") else "_" for c in name)
    return out.strip("._-")[:64] or "binary"


def relocated_idb(binary: str) -> str:
    """A writable database path for ``binary``, stable across runs.

    Keyed by the resolved source path so the same binary always maps to the same
    database (your work comes back), and hashed so two ``busybox``es from
    different directories cannot collide. Flat rather than one directory per
    binary: IDA unpacks its scratch files alongside the ``.i64`` under the same
    stem, and the hash already makes that stem unique.
    """
    real = os.path.normcase(os.path.realpath(os.path.expanduser(binary)))
    digest = hashlib.sha1(real.encode("utf-8", "surrogateescape")).hexdigest()[:8]
    stem = os.path.basename(real.rstrip(os.sep) or real)
    if stem.lower().endswith(".i64"):
        stem = stem[:-4]
    return os.path.join(db_root(), f"{_slug(stem)}-{digest}.i64")


def blocked_reason(binary: str) -> str | None:
    """Why IDA could not write ``binary``'s database here, or None if it can.

    A short phrase that completes "can't open this binary because ...", meant to
    be shown to a human. ``None`` is the common answer and must stay cheap: only
    ``os.access`` calls, no probe files in the user's directories.

    ``os.access`` is not the whole truth (ACLs, an NFS mount that lies, a full
    disk), which is why the caller also treats a failed open as a candidate for
    relocation. It is right about the case that actually happens -- a binary in a
    system directory -- and being right there is worth a syscall.
    """
    idb = expected_idb(binary)
    src = os.path.abspath(os.path.expanduser(binary))
    if not os.path.exists(src):
        return None  # not ours to judge: a registered database, or a typo
    parent = os.path.dirname(idb) or "."
    if not os.path.isdir(parent):
        return f"{parent} is not a directory"
    if not os.access(parent, os.W_OK | os.X_OK):
        return f"{parent} is not writable"
    if os.path.exists(idb) and not os.access(idb, os.W_OK):
        # The directory is fine but the database itself isn't ours -- someone
        # else's analysis, or a root-owned .i64 shipped with the firmware. IDA
        # rewrites the .i64 on save, so read-only is a dead end, not a view.
        return f"{idb} exists but is not writable"
    return None


def can_create(target: str) -> bool:
    """Whether ``target`` can really be created right now, by trying it.

    The truthful version of ``blocked_reason``'s ``os.access`` check, and the
    only one that knows about ACLs, a read-only remount or a full disk. It costs
    a create + unlink in the user's directory, so it is for the path where an
    open has ALREADY failed and the question is what to blame -- never for the
    pre-flight check, which must not touch anything.

    An existing ``target`` is left strictly alone: this must never truncate a
    database.
    """
    target = os.path.abspath(os.path.expanduser(target))
    if os.path.exists(target):
        return True
    try:
        fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
    except OSError:
        return False
    os.close(fd)
    try:
        os.unlink(target)
    except OSError:  # created it but cannot remove it: still "creatable"
        pass
    return True


def describe(binary: str, target: str) -> str:
    """One line about what relocating ``binary`` to ``target`` will do."""
    if os.path.exists(target):
        return "resuming the database already kept there (your names and comments)"
    return f"a new database; the original {expected_idb(binary)} is never written"


def ensure_parent(target: str) -> str:
    """Create ``target``'s directory (private) and return ``target``.

    0700 because a database exposes the whole binary plus whatever the session
    recorded about it, and this directory accumulates one for every binary
    opened from a place that could not hold its own.
    """
    parent = os.path.dirname(os.path.abspath(os.path.expanduser(target)))
    if parent:
        os.makedirs(parent, mode=0o700, exist_ok=True)
    return os.path.abspath(os.path.expanduser(target))