aboutsummaryrefslogtreecommitdiffstats
path: root/src/steamvr_patches/__init__.py
blob: be3b696e1a961f781d43fb34cb947a661034638d (plain)
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
# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski>
# SPDX-License-Identifier: GPL-3.0-or-later

import argparse
import hashlib
import sys
import tempfile
import tomllib
from base64 import b85encode
from importlib.resources import files
from io import BytesIO
from json import dumps
from pathlib import Path
from typing import Annotated, BinaryIO, Self
from zlib import compress

from pydantic import BaseModel, ConfigDict, Field, field_validator

from .elf import Hook, compile_hooks

NonnegativeInt = Annotated[int, Field(strict=True, ge=0)]


class Compilation(BaseModel):
    model_config = ConfigDict(extra="forbid")

    sources: list[Path] = Field(min_length=1)
    imports: dict[str, NonnegativeInt] = Field(default_factory=dict)
    hooks: list[Hook] = Field(min_length=1)


class FilePatch(BaseModel):
    model_config = ConfigDict(val_json_bytes="hex", extra="forbid")

    path: Path
    sha256: bytes = Field(min_length=32, max_length=32)
    edits: list[tuple[NonnegativeInt, bytes]]
    compiled: Compilation | None = None


class PatchSet(BaseModel):
    model_config = ConfigDict(extra="forbid")

    target: str
    targets: list[FilePatch]

    @field_validator("targets")
    @classmethod
    def unique_paths(cls, targets: list[FilePatch]) -> list[FilePatch]:
        paths = [target.path for target in targets]
        if len(set(paths)) != len(paths):
            raise ValueError("duplicate patch target")
        return targets

    @classmethod
    def parse(cls, config: dict, directory: Path) -> Self:
        result = cls.model_validate(config)
        for target in result.targets:
            if target.compiled is not None:
                target.compiled.sources = [
                    directory / source for source in target.compiled.sources
                ]
        return result


def compile_patch(config: FilePatch, source: BinaryIO, output: Path) -> dict:
    source.seek(0)
    data = source.read()
    if hashlib.sha256(data).digest() != config.sha256:
        raise ValueError(f"input SHA-256 mismatch: {config.path}")
    edits = list(config.edits)
    if config.compiled is not None:
        compiled = config.compiled
        edits += compile_hooks(
            source, compiled.sources, compiled.imports, compiled.hooks, output
        )
    end = 0
    for offset, replacement in sorted(edits):
        if offset < end:
            raise ValueError("overlapping edits")
        end = offset + len(replacement)
    patched = BytesIO(data)
    for offset, replacement in edits:
        patched.seek(offset)
        patched.write(replacement)
    return {
        "original_hash": config.sha256,
        "patched_hash": hashlib.sha256(patched.getbuffer()).digest(),
        "edits": edits,
    }


def render(patches: dict[str, dict]) -> str:
    template = files("steamvr_patches").joinpath("template.py").read_text()
    placeholder = "PATCH_DATA = {}"
    if template.count(placeholder) != 1:
        raise ValueError("missing or duplicate runtime patch-table placeholder")
    data = dumps(patches, default=bytes.hex, separators=(",", ":")).encode()
    encoded = b85encode(compress(data, level=9))
    return template.replace(placeholder, f"PATCH_DATA = decode_patch_data({encoded!r})")


def generate(patch_data: Path, input_root: Path) -> str:
    with (patch_data / "patches.toml").open("rb") as source:
        definition = PatchSet.parse(tomllib.load(source), patch_data)
    patches = {}
    with tempfile.TemporaryDirectory(prefix="generate-patcher-") as temporary:
        for index, config in enumerate(definition.targets):
            with (input_root / config.path).open("rb") as source:
                patches[config.path.as_posix()] = compile_patch(
                    config, source, Path(temporary) / str(index)
                )
    return render(patches)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "patch_data", type=Path, help="directory containing patches.toml etc"
    )
    parser.add_argument(
        "input_root", type=Path, help="directory containing the unpatched target files"
    )
    args = parser.parse_args()
    sys.stdout.write(generate(args.patch_data, args.input_root))