aboutsummaryrefslogtreecommitdiffstats
path: root/fw/harvest_drivers.py
blob: d6d90c3baf81402fd28df22b1c9e8f973bfe73fd (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
#!/usr/bin/env python3
"""Harvest PSP firmware from every AMD Windows driver package for the RX 7900 XTX.

One package at a time, because disk is the binding constraint: each `.exe` is 0.6-1.5 GB
and a full NSIS extraction is ~3x that. So the loop is
    download -> extract ONLY amdkmdag.sys -> carve -> keep new blobs -> delete everything

Scoping note: firmware is extracted from `amdkmdag.sys` only. In 22.12.1 that file held
639 of the ~700 `$PS1` hits in the whole package; the rest were audio-driver and VCN
blobs, not PSP modules. So this is a deliberate, documented narrowing, not a claim that
nothing else carries firmware.

Resumable: every finished version is recorded in `state.json`, so re-running skips work
already done. Dedup is by body sha256 against the existing corpus (`psp_out/versions`,
`versions_lfw`, `versions_win`) plus everything harvested so far, so only genuinely new
builds are written.

usage: fw/harvest_drivers.py [--limit N] [--scratch DIR] [--out DIR]
"""
import argparse
import glob
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from carve_driver import carve                                    # noqa: E402

PAGE = ("https://www.amd.com/en/support/downloads/previous-drivers.html/graphics/"
        "radeon-rx/radeon-rx-7000-series/amd-radeon-rx-7900-xtx.html")
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"
MIN_FREE_GB = 6


def free_gb(path):
    st = os.statvfs(path)
    return st.f_bavail * st.f_frsize / 2**30


def driver_list():
    req = urllib.request.Request(PAGE, headers={"User-Agent": UA})
    html = urllib.request.urlopen(req, timeout=60).read().decode("utf-8", "replace")
    urls = sorted(set(re.findall(r'https?://drivers\.amd\.com/[^"\'<> ]+\.exe', html)))
    out = []
    for u in urls:
        m = re.search(r'edition-([0-9]+\.[0-9]+(?:\.[0-9]+)?)', u)
        if not m:
            continue                                  # skip the RGB-LED utility etc.
        out.append((m.group(1), u))
    # oldest first
    def key(v):
        return [int(x) for x in v[0].split('.')]
    return sorted({v: u for v, u in out}.items(), key=key)


def corpus_hashes(repo):
    h = {}
    for d in ('psp_out/versions', 'psp_out/versions_lfw', 'psp_out/versions_win',
              'psp_out/versions_win/containers'):
        for p in glob.glob(os.path.join(repo, d, '*.bin')):
            try:
                h[hashlib.sha256(open(p, 'rb').read()).hexdigest()] = os.path.basename(p)
            except OSError:
                pass
    return h


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--scratch', default='/home/user/scratch/amd-drv/harvest')
    ap.add_argument('--out', default=None, help='default: <repo>/psp_out/versions_win')
    ap.add_argument('--limit', type=int, default=0)
    ap.add_argument('--repo', default=os.path.dirname(os.path.dirname(
        os.path.abspath(__file__))))
    args = ap.parse_args()

    out = args.out or os.path.join(args.repo, 'psp_out', 'versions_win')
    os.makedirs(args.scratch, exist_ok=True)
    os.makedirs(os.path.join(out, 'containers'), exist_ok=True)

    state_p = os.path.join(args.scratch, 'state.json')
    state = json.load(open(state_p)) if os.path.exists(state_p) else {}
    inv_p = os.path.join(args.repo, 'fw', 'windows_inventory.jsonl')

    known = corpus_hashes(args.repo)
    print('corpus: %d known bodies' % len(known), flush=True)

    drivers = driver_list()
    print('drivers: %d versions, %s .. %s'
          % (len(drivers), drivers[0][0], drivers[-1][0]), flush=True)

    done = 0
    for version, url in drivers:
        # retry anything that previously errored; skip only real successes
        if version in state and 'modules' in state[version]:
            continue
        if args.limit and done >= args.limit:
            break
        if free_gb(args.scratch) < MIN_FREE_GB:
            print('STOP: only %.1f GB free' % free_gb(args.scratch), flush=True)
            break
        exe = os.path.join(args.scratch, '%s.exe' % version)
        xdir = os.path.join(args.scratch, 'x_%s' % version)
        t0 = time.time()
        try:
            r = subprocess.run(['curl', '-sS', '-f', '-A', UA, '-e',
                                'https://www.amd.com/', '-o', exe, url],
                               capture_output=True, timeout=3600)
            if r.returncode != 0 or not os.path.exists(exe):
                state[version] = {'error': 'download rc=%d' % r.returncode}
                print('%-9s DOWNLOAD FAILED' % version, flush=True)
                continue
            # Two packaging formats, discovered the hard way: 22.x/23.0-23.5 are
            # NSIS self-extractors, while 23.7.1 onward are PE32+ 7-Zip SFX
            # (7z archive embedded at ~0x1b3e00).  `-tnsis` silently yields
            # nothing on the latter, which is why a first run reported
            # "NO amdkmdag.sys" for every version from 23.7.1.  Try both.
            sysfiles = []
            for fmt in ('-tnsis', '-t7z'):
                subprocess.run(['7z', 'x', '-y', fmt, '-o' + xdir, exe,
                                '-r', 'amdkmdag.sys'],
                               capture_output=True, timeout=3600)
                sysfiles = glob.glob(os.path.join(xdir, '**', 'amdkmdag.sys'),
                                     recursive=True)
                if sysfiles:
                    break
            if not sysfiles:
                state[version] = {'error': 'no amdkmdag.sys'}
                print('%-9s NO amdkmdag.sys' % version, flush=True)
                continue
            recs = []
            for sf in sysfiles:
                with open(sf, 'rb') as fh:
                    data = fh.read()
                recs.extend([r for r in carve(data, os.path.basename(sf))
                             if r['verdict'] == 'module'])
            new = 0
            seen = set()
            with open(inv_p, 'a') as inv:
                for r in recs:
                    if r['sha256'] in seen:
                        continue
                    seen.add(r['sha256'])
                    isnew = r['sha256'] not in known
                    rec = {k: v for k, v in r.items() if not k.startswith('_')}
                    rec['driver'] = version
                    rec['new'] = isnew
                    inv.write(json.dumps(rec) + '\n')
                    if isnew:
                        known[r['sha256']] = version
                        tag = (r['ta_version'] or r['version']).replace('.', '-')
                        nm = '%s_%s_%s.bin' % (r['guess'], tag, r['sha256'][:8])
                        with open(os.path.join(out, nm), 'wb') as fh:
                            fh.write(r['_body'])
                        with open(os.path.join(out, 'containers', nm), 'wb') as fh:
                            fh.write(r['_container'])
                        new += 1
            state[version] = {'modules': len(seen), 'new': new,
                              'secs': round(time.time() - t0, 1)}
            print('%-9s modules=%-3d new=%-3d  (%.0fs)'
                  % (version, len(seen), new, time.time() - t0), flush=True)
        except Exception as e:
            state[version] = {'error': '%s: %s' % (type(e).__name__, str(e)[:80])}
            print('%-9s ERROR %s' % (version, str(e)[:60]), flush=True)
        finally:
            for p in (exe,):
                if os.path.exists(p):
                    os.remove(p)
            shutil.rmtree(xdir, ignore_errors=True)
            json.dump(state, open(state_p, 'w'), indent=1)
            done += 1
    print('done: %d processed this run, %d total known bodies'
          % (done, len(known)), flush=True)
    return 0


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