File size: 15,452 Bytes
4f0bd4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
"""
C preprocessor integration for AMC.

Expands #include directives via `cc -E` so that each source file
becomes a self-contained translation unit that the parser and CBMC
can handle without knowing the original include paths.

Also strips GCC/ARM64 extensions that CBMC does not accept:
  __attribute__((...)), __asm__/__asm blocks, _Noreturn, typeof,
  and register-asm variable declarations.
"""

from __future__ import annotations

import os
import re
import subprocess
import tempfile
from pathlib import Path

from bmc_agent.logger import get_logger

logger = get_logger("preprocessor")


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def preprocess(
    source_file: str | Path,
    include_dirs: list[str] | None = None,
    defines: list[str] | None = None,
    cc: str = "cc",
) -> str:
    """
    Return a cleaned, self-contained C source string for *source_file*.

    Steps:
      1. Run ``cc -E -P`` to expand all #include references.
      2. Strip lines that originate from system headers (``/usr/``, ``/lib/``).
      3. Strip GCC/ARM64 extensions that confuse CBMC.
      4. Prepend standard CBMC-friendly headers.

    Parameters
    ----------
    source_file:
        Path to the ``.c`` file to preprocess.
    include_dirs:
        List of ``-I`` paths to pass to the compiler.
    defines:
        List of ``-D`` macros to pass to the compiler.
    cc:
        C compiler binary to use for preprocessing (default ``cc``).
    """
    source_file = Path(source_file)
    include_dirs = include_dirs or []
    defines = defines or []

    expanded = _run_preprocessor(source_file, include_dirs, defines, cc)
    cleaned = _strip_system_content(expanded, source_file)
    cleaned = _strip_gcc_extensions(cleaned)
    cleaned = _prepend_cbmc_headers(cleaned)
    return cleaned


_HEADER_GLOBS = ("*.h", "*.hpp", "*.hh", "*.hxx")
# Directories never worth scanning for project headers (VCS / build / vendor).
_SKIP_DIRS = {".git", ".svn", ".hg", "build", "_build", "out", "node_modules"}
# Bound on the number of -I paths discovered, so a pathological repo can't
# explode the preprocessor command line. Shallower dirs are kept first.
_MAX_INCLUDE_DIRS = 200


def discover_include_dirs(
    source_dir: str | Path,
    exclude_patterns: "list[str] | tuple[str, ...]" = (),
) -> list[str]:
    """Best-effort discovery of a repo's ``-I`` include directories.

    A cloned repo's headers (e.g. ``include/window-rules.h``) aren't found by
    ``cc -E`` unless their directory is on the include path. The web path has no
    UI to supply ``-I`` paths, so without this every harness fails to build with
    ``fatal error: <header>: No such file or directory``. Heuristic:

    - every directory containing a header file (resolves ``#include "foo.h"``),
    - any ``include``/``inc`` ancestor of a header (resolves nested
      ``#include "labwc/foo.h"`` style), and
    - ``source_dir`` itself.

    Skips VCS/build/vendor dirs and anything matching *exclude_patterns* (note:
    these source-file patterns are matched against header names too, so a pattern
    like ``*test*`` also drops matching headers β€” at worst this discovers fewer
    include dirs, never an unsound result). Returns deduped absolute paths,
    shallowest first, capped at ``_MAX_INCLUDE_DIRS``.
    """
    import fnmatch

    source_dir = Path(source_dir).resolve()
    found: set[Path] = {source_dir}

    for pattern in _HEADER_GLOBS:
        for header in source_dir.rglob(pattern):
            parts = header.relative_to(source_dir).parts
            if any(p in _SKIP_DIRS for p in parts[:-1]):
                continue
            if any(fnmatch.fnmatch(header.name, pat) for pat in exclude_patterns):
                continue
            parent = header.parent
            found.add(parent)
            # Add any include/inc ancestor so `#include "sub/foo.h"` resolves.
            for anc in parent.parents:
                if anc == source_dir.parent:
                    break
                if anc.name in ("include", "inc"):
                    found.add(anc)

    # Shallowest first: keeps the most generally-useful roots when capped.
    ordered = sorted(found, key=lambda p: (len(p.parts), str(p)))
    if len(ordered) > _MAX_INCLUDE_DIRS:
        logger.info(
            "discover_include_dirs: %d candidate dirs, capping to %d",
            len(ordered), _MAX_INCLUDE_DIRS,
        )
        ordered = ordered[:_MAX_INCLUDE_DIRS]
    return [str(p) for p in ordered]


def preprocess_to_file(
    source_file: str | Path,
    output_file: str | Path,
    include_dirs: list[str] | None = None,
    defines: list[str] | None = None,
    cc: str = "cc",
) -> Path:
    """Preprocess *source_file* and write the result to *output_file*."""
    result = preprocess(source_file, include_dirs, defines, cc)
    out = Path(output_file)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(result, encoding="utf-8")
    return out


# ---------------------------------------------------------------------------
# Step 1: run cc -E
# ---------------------------------------------------------------------------


# Missing-header diagnostics from cc/clang, e.g.
#   gcc:   fatal error: wlr/types/wlr_output.h: No such file or directory
#   clang: fatal error: 'wlr/types/wlr_output.h' file not found
_MISSING_HEADER_RES = (
    re.compile(r"fatal error:\s*([^\n:'\"]+?):\s*No such file or directory"),
    re.compile(r"fatal error:\s*['\"]([^'\"\n]+)['\"]\s*file not found"),
)
# Bound on empty header stubs created per file, so a pathological include graph
# can't spin forever (each stub costs one cc -E re-run).
_MAX_HEADER_STUBS = 100


def _missing_header(stderr: str) -> "str | None":
    """Header path from a cc/clang "No such file" fatal error, or None."""
    for rx in _MISSING_HEADER_RES:
        m = rx.search(stderr)
        if m:
            hdr = m.group(1).strip()
            # Reject obvious non-paths (defensive against odd compiler output)
            # and any path that would escape the stub dir: ``..`` traversal or an
            # absolute path (``stub_dir / "/abs"`` resolves to ``/abs`` itself,
            # writing the stub outside the temp dir).
            if hdr and ".." not in hdr.split("/") and not os.path.isabs(hdr):
                return hdr
    return None


def _run_preprocessor(
    source_file: Path,
    include_dirs: list[str],
    defines: list[str],
    cc: str,
) -> str:
    """Run ``cc -E -P``, stubbing unresolvable headers so it can complete.

    Real projects #include third-party headers that aren't in the uploaded tree
    (e.g. labwc -> <wlr/...>, <wayland-*>), often nested inside an otherwise
    resolvable project header. ``cc -E`` hard-fails on the first missing one,
    which previously dropped us to RAW source with the dangling #include intact
    β€” and CBMC then can't build the harness ("harness build failed" for every
    function). Instead, on each "No such file" error we create an EMPTY stub for
    the named header on a private -I dir (lowest priority, so real headers still
    win) and retry. Project + libc headers expand normally; only genuinely-absent
    externals become empty stubs, leaving NO dangling #include in the output.
    """
    stub_dir = Path(tempfile.mkdtemp(prefix="amc_hdrstub_"))
    stubbed: list[str] = []
    try:
        while True:
            cmd = [cc, "-E", "-P"]
            for d in include_dirs:
                cmd += ["-I", d]
            cmd += ["-I", str(stub_dir)]  # last: stubs only fill genuine gaps
            for define in defines:
                cmd += ["-D", define]
            # Suppress warnings; treat as plain C
            cmd += ["-w", "-x", "c", str(source_file)]

            try:
                result = subprocess.run(
                    cmd, capture_output=True, text=True, timeout=60,
                )
            except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
                logger.warning("Cannot run preprocessor (%s): %s β€” reading file as-is", cc, exc)
                return source_file.read_text(encoding="utf-8", errors="replace")

            if result.returncode == 0 or result.stdout.strip():
                if stubbed:
                    # WARNING, not INFO: empty stubs drop the headers' macros /
                    # consts / types, so the preprocessed TU fed to the backend
                    # has altered semantics. A verdict on it should be auditable.
                    logger.warning(
                        "preprocess: stubbed %d unresolved header(s) for %s β€” "
                        "verdict rests on source with those definitions removed: %s",
                        len(stubbed), source_file.name,
                        ", ".join(sorted(set(stubbed))[:20]),
                    )
                return result.stdout

            hdr = _missing_header(result.stderr)
            if hdr is None or hdr in stubbed or len(stubbed) >= _MAX_HEADER_STUBS:
                logger.warning(
                    "Preprocessor failed for %s: %s", source_file, result.stderr[:200]
                )
                # Fall back to reading the file as-is.
                return source_file.read_text(encoding="utf-8", errors="replace")

            stub_path = stub_dir / hdr
            try:
                stub_path.parent.mkdir(parents=True, exist_ok=True)
                stub_path.write_text(
                    f"/* AMC: empty stub for unresolved header {hdr} */\n",
                    encoding="utf-8",
                )
            except OSError:
                # Can't materialize the stub (odd path) β€” give up cleanly.
                return source_file.read_text(encoding="utf-8", errors="replace")
            stubbed.append(hdr)
    finally:
        import shutil
        shutil.rmtree(stub_dir, ignore_errors=True)


# ---------------------------------------------------------------------------
# Step 2: strip system-header content
# ---------------------------------------------------------------------------

# Marker lines emitted by cc -E (without -P): # <lineno> "<path>" <flags>
# We use them to track which file lines belong to.  With -P they are absent,
# but system headers expand inline.  We detect system content by checking
# whether it looks like it came from standard paths.
#
# Without -P we get linemarkers; strip content from system paths.
# With -P we don't β€” fall back to heuristic stripping of known system decls.

_SYSTEM_PATHS = ("/usr/", "/lib/", "/opt/homebrew/", "/Applications/Xcode")
_LINEMARKER = re.compile(r'^# \d+ "([^"]+)"')


def _strip_system_content(source: str, original_file: Path) -> str:
    """Remove content that expanded from system headers."""
    lines = source.splitlines(keepends=True)

    # Check if preprocessor emitted linemarkers (happens without -P).
    has_markers = any(_LINEMARKER.match(l) for l in lines[:50])
    if has_markers:
        return _strip_by_linemarkers(lines, original_file)

    # -P was used (no markers): keep everything β€” the system headers already
    # provided only type declarations that CBMC needs.  Just remove duplicate
    # blank lines to keep the file manageable.
    return _collapse_blanks(source)


def _strip_by_linemarkers(lines: list[str], original_file: Path) -> str:
    in_user_file = True
    out: list[str] = []
    for line in lines:
        m = _LINEMARKER.match(line)
        if m:
            path = m.group(1)
            in_user_file = not any(path.startswith(sp) for sp in _SYSTEM_PATHS)
            continue  # don't emit the marker itself
        if in_user_file:
            out.append(line)
    return "".join(out)


def _collapse_blanks(source: str) -> str:
    return re.sub(r'\n{3,}', '\n\n', source)


# ---------------------------------------------------------------------------
# Step 3: strip GCC / ARM64 extensions
# ---------------------------------------------------------------------------

def _strip_attributes_nested(source: str) -> str:
    """Remove __attribute__((...)) handling nested parentheses correctly."""
    result = []
    i = 0
    n = len(source)
    marker = "__attribute__"
    mlen = len(marker)
    while i < n:
        if source[i:i+mlen] == marker:
            j = i + mlen
            # skip whitespace
            while j < n and source[j] in ' \t\n\r':
                j += 1
            if j < n and source[j] == '(':
                # consume the outer paren pair with nesting count
                depth = 0
                while j < n:
                    if source[j] == '(':
                        depth += 1
                    elif source[j] == ')':
                        depth -= 1
                        if depth == 0:
                            j += 1
                            break
                    j += 1
                i = j  # skip entire __attribute__((...))
            else:
                result.append(source[i])
                i += 1
        else:
            result.append(source[i])
            i += 1
    return ''.join(result)

_ATTR_RE = re.compile(r'__attribute__\s*\(.*?\)', re.DOTALL)  # fallback, unused
_DECLSPEC_RE = re.compile(r'__declspec\s*\([^)]*\)')
_TYPEOF_RE = re.compile(r'\btypeof\s*\(')
# __asm__ volatile ( "..." : ... : ... : ... ) or __asm__ ( "..." )
_ASM_RE = re.compile(
    r'__asm__\s*(?:volatile\s*)?\s*\([^;]*\)\s*;',
    re.DOTALL,
)
# register uint64_t x asm("reg") β€” ARM register variable
_REGVAR_RE = re.compile(
    r'\bregister\b([^;]+)\basm\s*\([^)]*\)\s*;',
)
# _Noreturn (C11 keyword CBMC may not handle)
_NORETURN_RE = re.compile(r'\b_Noreturn\b')
# __extension__
_EXTENSION_RE = re.compile(r'\b__extension__\b')
# __restrict / __restrict__
_RESTRICT_RE = re.compile(r'\b__restrict(?:__)?(\s)')
# __volatile__ (same as volatile)
_VOLATILE_RE = re.compile(r'\b__volatile__\b')
# __const__ (same as const)
_CONST_RE = re.compile(r'\b__const__\b')
# __signed__ (same as signed)
_SIGNED_RE = re.compile(r'\b__signed__\b')
# __inline__ / __inline (same as inline)
_INLINE_RE = re.compile(r'\b__inline(?:__)?\b')


def _strip_gcc_extensions(source: str) -> str:
    source = _strip_attributes_nested(source)
    source = _DECLSPEC_RE.sub('', source)
    source = _ASM_RE.sub(';', source)
    source = _REGVAR_RE.sub(r'/* register-asm variable removed */;', source)
    source = _NORETURN_RE.sub('', source)
    source = _EXTENSION_RE.sub('', source)
    source = _RESTRICT_RE.sub(r'\1', source)
    source = _VOLATILE_RE.sub('volatile', source)
    source = _CONST_RE.sub('const', source)
    source = _SIGNED_RE.sub('signed', source)
    source = _INLINE_RE.sub('inline', source)
    return source


# ---------------------------------------------------------------------------
# Step 4: prepend CBMC-friendly headers
# ---------------------------------------------------------------------------

_CBMC_PREAMBLE = """\
/* AMC preprocessor preamble β€” CBMC-friendly type definitions */
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <assert.h>
#ifndef NULL
#define NULL ((void*)0)
#endif
#ifndef true
#define true 1
#define false 0
#endif

"""


def _prepend_cbmc_headers(source: str) -> str:
    # Avoid duplicate preamble if preprocessing ran twice
    if "AMC preprocessor preamble" in source:
        return source
    return _CBMC_PREAMBLE + source