[llvm-branch-commits] [clang] [llvm] [clang][ssaf] Add clang-ssaf-driver (PR #232549)
Jan Korous via llvm-branch-commits
llvm-branch-commits at lists.llvm.org
Thu Oct 15 16:55:37 PDT 2026
https://github.com/jkorous-apple created https://github.com/llvm/llvm-project/pull/232549
Add a tool that runs a source transformation over a project as it builds
and presents the result. It knows which summaries and which
whole-program analysis each transformation needs, so a user names only
the transformation and the build command; cpp-bounded-buffers is the
first. It drives projects built with make through
clang-ssaf-orchestrator-make.
The driver writes the link unit's source edits as a patch, and prints
the transformation report the way a compiler prints diagnostics as well
as writing it as SARIF. A merge resolves only the conflicts among its
own inputs, so an edit that overlaps a reported conflict is left out of
the patch.
The tool is documented in
clang/docs/ScalableStaticAnalysis/user-docs/Driver.md. Unit tests cover
the internals, and lit tests run it over small make projects. Like the
orchestrator, it is neither built nor tested on Windows.
This is a generalization of a tool that we proposed earlier,
clang-reforge. We want to build a general-purpose analysis and
transformation driver decoupled from the specifics.
This relands a8797d63e97f (#231999), reverted with its stack in
782610a701b4 (#232274), without --apply, which needed
clang-apply-replacements from clang-tools-extra. The GN build files come
from 6abd4709cff4 (#232118).
Assisted-by: Claude
>From 601d80f46e52b81fff38cd7908fb9a9fe1b4ca58 Mon Sep 17 00:00:00 2001
From: Jan Korous <jkorous at apple.com>
Date: Thu, 15 Oct 2026 14:30:57 -0700
Subject: [PATCH] [clang][ssaf] Add clang-ssaf-driver
Add a tool that runs a source transformation over a project as it builds
and presents the result. It knows which summaries and which
whole-program analysis each transformation needs, so a user names only
the transformation and the build command; cpp-bounded-buffers is the
first. It drives projects built with make through
clang-ssaf-orchestrator-make.
The driver writes the link unit's source edits as a patch, and prints
the transformation report the way a compiler prints diagnostics as well
as writing it as SARIF. A merge resolves only the conflicts among its
own inputs, so an edit that overlaps a reported conflict is left out of
the patch.
The tool is documented in
clang/docs/ScalableStaticAnalysis/user-docs/Driver.md. Unit tests cover
the internals, and lit tests run it over small make projects. Like the
orchestrator, it is neither built nor tested on Windows.
This is a generalization of a tool that we proposed earlier,
clang-reforge. We want to build a general-purpose analysis and
transformation driver decoupled from the specifics.
This relands a8797d63e97f (#231999), reverted with its stack in
782610a701b4 (#232274), without --apply, which needed
clang-apply-replacements from clang-tools-extra. The GN build files come
from 6abd4709cff4 (#232118).
Assisted-by: Claude
---
.../user-docs/Driver.md | 94 +++++++
.../ssaf-driver/archive-conflict.test | 56 ++++
.../Scalable/ssaf-driver/bad-args.test | 12 +
.../Scalable/ssaf-driver/build-failure.test | 17 ++
.../Scalable/ssaf-driver/conflicts.test | 58 ++++
.../Analysis/Scalable/ssaf-driver/help.test | 26 ++
.../Scalable/ssaf-driver/lit.local.cfg | 93 +++++++
.../Analysis/Scalable/ssaf-driver/report.test | 48 ++++
.../Scalable/ssaf-driver/transformation.test | 72 +++++
.../Scalable/ssaf-driver/unittests.test | 8 +
clang/test/CMakeLists.txt | 1 +
clang/tools/CMakeLists.txt | 1 +
clang/tools/clang-ssaf-driver/CMakeLists.txt | 74 ++++++
clang/tools/clang-ssaf-driver/README.md | 34 +++
.../clang-ssaf-driver/bin/clang-ssaf-driver | 15 ++
.../lib/clang_ssaf_driver/__init__.py | 10 +
.../lib/clang_ssaf_driver/driver.py | 93 +++++++
.../clang_ssaf_driver/logging_diagnostics.py | 31 +++
.../clang_ssaf_driver/make_orchestrator.py | 64 +++++
.../lib/clang_ssaf_driver/options.py | 142 ++++++++++
.../lib/clang_ssaf_driver/patch.py | 129 +++++++++
.../lib/clang_ssaf_driver/report.py | 136 ++++++++++
.../lib/clang_ssaf_driver/source_edits.py | 150 +++++++++++
.../lib/clang_ssaf_driver/transformations.py | 18 ++
.../lib/clang_ssaf_driver/version.py.in | 5 +
.../tools/clang-ssaf-driver/tests/__init__.py | 3 +
.../clang-ssaf-driver/tests/test_driver.py | 123 +++++++++
.../tests/test_make_orchestrator.py | 137 ++++++++++
.../clang-ssaf-driver/tests/test_options.py | 72 +++++
.../clang-ssaf-driver/tests/test_patch.py | 249 ++++++++++++++++++
.../clang-ssaf-driver/tests/test_report.py | 204 ++++++++++++++
.../tests/test_source_edits.py | 141 ++++++++++
llvm/utils/gn/secondary/clang/test/BUILD.gn | 1 +
.../clang/tools/clang-ssaf-driver/BUILD.gn | 40 +++
34 files changed, 2357 insertions(+)
create mode 100644 clang/docs/ScalableStaticAnalysis/user-docs/Driver.md
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/bad-args.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/build-failure.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/conflicts.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/help.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/report.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/transformation.test
create mode 100644 clang/test/Analysis/Scalable/ssaf-driver/unittests.test
create mode 100644 clang/tools/clang-ssaf-driver/CMakeLists.txt
create mode 100644 clang/tools/clang-ssaf-driver/README.md
create mode 100755 clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py
create mode 100644 clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in
create mode 100644 clang/tools/clang-ssaf-driver/tests/__init__.py
create mode 100644 clang/tools/clang-ssaf-driver/tests/test_driver.py
create mode 100644 clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py
create mode 100644 clang/tools/clang-ssaf-driver/tests/test_options.py
create mode 100644 clang/tools/clang-ssaf-driver/tests/test_patch.py
create mode 100644 clang/tools/clang-ssaf-driver/tests/test_report.py
create mode 100644 clang/tools/clang-ssaf-driver/tests/test_source_edits.py
create mode 100644 llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn
diff --git a/clang/docs/ScalableStaticAnalysis/user-docs/Driver.md b/clang/docs/ScalableStaticAnalysis/user-docs/Driver.md
new file mode 100644
index 00000000000000..5a02bf7818eca5
--- /dev/null
+++ b/clang/docs/ScalableStaticAnalysis/user-docs/Driver.md
@@ -0,0 +1,94 @@
+# Transforming a Project
+
+`clang-ssaf-driver` runs a source transformation over a whole project as it
+builds, and presents what it found: the source edits as a patch, and the
+transformation report as diagnostics and as SARIF. It drives projects built
+with make through `clang-ssaf-orchestrator-make`, whose requirements it
+shares; see {doc}`MakeProjects`.
+
+## Usage
+
+Run it from the project root, with its options before the build command:
+
+```console
+$ clang-ssaf-driver --transformation=cpp-bounded-buffers -- make -j8
+```
+
+The driver knows which summaries and which whole-program analysis each
+transformation needs:
+
+```{eval-rst}
+.. list-table::
+ :header-rows: 1
+
+ * - Transformation
+ - What it does
+ * - ``cpp-bounded-buffers``
+ - Rewrites the raw pointers and arrays that need bounds into
+ ``bounded_ptr`` and ``bounded_array``.
+```
+
+## Results
+
+A run writes two files into the output directory:
+
+- `<transformation>.patch`, the source edits as one unified diff whose paths
+ are relative to the project root, for `patch -p1` from there;
+- `<transformation>.sarif`, the transformation report: the findings of every
+ translation unit, and every conflict between source edits.
+
+Both are named on stdout, after the orchestrator's own results:
+
+```text
+patch: /work/proj/ssaf-output/cpp-bounded-buffers.patch
+report: /work/proj/ssaf-output/cpp-bounded-buffers.sarif
+```
+
+The report is also printed on stderr, one line per finding the way a compiler
+prints a diagnostic, and closes with a count for each rule:
+
+```text
+src/shared.h:1:8: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+src/shared.h:3:1: error: 2 overlapping replacement(s) at file:///work/proj/src/shared.h byte 20 were dropped; resolve manually. [clang-reforge-replacement-conflict]
+src/shared.h:5:1: note: candidate edit: "bounded_ptr<int> "
+src/shared.h:3:1: note: candidate edit: "bounded_ptr<long> "
+1 clang-reforge-replacement-conflict, 1 cpp-bounded-buffers-skipped reported
+```
+
+A finding is a place the transformation did not rewrite. A conflict is a place
+where translation units asked for different edits; none of them is made, and
+an edit that overlaps a conflict is left out of the patch too, with a warning.
+Both are left to be done by hand, and neither changes the exit status.
+
+## Options
+
+```{eval-rst}
+.. list-table::
+ :header-rows: 1
+
+ * - Option
+ - Purpose
+ * - ``--transformation=<name>``
+ - The transformation to run. Required.
+ * - ``-o``, ``--output-dir=<dir>``
+ - Where the results and the SSAF artifacts go. Defaults to
+ ``ssaf-output``, and is the orchestrator's output directory.
+ * - ``--ssaf-clang=<path>``, ``--ssaf-linker=<path>``,
+ ``--ssaf-analyzer=<path>``, ``--ssaf-src-edit-merge=<path>``,
+ ``--ssaf-report-merge=<path>``,
+ ``--ssaf-conflict-merge=<path>``,
+ ``--ssaf-extra-clang-arg=<arg>``, ``-k``, ``-v``
+ - Passed to the orchestrator; see {doc}`MakeProjects`.
+ * - ``--version``
+ - The clang version this was built from.
+```
+
+## What is not supported
+
+**Edits outside the project root.** A file the project does not contain is
+left out of the patch and is not applied to; the driver names it in a warning.
+
+**Compilations that run in another directory**, as in recursive make or
+`make -C`. A source edit names its file relative to where its compilation ran,
+and is merged where the link runs, so such an edit can be lost or land on the
+wrong file. Run the build from the project root.
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test b/clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test
new file mode 100644
index 00000000000000..4ecb37c0a20b9d
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/archive-conflict.test
@@ -0,0 +1,56 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# The two halves of the archive disagree on the header, so the archive's merge
+# drops both of their edits to it. main.cpp asks for one of them again, and
+# the link's own merge keeps it; the driver leaves it out, because it
+# overlaps a reported conflict.
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN: --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN: 2> %t/stderr.txt > /dev/null
+# RUN: FileCheck %s --check-prefix=STDERR --input-file=%t/stderr.txt
+# STDERR: src/shared.h:3:1: error: 2 overlapping replacement(s) at {{.*}} were dropped; resolve manually. [clang-reforge-replacement-conflict]
+# STDERR: clang-ssaf-driver: warning: left out 1 edit(s) to src/shared.h that overlap a reported conflict
+
+# RUN: FileCheck %s --check-prefix=PATCH \
+# RUN: --input-file=%t/out/cpp-bounded-buffers.patch
+# PATCH: +++ b/src/a.cpp
+# PATCH: +bounded_ptr<int> g;
+# PATCH-NOT: shared.h
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp src/shared.h
+ $(CXX) -c -o $@ $<
+build/libparts.a: build/a.o build/b.o
+ ar rcs $@ build/a.o build/b.o
+build/prg: build/main.o build/libparts.a
+ $(CXX) -o $@ build/main.o build/libparts.a
+
+#--- src/shared.h
+extern
+#if USE_LONG
+long
+#else
+int
+#endif
+*g;
+
+#--- src/a.cpp
+#include "shared.h"
+int *g;
+int a() { return g[5]; }
+
+#--- src/b.cpp
+#define USE_LONG 1
+#include "shared.h"
+long b() { return g[7]; }
+
+#--- src/main.cpp
+#include "shared.h"
+int a();
+long b();
+int main() { return a() + (int)b(); }
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/bad-args.test b/clang/test/Analysis/Scalable/ssaf-driver/bad-args.test
new file mode 100644
index 00000000000000..f17bfb8bf1e424
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/bad-args.test
@@ -0,0 +1,12 @@
+
+# RUN: not %clang-ssaf-driver -- make 2>&1 \
+# RUN: | FileCheck %s --check-prefix=NO-TRANSFORMATION
+# NO-TRANSFORMATION: error: the following arguments are required: --transformation
+
+# RUN: not %clang-ssaf-driver --transformation=no-such -- make 2>&1 \
+# RUN: | FileCheck %s --check-prefix=UNKNOWN
+# UNKNOWN: error: argument --transformation: invalid choice: 'no-such' {{.*}}cpp-bounded-buffers
+
+# RUN: not %clang-ssaf-driver --transformation=cpp-bounded-buffers 2>&1 \
+# RUN: | FileCheck %s --check-prefix=NO-BUILD
+# NO-BUILD: clang-ssaf-driver: error: no build command given
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/build-failure.test b/clang/test/Analysis/Scalable/ssaf-driver/build-failure.test
new file mode 100644
index 00000000000000..732ea9b5bf26da
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/build-failure.test
@@ -0,0 +1,17 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+
+# A failing build fails the run with the orchestrator's status, and leaves
+# neither a patch nor a report.
+# RUN: cd %t
+# RUN: not %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN: --ssaf-clang=%ssaf-driver-clang -o %t/out -- make 2>&1 \
+# RUN: | FileCheck %s
+# CHECK: clang-ssaf-orchestrator-make: error: build failed (exit 2)
+# CHECK: clang-ssaf-driver: error: clang-ssaf-orchestrator-make failed (exit 2)
+# RUN: not ls %t/out/cpp-bounded-buffers.patch
+# RUN: not ls %t/out/cpp-bounded-buffers.sarif
+
+#--- Makefile
+all:
+ false
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/conflicts.test b/clang/test/Analysis/Scalable/ssaf-driver/conflicts.test
new file mode 100644
index 00000000000000..ea98af9a386dfe
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/conflicts.test
@@ -0,0 +1,58 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# The two translation units see different declarations of g in the header,
+# so their edits to it conflict and neither is applied. The conflict is
+# reported with both candidates, and the rest of the edits are still made.
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN: --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN: 2> %t/stderr.txt > /dev/null
+
+# RUN: FileCheck %s --check-prefix=STDERR --input-file=%t/stderr.txt
+# STDERR: src/shared.h:3:1: error: 2 overlapping replacement(s) at file://{{.*}}/src/shared.h byte 20 were dropped; resolve manually. [clang-reforge-replacement-conflict]
+# STDERR-DAG: src/shared.h:3:1: note: candidate edit: "bounded_ptr<long> "
+# STDERR-DAG: src/shared.h:5:1: note: candidate edit: "bounded_ptr<int> "
+# STDERR: 1 clang-reforge-replacement-conflict reported
+
+# RUN: FileCheck %s --check-prefix=SARIF \
+# RUN: --input-file=%t/out/cpp-bounded-buffers.sarif
+# SARIF: "name": "clang-ssaf",
+# SARIF: "byteOffset": 20
+# SARIF: "ruleId": "clang-reforge-replacement-conflict"
+# SARIF: "name": "clang-ssaf-src-edit-merge",
+
+# RUN: FileCheck %s --check-prefix=PATCH \
+# RUN: --input-file=%t/out/cpp-bounded-buffers.patch
+# PATCH: +++ b/src/a.cpp
+# PATCH: +bounded_ptr<int> g;
+# PATCH-NOT: shared.h
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp src/shared.h
+ $(CXX) -c -o $@ $<
+build/prg: build/a.o build/b.o
+ $(CXX) -o $@ build/a.o build/b.o
+
+#--- src/shared.h
+extern
+#if USE_LONG
+long
+#else
+int
+#endif
+*g;
+
+#--- src/a.cpp
+#include "shared.h"
+int *g;
+long b();
+int main() { return g[5] + (int)b(); }
+
+#--- src/b.cpp
+#define USE_LONG 1
+#include "shared.h"
+long b() { return g[7]; }
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/help.test b/clang/test/Analysis/Scalable/ssaf-driver/help.test
new file mode 100644
index 00000000000000..96857ce9e5fd22
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/help.test
@@ -0,0 +1,26 @@
+
+# RUN: %clang-ssaf-driver --help | FileCheck %s
+
+# CHECK: usage: clang-ssaf-driver [options] --transformation=<name> [--] <build command>
+# CHECK: --version
+# CHECK: --transformation <name>
+# CHECK: --output-dir
+# CHECK: --ssaf-clang
+# CHECK: --ssaf-linker
+# CHECK: --ssaf-analyzer
+# CHECK: --ssaf-src-edit-merge
+# CHECK: --ssaf-report-merge
+# CHECK: --ssaf-conflict-merge
+# CHECK: --ssaf-extra-clang-arg
+# CHECK: --keep-going
+# CHECK: --verbose
+
+# RUN: %clang-ssaf-driver --version | FileCheck %s --check-prefix=VERSION
+# VERSION: clang-ssaf-driver {{[0-9]+\.[0-9]+\.[0-9]+}}
+
+# The tool imports its own package, whatever else of that name PYTHONPATH holds.
+# RUN: rm -rf %t && mkdir -p %t/clang_ssaf_driver
+# RUN: echo 'raise SystemExit("a decoy was imported")' \
+# RUN: > %t/clang_ssaf_driver/__init__.py
+# RUN: env PYTHONPATH=%t %clang-ssaf-driver --version \
+# RUN: | FileCheck %s --check-prefix=VERSION
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg b/clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg
new file mode 100644
index 00000000000000..10c9b7e10b3802
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/lit.local.cfg
@@ -0,0 +1,93 @@
+# -*- Python -*-
+
+import lit.util
+import os
+import platform
+import subprocess
+import tempfile
+
+if platform.system() == "Windows":
+ config.unsupported = True
+
+
+def _run(command, stdin=None):
+ try:
+ return subprocess.run(
+ command,
+ input=stdin,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ env=config.environment,
+ )
+ except OSError:
+ return None
+
+
+def _succeeds(command, stdin=None):
+ done = _run(command, stdin)
+ return done is not None and done.returncode == 0
+
+
+# The tests build projects with make, so they need GNU make, and a clang and
+# an ar that compile, archive and link C++ programs for the host.
+def _can_build_projects():
+ path = config.environment["PATH"]
+ make = lit.util.which("make", path)
+ version = _run([make, "--version"]) if make else None
+ if not version or version.returncode or not version.stdout.startswith(b"GNU Make"):
+ return False
+ ar = lit.util.which("ar", path)
+ with tempfile.TemporaryDirectory() as tmp:
+ obj = os.path.join(tmp, "a.o")
+ cxx = [config.clang, "--driver-mode=g++"]
+ main = b"int main() { return 0; }\n"
+ return (
+ ar is not None
+ and _succeeds(cxx + ["-x", "c++", "-", "-c", "-o", obj], main)
+ and _succeeds([ar, "rcs", os.path.join(tmp, "liba.a"), obj])
+ and _succeeds(cxx + [obj, "-o", os.path.join(tmp, "a.out")])
+ )
+
+
+if "native" in config.available_features and _can_build_projects():
+ config.available_features.add("ssaf-driver-make-build")
+
+# GNU and BSD patch answer --version and take the options the tests use.
+if _succeeds(["patch", "--version"]):
+ config.available_features.add("patch")
+
+config.substitutions.append(
+ (
+ "%clang-ssaf-driver",
+ '"%s" "%s"'
+ % (
+ config.python_executable,
+ os.path.join(config.llvm_tools_dir, "clang-ssaf-driver"),
+ ),
+ )
+)
+
+config.substitutions.append(
+ ("%ssaf-driver-python", '"%s"' % config.python_executable)
+)
+
+config.substitutions.append(
+ (
+ "%ssaf-driver-libs",
+ os.path.join(os.path.dirname(config.llvm_tools_dir), "lib"),
+ )
+)
+
+config.substitutions.append(
+ (
+ "%ssaf-driver-src",
+ os.path.join(config.clang_src_dir, "tools", "clang-ssaf-driver"),
+ )
+)
+
+config.substitutions.append(
+ (
+ "%ssaf-driver-clang",
+ config.clang if config.have_llvm_driver else os.path.realpath(config.clang),
+ )
+)
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/report.test b/clang/test/Analysis/Scalable/ssaf-driver/report.test
new file mode 100644
index 00000000000000..979eebda5e5a40
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/report.test
@@ -0,0 +1,48 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# Both translation units report the declaration group in the header; the
+# report has each of its declarators once, after main.cpp's own group.
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN: --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN: 2> %t/stderr.txt | FileCheck %s --check-prefix=STDOUT
+# STDOUT: report: {{[^ ]*}}/out/cpp-bounded-buffers.sarif
+
+# RUN: FileCheck %s --check-prefix=STDERR --input-file=%t/stderr.txt
+# STDERR: src/main.cpp:2:1: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: src/main.cpp:2:1: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: src/shared.h:1:8: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: src/shared.h:1:8: note: declarator of a multi-declarator group is not yet rewritten [cpp-bounded-buffers-skipped]
+# STDERR-NEXT: 4 cpp-bounded-buffers-skipped reported
+
+# The report holds the same findings, and the patch changes nothing.
+# RUN: FileCheck %s --check-prefix=SARIF \
+# RUN: --input-file=%t/out/cpp-bounded-buffers.sarif
+# RUN: grep 'multi-declarator group' %t/out/cpp-bounded-buffers.sarif | count 4
+# RUN: count 0 < %t/out/cpp-bounded-buffers.patch
+# SARIF: "runs": [
+# SARIF: "name": "clang-ssaf",
+# SARIF: "name": "clang-ssaf-src-edit-merge",
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp src/shared.h
+ $(CXX) -c -o $@ $<
+build/prg: build/main.o build/use.o
+ $(CXX) -o $@ build/main.o build/use.o
+
+#--- src/shared.h
+extern int *p, *q;
+
+#--- src/use.cpp
+#include "shared.h"
+int use() { return p[1] + q[1]; }
+
+#--- src/main.cpp
+#include "shared.h"
+int *p, *q;
+int use();
+int main() { return use(); }
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/transformation.test b/clang/test/Analysis/Scalable/ssaf-driver/transformation.test
new file mode 100644
index 00000000000000..a51008e2335f16
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/transformation.test
@@ -0,0 +1,72 @@
+# REQUIRES: ssaf-driver-make-build
+# RUN: rm -rf %t && split-file %s %t
+# RUN: mkdir -p %t/build
+
+# RUN: cd %t
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN: --ssaf-clang=%ssaf-driver-clang -o %t/out -- make \
+# RUN: | FileCheck %s --check-prefix=STDOUT
+# STDOUT: source-edits: {{[^ ]*}}/out/ssaf-artifacts{{[^ ]*}}/build/prg.edits.yaml
+# STDOUT: patch: {{[^ ]*}}/out/cpp-bounded-buffers.patch
+# STDOUT: report: {{[^ ]*}}/out/cpp-bounded-buffers.sarif
+
+# RUN: diff %t/expected.patch %t/out/cpp-bounded-buffers.patch
+
+# The sources are left as they were.
+# RUN: FileCheck %s --check-prefix=UNCHANGED --input-file=%t/src/parse.cpp
+# UNCHANGED: int total(int *data, int n) {
+
+# A second run into the same output directory starts afresh.
+# RUN: %clang-ssaf-driver --transformation=cpp-bounded-buffers \
+# RUN: --ssaf-clang=%ssaf-driver-clang -o %t/out -- make > %t/second.txt
+# RUN: diff %t/expected.patch %t/out/cpp-bounded-buffers.patch
+
+# The patch applies to the sources.
+# RUN: %if patch %{ rm -rf %t/copy && mkdir -p %t/copy && cp -R %t/src %t/copy %}
+# RUN: %if patch %{ patch -s -p1 -d %t/copy < %t/out/cpp-bounded-buffers.patch %}
+# RUN: %if patch %{ FileCheck %s --check-prefix=PATCHED --input-file=%t/copy/src/parse.cpp %}
+# PATCHED: int total(bounded_ptr<int> data, int n) {
+
+#--- Makefile
+CXX = clang++
+all: build/prg
+build/%.o: src/%.cpp
+ $(CXX) -c -o $@ $<
+build/prg: build/main.o build/parse.o
+ $(CXX) -o $@ build/main.o build/parse.o
+
+#--- src/parse.cpp
+int total(int *data, int n) {
+ int t = 0;
+ for (int i = 0; i < n; ++i)
+ t += data[i];
+ return t;
+}
+
+#--- src/main.cpp
+int total(int *data, int n);
+int main() {
+ int values[4] = {1, 2, 3, 4};
+ return total(values, 4);
+}
+
+#--- expected.patch
+--- a/src/main.cpp
++++ b/src/main.cpp
+@@ -1,6 +1,6 @@
+-int total(int *data, int n);
++int total(bounded_ptr<int> data, int n);
+ int main() {
+- int values[4] = {1, 2, 3, 4};
++ bounded_array<int, 4> values = {1, 2, 3, 4};
+ return total(values, 4);
+ }
+
+--- a/src/parse.cpp
++++ b/src/parse.cpp
+@@ -1,4 +1,4 @@
+-int total(int *data, int n) {
++int total(bounded_ptr<int> data, int n) {
+ int t = 0;
+ for (int i = 0; i < n; ++i)
+ t += data[i];
diff --git a/clang/test/Analysis/Scalable/ssaf-driver/unittests.test b/clang/test/Analysis/Scalable/ssaf-driver/unittests.test
new file mode 100644
index 00000000000000..60ab238f41cb17
--- /dev/null
+++ b/clang/test/Analysis/Scalable/ssaf-driver/unittests.test
@@ -0,0 +1,8 @@
+
+# RUN: env PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=%ssaf-driver-libs \
+# RUN: %ssaf-driver-python -m unittest discover \
+# RUN: -s %ssaf-driver-src/tests -t %ssaf-driver-src \
+# RUN: > %t.log 2>&1
+# RUN: FileCheck %s --input-file=%t.log --implicit-check-not=FAILED
+
+# CHECK: {{^OK}}
diff --git a/clang/test/CMakeLists.txt b/clang/test/CMakeLists.txt
index e96711ffbe2cdf..2ef2d1024fdefc 100644
--- a/clang/test/CMakeLists.txt
+++ b/clang/test/CMakeLists.txt
@@ -117,6 +117,7 @@ list(APPEND CLANG_TEST_DEPS
if(NOT WIN32)
list(APPEND CLANG_TEST_DEPS
+ clang-ssaf-driver
clang-ssaf-orchestrator-make
)
endif()
diff --git a/clang/tools/CMakeLists.txt b/clang/tools/CMakeLists.txt
index c7369b18c06394..2b0d706185902c 100644
--- a/clang/tools/CMakeLists.txt
+++ b/clang/tools/CMakeLists.txt
@@ -20,6 +20,7 @@ add_clang_subdirectory(clang-ssaf-conflict-merge)
add_clang_subdirectory(clang-ssaf-format)
add_clang_subdirectory(clang-ssaf-linker)
if(NOT WIN32)
+ add_clang_subdirectory(clang-ssaf-driver)
add_clang_subdirectory(clang-ssaf-orchestrator-make)
endif()
add_clang_subdirectory(clang-ssaf-report-merge)
diff --git a/clang/tools/clang-ssaf-driver/CMakeLists.txt b/clang/tools/clang-ssaf-driver/CMakeLists.txt
new file mode 100644
index 00000000000000..d805805f171b45
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/CMakeLists.txt
@@ -0,0 +1,74 @@
+set(SsafDriverLib
+ "__init__.py"
+ "driver.py"
+ "logging_diagnostics.py"
+ "make_orchestrator.py"
+ "options.py"
+ "patch.py"
+ "report.py"
+ "source_edits.py"
+ "transformations.py")
+
+set(SsafDriverPackageDir "lib/clang_ssaf_driver")
+
+# Copy one file into the build tree and install it, recording the copy so the
+# tool's target can depend on it. kind is FILES or PROGRAMS.
+function(add_ssaf_driver_file source build_dir destination kind)
+ get_filename_component(name "${source}" NAME)
+ set(copied "${LLVM_BINARY_DIR}/${build_dir}/${name}")
+ add_custom_command(OUTPUT "${copied}"
+ COMMAND ${CMAKE_COMMAND} -E make_directory
+ "${LLVM_BINARY_DIR}/${build_dir}"
+ COMMAND ${CMAKE_COMMAND} -E copy
+ "${CMAKE_CURRENT_SOURCE_DIR}/${source}" "${copied}"
+ DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/${source}")
+ set(SSAF_DRIVER_DEPENDS ${SSAF_DRIVER_DEPENDS} "${copied}" PARENT_SCOPE)
+ install(${kind} "${source}"
+ DESTINATION "${destination}"
+ COMPONENT clang-ssaf-driver)
+endfunction()
+
+add_ssaf_driver_file("bin/clang-ssaf-driver"
+ "bin" "${CMAKE_INSTALL_BINDIR}" PROGRAMS)
+
+foreach(lib ${SsafDriverLib})
+ add_ssaf_driver_file("${SsafDriverPackageDir}/${lib}"
+ "${SsafDriverPackageDir}" "${SsafDriverPackageDir}" FILES)
+endforeach()
+
+# Substituted at configure time into this directory's own build area, then
+# copied like every other file, so that deleting the package from the build
+# tree leaves a rule to put it back.
+configure_file(
+ "${CMAKE_CURRENT_SOURCE_DIR}/${SsafDriverPackageDir}/version.py.in"
+ "${CMAKE_CURRENT_BINARY_DIR}/version.py")
+set(version_py "${LLVM_BINARY_DIR}/${SsafDriverPackageDir}/version.py")
+add_custom_command(OUTPUT "${version_py}"
+ COMMAND ${CMAKE_COMMAND} -E make_directory
+ "${LLVM_BINARY_DIR}/${SsafDriverPackageDir}"
+ COMMAND ${CMAKE_COMMAND} -E copy
+ "${CMAKE_CURRENT_BINARY_DIR}/version.py" "${version_py}"
+ DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/version.py")
+list(APPEND SSAF_DRIVER_DEPENDS "${version_py}")
+install(FILES "${CMAKE_CURRENT_BINARY_DIR}/version.py"
+ DESTINATION "${SsafDriverPackageDir}"
+ COMPONENT clang-ssaf-driver)
+
+add_custom_target(clang-ssaf-driver ALL DEPENDS ${SSAF_DRIVER_DEPENDS})
+set_target_properties(clang-ssaf-driver PROPERTIES FOLDER "Clang/Tools")
+if(NOT LLVM_ENABLE_IDE)
+ add_llvm_install_targets("install-clang-ssaf-driver"
+ DEPENDS clang-ssaf-driver
+ COMPONENT clang-ssaf-driver)
+endif()
+
+# The driver runs the orchestrator installed beside it.
+if(CLANG_TOOL_CLANG_SSAF_ORCHESTRATOR_MAKE_BUILD)
+ add_dependencies(clang-ssaf-driver clang-ssaf-orchestrator-make)
+ if(NOT LLVM_ENABLE_IDE)
+ add_dependencies(install-clang-ssaf-driver
+ install-clang-ssaf-orchestrator-make)
+ add_dependencies(install-clang-ssaf-driver-stripped
+ install-clang-ssaf-orchestrator-make-stripped)
+ endif()
+endif()
diff --git a/clang/tools/clang-ssaf-driver/README.md b/clang/tools/clang-ssaf-driver/README.md
new file mode 100644
index 00000000000000..7071a2a39e0c5d
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/README.md
@@ -0,0 +1,34 @@
+# clang-ssaf-driver
+
+Runs a Scalable Static Analysis Framework source transformation over a project
+as it builds, and presents the result: the source edits as a patch, and the
+transformation report as diagnostics and as SARIF. Projects built with make
+are driven through `clang-ssaf-orchestrator-make`, installed beside it.
+
+- Using it: [Transforming a Project](
+ ../../docs/ScalableStaticAnalysis/user-docs/Driver.md)
+
+## Layout
+
+| | |
+|---|---|
+| `bin/` | the entry script, installed to `bin/` |
+| `lib/clang_ssaf_driver/` | the package, installed to `lib/` |
+| `tests/` | the Python unit tests |
+
+## Running the tests
+
+```
+PYTHONPATH=lib python3 -m unittest discover # from this directory
+llvm-lit clang/test/Analysis/Scalable/ssaf-driver
+```
+
+The lit suite runs the unit tests too, against the copy in the build tree, so
+`ninja check-clang` covers both. The tool is a declared dependency of
+`check-clang`.
+
+## Packaging
+
+CMake copies the Python files into the build tree and installs them under the
+`clang-ssaf-driver` component, and installing it installs the orchestrator's
+component too.
diff --git a/clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver b/clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver
new file mode 100755
index 00000000000000..b21f4000e684d4
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/bin/clang-ssaf-driver
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import os.path
+import sys
+
+this_dir = os.path.dirname(os.path.realpath(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(this_dir), "lib"))
+
+from clang_ssaf_driver import driver
+
+if __name__ == "__main__":
+ sys.exit(driver.main(sys.argv[1:]))
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py
new file mode 100644
index 00000000000000..5b5a2956e2697d
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/__init__.py
@@ -0,0 +1,10 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+PROGRAM = "clang-ssaf-driver"
+
+try:
+ from .version import VERSION
+except ImportError:
+ VERSION = "unknown (not built)"
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py
new file mode 100644
index 00000000000000..8cffb6673a870d
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/driver.py
@@ -0,0 +1,93 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import os
+
+from . import (
+ logging_diagnostics,
+ make_orchestrator,
+ options,
+ patch,
+ report,
+ source_edits,
+)
+from .transformations import TRANSFORMATIONS
+
+
+def run(command_line_opts):
+ orchestrator = make_orchestrator.find_orchestrator()
+ if not os.path.isfile(orchestrator):
+ return options.Problem(
+ options.ENVIRONMENT_ERROR,
+ "cannot find %s: %s" % (make_orchestrator.ORCHESTRATOR, orchestrator),
+ )
+ project_root = os.path.realpath(os.getcwd())
+ output_dir = os.path.realpath(command_line_opts.output_dir)
+ status, results = make_orchestrator.run_orchestrator(
+ make_orchestrator.orchestrator_command(
+ orchestrator,
+ TRANSFORMATIONS[command_line_opts.transformation],
+ command_line_opts,
+ ),
+ command_line_opts.verbose,
+ )
+ if status != 0:
+ return options.Problem(
+ status, "%s failed (exit %d)" % (make_orchestrator.ORCHESTRATOR, status)
+ )
+ if "source-edits" not in results:
+ return options.Problem(
+ options.ENVIRONMENT_ERROR,
+ "%s reported no source edits" % make_orchestrator.ORCHESTRATOR,
+ )
+
+ replacements, problem = source_edits.read_source_edits(results["source-edits"])
+ if problem is not None:
+ return problem
+ log, problem = report.load(
+ [results.get("transformation-report"), results.get("conflict-report")]
+ )
+ if problem is not None:
+ return problem
+ collected, problem = patch.collect(
+ replacements, project_root, report.conflict_ranges(log)
+ )
+ if problem is not None:
+ return problem
+
+ for line in report.diagnostics(log, project_root):
+ logging_diagnostics.report_finding(line)
+ for path in collected.outside:
+ logging_diagnostics.report_warning(
+ "edits to a file outside the project were left out: %s" % path
+ )
+ for relative_path, count in collected.blocked:
+ logging_diagnostics.report_warning(
+ "left out %d edit(s) to %s that overlap a reported conflict"
+ % (count, relative_path)
+ )
+
+ patch_path = os.path.join(output_dir, command_line_opts.transformation + ".patch")
+ report_path = os.path.join(output_dir, command_line_opts.transformation + ".sarif")
+ try:
+ with open(patch_path, "wb") as handle:
+ handle.write(patch.render(collected.files))
+ report.write(report_path, log)
+ except OSError as error:
+ return options.Problem(
+ options.ENVIRONMENT_ERROR, "cannot write the results: %s" % error
+ )
+ logging_diagnostics.report_info("patch", patch_path)
+ logging_diagnostics.report_info("report", report_path)
+ return None
+
+
+def main(argv=None):
+ command_line_opts, problem = options.resolve(argv)
+ if problem is None:
+ problem = run(command_line_opts)
+ if problem is not None:
+ logging_diagnostics.report_error(problem.message, problem.details)
+ return problem.exit_status
+ return 0
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py
new file mode 100644
index 00000000000000..c5365d4e35126f
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/logging_diagnostics.py
@@ -0,0 +1,31 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import sys
+
+from . import PROGRAM
+
+
+def report_note(verbose, message):
+ if verbose:
+ sys.stderr.write("%s: note: %s\n" % (PROGRAM, message))
+
+
+def report_warning(message):
+ sys.stderr.write("%s: warning: %s\n" % (PROGRAM, message))
+
+
+def report_error(message, details=()):
+ sys.stderr.write("%s: error: %s\n" % (PROGRAM, message))
+ for detail in details:
+ sys.stderr.write(" %s\n" % detail)
+
+
+def report_info(key, value):
+ sys.stdout.write("%s: %s\n" % (key, value))
+ sys.stdout.flush()
+
+
+def report_finding(text):
+ sys.stderr.write(text + "\n")
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py
new file mode 100644
index 00000000000000..0f5190280e8e50
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/make_orchestrator.py
@@ -0,0 +1,64 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import os
+import subprocess
+import sys
+
+from . import logging_diagnostics
+
+ORCHESTRATOR = "clang-ssaf-orchestrator-make"
+
+RESULT_KEYS = frozenset(["source-edits", "transformation-report", "conflict-report"])
+
+
+def find_orchestrator():
+ package_dir = os.path.dirname(os.path.realpath(__file__))
+ prefix = os.path.dirname(os.path.dirname(package_dir))
+ return os.path.join(prefix, "bin", ORCHESTRATOR)
+
+
+def orchestrator_command(orchestrator, transformation, command_line_opts):
+ return (
+ [
+ sys.executable,
+ orchestrator,
+ "--extract-summaries=" + transformation.extract_summaries,
+ "--analysis=" + transformation.analysis,
+ "--transformation=" + transformation.transformation,
+ "--output-dir=" + command_line_opts.output_dir,
+ ]
+ + command_line_opts.orchestrator_args
+ + ["--"]
+ + command_line_opts.build_command
+ )
+
+
+def exit_status(return_code):
+ return 128 - return_code if return_code < 0 else return_code
+
+
+def read_result(line):
+ key, separator, value = line.rstrip(b"\n").partition(b": ")
+ key = key.decode("ascii", "replace")
+ if not separator or key not in RESULT_KEYS:
+ return None
+ return key, os.fsdecode(value)
+
+
+def run_orchestrator(command, verbose, relay=None):
+ relay = relay or sys.stdout.buffer
+ logging_diagnostics.report_note(verbose, "running: %s" % " ".join(command))
+ results = {}
+ with subprocess.Popen(command, stdout=subprocess.PIPE) as process:
+ try:
+ for line in process.stdout:
+ relay.write(line)
+ relay.flush()
+ result = read_result(line)
+ if result:
+ results[result[0]] = result[1]
+ except KeyboardInterrupt:
+ pass
+ return exit_status(process.returncode), results
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py
new file mode 100644
index 00000000000000..82f5f8ff436b7e
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/options.py
@@ -0,0 +1,142 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import argparse
+import collections
+
+from . import PROGRAM, VERSION
+from .transformations import TRANSFORMATIONS
+
+Problem = collections.namedtuple(
+ "Problem", "exit_status message details", defaults=((),)
+)
+
+USAGE_ERROR = 2
+ENVIRONMENT_ERROR = 1
+
+FORWARDED_TOOLS = (
+ (
+ "--ssaf-clang",
+ "SSAF-capable clang used for the SSAF compilations (default: clang on PATH).",
+ ),
+ ("--ssaf-linker", "Path to clang-ssaf-linker (default: found on PATH)."),
+ ("--ssaf-analyzer", "Path to clang-ssaf-analyzer (default: found on PATH)."),
+ (
+ "--ssaf-src-edit-merge",
+ "Path to clang-ssaf-src-edit-merge (default: found on PATH).",
+ ),
+ (
+ "--ssaf-report-merge",
+ "Path to clang-ssaf-report-merge (default: found on PATH).",
+ ),
+ (
+ "--ssaf-conflict-merge",
+ "Path to clang-ssaf-conflict-merge (default: found on PATH).",
+ ),
+)
+
+
+def build_parser():
+ parser = argparse.ArgumentParser(
+ prog=PROGRAM,
+ usage="%(prog)s [options] --transformation=<name> [--] <build command>",
+ description="Run an SSAF transformation over a project as it builds. "
+ "The source edits are written as a patch, and the transformation report "
+ "is printed and written as SARIF.",
+ )
+ parser.add_argument("--version", action="version", version="%(prog)s " + VERSION)
+ parser.add_argument(
+ "--transformation",
+ metavar="<name>",
+ required=True,
+ choices=sorted(TRANSFORMATIONS),
+ help="The transformation to run, one of: %s. Required."
+ % ", ".join(sorted(TRANSFORMATIONS)),
+ )
+ parser.add_argument(
+ "-o",
+ "--output-dir",
+ metavar="<dir>",
+ default="ssaf-output",
+ help="Directory for the results and the SSAF artifacts "
+ "(default: %(default)s).",
+ )
+ for option, purpose in FORWARDED_TOOLS:
+ parser.add_argument(option, metavar="<path>", default=None, help=purpose)
+ parser.add_argument(
+ "--ssaf-extra-clang-arg",
+ metavar="<arg>",
+ action="append",
+ default=[],
+ dest="ssaf_extra_clang_args",
+ help="Additional argument for every SSAF compilation. Repeatable.",
+ )
+ parser.add_argument(
+ "-k",
+ "--keep-going",
+ action="store_true",
+ help="Carry on even if some SSAF steps failed.",
+ )
+ parser.add_argument(
+ "-v",
+ "--verbose",
+ action="store_true",
+ help="Print every command that runs.",
+ )
+ parser.add_argument(
+ "build",
+ nargs=argparse.REMAINDER,
+ metavar="<build command>",
+ help="The build command. A '--' separator before it is optional.",
+ )
+ return parser
+
+
+class CommandLineOpts:
+ def __init__(
+ self,
+ build_command=(),
+ transformation=None,
+ output_dir=None,
+ orchestrator_args=(),
+ verbose=False,
+ ):
+ self.build_command = list(build_command)
+ self.transformation = transformation
+ self.output_dir = output_dir
+ self.orchestrator_args = list(orchestrator_args)
+ self.verbose = verbose
+
+
+def resolve(argv):
+ args = build_parser().parse_args(argv)
+
+ build_command = list(args.build)
+ if build_command and build_command[0] == "--":
+ build_command = build_command[1:]
+ if not build_command:
+ return None, Problem(USAGE_ERROR, "no build command given")
+
+ orchestrator_args = []
+ for option, _ in FORWARDED_TOOLS:
+ value = getattr(args, option[2:].replace("-", "_"))
+ if value is not None:
+ orchestrator_args.append("%s=%s" % (option, value))
+ for arg in args.ssaf_extra_clang_args:
+ orchestrator_args.append("--ssaf-extra-clang-arg=" + arg)
+ if args.keep_going:
+ orchestrator_args.append("--keep-going")
+ if args.verbose:
+ orchestrator_args.append("--verbose")
+
+ return (
+ CommandLineOpts(
+ build_command=build_command,
+ transformation=args.transformation,
+ output_dir=args.output_dir,
+ orchestrator_args=orchestrator_args,
+ verbose=args.verbose,
+ ),
+ None,
+ )
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py
new file mode 100644
index 00000000000000..2ee1be8fe26395
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/patch.py
@@ -0,0 +1,129 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import collections
+import difflib
+import os
+
+from .options import ENVIRONMENT_ERROR, Problem
+
+FileEdits = collections.namedtuple(
+ "FileEdits", "relative_path path edits original edited"
+)
+
+Collected = collections.namedtuple("Collected", "files outside blocked")
+
+NO_NEWLINE = b"\\ No newline at end of file\n"
+
+
+def apply_edits(original, edits):
+ pieces = []
+ position = 0
+ previous = None
+ for offset, length, text in edits:
+ if offset + length > len(original):
+ return None, "an edit at byte %d runs past the end of the file" % offset
+ if offset < position or (length == 0 and previous == (offset, 0)):
+ return None, "edits overlap at byte %d" % offset
+ pieces.append(original[position:offset])
+ pieces.append(text.encode("utf-8"))
+ position = offset + length
+ previous = (offset, length)
+ pieces.append(original[position:])
+ return b"".join(pieces), None
+
+
+def _identity(path):
+ try:
+ status = os.stat(path)
+ except OSError:
+ return path
+ return (status.st_dev, status.st_ino)
+
+
+def _overlaps(edit, start, end):
+ offset, length, _ = edit
+ if length == 0:
+ return start < offset < end
+ return offset < end and start < offset + length
+
+
+def collect(replacements, project_root, conflicts):
+ grouped = {}
+ paths = {}
+ for each in replacements:
+ path = os.path.realpath(os.path.join(project_root, each.file_path))
+ identity = _identity(path)
+ paths[identity] = min(paths.get(identity, path), path)
+ grouped.setdefault(identity, set()).add((each.offset, each.length, each.text))
+ conflicting = {}
+ for path, ranges in conflicts.items():
+ conflicting.setdefault(_identity(os.path.realpath(path)), []).extend(ranges)
+
+ files = []
+ outside = []
+ blocked = []
+ problems = []
+ for identity in sorted(grouped, key=lambda each: paths[each]):
+ path = paths[identity]
+ relative_path = os.path.relpath(path, project_root)
+ if relative_path == os.pardir or relative_path.startswith(os.pardir + os.sep):
+ outside.append(path)
+ continue
+ ranges = conflicting.get(identity, ())
+ edits = sorted(
+ (
+ edit
+ for edit in grouped[identity]
+ if not any(_overlaps(edit, start, end) for start, end in ranges)
+ ),
+ key=lambda edit: (edit[0], edit[1]),
+ )
+ if len(edits) < len(grouped[identity]):
+ blocked.append((relative_path, len(grouped[identity]) - len(edits)))
+ if not edits:
+ continue
+ try:
+ with open(path, "rb") as handle:
+ original = handle.read()
+ except OSError as error:
+ problems.append("%s: %s" % (relative_path, error.strerror))
+ continue
+ edited, problem = apply_edits(original, edits)
+ if problem:
+ problems.append("%s: %s" % (relative_path, problem))
+ continue
+ files.append(FileEdits(relative_path, path, edits, original, edited))
+
+ if problems:
+ return None, Problem(
+ ENVIRONMENT_ERROR, "the source edits cannot be applied:", problems
+ )
+ return Collected(files, outside, blocked), None
+
+
+def _lines(content):
+ lines = content.split(b"\n")
+ last = lines.pop()
+ lines = [line + b"\n" for line in lines]
+ if last:
+ lines.append(last)
+ return lines
+
+
+def render(files):
+ pieces = []
+ for each in files:
+ name = os.fsencode(each.relative_path)
+ for line in difflib.diff_bytes(
+ difflib.unified_diff,
+ _lines(each.original),
+ _lines(each.edited),
+ b"a/" + name,
+ b"b/" + name,
+ ):
+ pieces.append(line)
+ if not line.endswith(b"\n"):
+ pieces.append(b"\n" + NO_NEWLINE)
+ return b"".join(pieces)
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py
new file mode 100644
index 00000000000000..975a9362bb8923
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/report.py
@@ -0,0 +1,136 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import collections
+import json
+import os
+import urllib.parse
+
+from . import PROGRAM
+from .options import ENVIRONMENT_ERROR, Problem
+
+SCHEMA = (
+ "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/"
+ "sarif-schema-2.1.0.json"
+)
+
+_LEVELS = {"error": "error", "warning": "warning", "note": "note", "none": "note"}
+
+_FILE_SCHEME = "file://"
+
+
+def load(paths):
+ runs = []
+ for path in paths:
+ if not path:
+ continue
+ try:
+ with open(path) as handle:
+ document = json.load(handle)
+ except (OSError, ValueError) as error:
+ return None, Problem(
+ ENVIRONMENT_ERROR, "cannot read the report %s: %s" % (path, error)
+ )
+ if not isinstance(document, dict) or not isinstance(document.get("runs"), list):
+ return None, Problem(ENVIRONMENT_ERROR, "not a SARIF log: %s" % path)
+ runs.extend(document["runs"])
+ return {"$schema": SCHEMA, "version": "2.1.0", "runs": runs}, None
+
+
+def write(path, log):
+ with open(path, "w") as handle:
+ json.dump(log, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+
+
+class _Sources:
+ def __init__(self):
+ self.contents = {}
+
+ def position(self, path, offset):
+ if path not in self.contents:
+ try:
+ with open(path, "rb") as handle:
+ self.contents[path] = handle.read()
+ except OSError:
+ self.contents[path] = None
+ content = self.contents[path]
+ if content is None or offset > len(content):
+ return None
+ line_start = content.rfind(b"\n", 0, offset) + 1
+ return content.count(b"\n", 0, offset) + 1, offset - line_start + 1
+
+
+def _path(uri):
+ if not uri.startswith(_FILE_SCHEME):
+ return uri
+ return urllib.parse.unquote(uri[len(_FILE_SCHEME) :])
+
+
+def _where(location, project_root, sources):
+ physical = location.get("physicalLocation") or {}
+ uri = (physical.get("artifactLocation") or {}).get("uri")
+ if not uri:
+ return PROGRAM
+ path = _path(uri)
+ shown = os.path.relpath(path, project_root)
+ if shown == os.pardir or shown.startswith(os.pardir + os.sep):
+ shown = path
+ region = physical.get("region") or {}
+ if "startLine" in region:
+ return "%s:%d:%d" % (shown, region["startLine"], region.get("startColumn", 1))
+ if "byteOffset" in region:
+ position = sources.position(path, region["byteOffset"])
+ if position:
+ return "%s:%d:%d" % ((shown,) + position)
+ return shown
+
+
+def _text(item):
+ return (item.get("message") or {}).get("text", "")
+
+
+def diagnostics(log, project_root):
+ sources = _Sources()
+ lines = []
+ counts = collections.Counter()
+ for run in log["runs"]:
+ for result in run.get("results") or []:
+ rule_id = result.get("ruleId", "")
+ counts[rule_id] += 1
+ locations = result.get("locations") or [{}]
+ lines.append(
+ "%s: %s: %s [%s]"
+ % (
+ _where(locations[0], project_root, sources),
+ _LEVELS.get(result.get("level"), "warning"),
+ _text(result),
+ rule_id,
+ )
+ )
+ for related in result.get("relatedLocations") or []:
+ lines.append(
+ "%s: note: %s"
+ % (_where(related, project_root, sources), _text(related))
+ )
+ if counts:
+ summary = ["%d %s" % (counts[rule_id], rule_id) for rule_id in sorted(counts)]
+ lines.append("%s reported" % ", ".join(summary))
+ return lines
+
+
+def conflict_ranges(log):
+ ranges = {}
+ for run in log["runs"]:
+ for result in run.get("results") or []:
+ for related in result.get("relatedLocations") or []:
+ physical = related.get("physicalLocation") or {}
+ uri = (physical.get("artifactLocation") or {}).get("uri")
+ region = physical.get("region") or {}
+ if uri and "byteOffset" in region and "byteLength" in region:
+ start = region["byteOffset"]
+ ranges.setdefault(_path(uri), []).append(
+ (start, start + region["byteLength"])
+ )
+ return ranges
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py
new file mode 100644
index 00000000000000..88f3ce67339eb1
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/source_edits.py
@@ -0,0 +1,150 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import collections
+import re
+
+from .options import ENVIRONMENT_ERROR, Problem
+
+Replacement = collections.namedtuple("Replacement", "file_path offset length text")
+
+_ENTRY_KEYS = ("Offset", "Length", "ReplacementText")
+
+_LINE = re.compile(
+ r"^(?P<indent>(?: - | )?)(?P<key>[A-Za-z]+):(?: +(?P<value>.*))?$"
+)
+
+_ESCAPE = re.compile(r"\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)")
+
+_ESCAPED = {
+ "0": "\0",
+ "a": "\a",
+ "b": "\b",
+ "t": "\t",
+ "n": "\n",
+ "v": "\v",
+ "f": "\f",
+ "r": "\r",
+ "e": "\x1b",
+ '"': '"',
+ "\\": "\\",
+ "N": "\x85",
+ "_": "\xa0",
+ "L": "\u2028",
+ "P": "\u2029",
+}
+
+
+class _Malformed(Exception):
+ def __init__(self, line, message):
+ super().__init__(message)
+ self.line = line
+ self.message = message
+
+
+def _unescape(body, line):
+ pieces = []
+ position = 0
+ for match in _ESCAPE.finditer(body):
+ pieces.append(body[position : match.start()])
+ code = match.group(1)
+ if len(code) > 1:
+ pieces.append(chr(int(code[1:], 16)))
+ elif code in _ESCAPED:
+ pieces.append(_ESCAPED[code])
+ else:
+ raise _Malformed(line, "unknown escape '\\%s'" % code)
+ position = match.end()
+ rest = body[position:]
+ if "\\" in rest:
+ raise _Malformed(line, "unfinished escape")
+ pieces.append(rest)
+ return "".join(pieces)
+
+
+def _scalar(value, line):
+ if value is None:
+ raise _Malformed(line, "missing value")
+ if value[:1] in ("'", '"'):
+ if len(value) < 2 or value[-1] != value[0]:
+ raise _Malformed(line, "unterminated string")
+ if value[0] == "'":
+ return value[1:-1].replace("''", "'")
+ return _unescape(value[1:-1], line)
+ return value
+
+
+def _integer(value, line):
+ text = _scalar(value, line)
+ if not text.isdigit():
+ raise _Malformed(line, "expected a number, got '%s'" % text)
+ return int(text)
+
+
+def _parse(lines):
+ if lines and lines[-1] == "":
+ lines = lines[:-1]
+ if not lines or lines[0] != "---":
+ raise _Malformed(1, "expected '---'")
+ if len(lines) < 2 or lines[-1] != "...":
+ raise _Malformed(len(lines), "expected '...'")
+ entries = []
+ replacements_line = None
+ for number, text in enumerate(lines[1:-1], start=2):
+ match = _LINE.match(text)
+ if not match:
+ raise _Malformed(number, "unexpected line")
+ indent, key, value = match.group("indent", "key", "value")
+ if not indent and key == "MainSourceFile":
+ _scalar(value, number)
+ elif not indent and key == "Replacements" and replacements_line is None:
+ replacements_line = number
+ if value is not None and value != "[]":
+ raise _Malformed(number, "expected a list of replacements")
+ elif indent == " - " and key == "FilePath" and replacements_line:
+ entries.append((number, {key: _scalar(value, number)}))
+ elif indent == " " and key in _ENTRY_KEYS and entries:
+ entry = entries[-1][1]
+ if key in entry:
+ raise _Malformed(number, "'%s' given twice" % key)
+ if key == "ReplacementText":
+ entry[key] = _scalar(value, number)
+ else:
+ entry[key] = _integer(value, number)
+ else:
+ raise _Malformed(number, "unexpected '%s'" % key)
+ if replacements_line is None:
+ raise _Malformed(len(lines), "no replacements")
+ replacements = []
+ for number, entry in entries:
+ missing = [key for key in _ENTRY_KEYS if key not in entry]
+ if missing:
+ raise _Malformed(number, "replacement without '%s'" % missing[0])
+ replacements.append(
+ Replacement(
+ entry["FilePath"],
+ entry["Offset"],
+ entry["Length"],
+ entry["ReplacementText"],
+ )
+ )
+ return replacements
+
+
+def read_source_edits(path):
+ try:
+ with open(path, "rb") as handle:
+ text = handle.read().decode("utf-8")
+ except (OSError, UnicodeDecodeError) as error:
+ return None, Problem(
+ ENVIRONMENT_ERROR, "cannot read the source edits: %s" % error
+ )
+ lines = [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
+ try:
+ return _parse(lines), None
+ except _Malformed as error:
+ return None, Problem(
+ ENVIRONMENT_ERROR,
+ "malformed source edits: %s:%d: %s" % (path, error.line, error.message),
+ )
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py
new file mode 100644
index 00000000000000..6a01c498a6cc87
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/transformations.py
@@ -0,0 +1,18 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import collections
+
+Transformation = collections.namedtuple(
+ "Transformation", "extract_summaries analysis transformation"
+)
+
+TRANSFORMATIONS = {
+ "cpp-bounded-buffers": Transformation(
+ extract_summaries="PointerFlow,TypeConstrainedPointers,UnsafeBufferUsage,"
+ "VirtualMethod",
+ analysis="UnsafeBufferReachableAnalysisResult",
+ transformation="cpp-bounded-buffers",
+ ),
+}
diff --git a/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in
new file mode 100644
index 00000000000000..69b39a828847af
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/lib/clang_ssaf_driver/version.py.in
@@ -0,0 +1,5 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+VERSION = "@CLANG_VERSION@"
diff --git a/clang/tools/clang-ssaf-driver/tests/__init__.py b/clang/tools/clang-ssaf-driver/tests/__init__.py
new file mode 100644
index 00000000000000..44390e67aacf47
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/__init__.py
@@ -0,0 +1,3 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
diff --git a/clang/tools/clang-ssaf-driver/tests/test_driver.py b/clang/tools/clang-ssaf-driver/tests/test_driver.py
new file mode 100644
index 00000000000000..99471d30def988
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_driver.py
@@ -0,0 +1,123 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import io
+import json
+import os
+import shutil
+import tempfile
+import unittest
+from unittest import mock
+
+from clang_ssaf_driver import driver, make_orchestrator
+
+
+class Running(unittest.TestCase):
+ def setUp(self):
+ self.tmp = os.path.realpath(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.project = os.path.join(self.tmp, "project")
+ os.makedirs(os.path.join(self.project, "src"))
+ self.addCleanup(os.chdir, os.getcwd())
+ os.chdir(self.project)
+ with open("src/a.cpp", "wb") as handle:
+ handle.write(b"int *p;\n")
+ self.out = os.path.join(self.project, "out")
+ os.makedirs(self.out)
+ orchestrator = os.path.join(self.tmp, "clang-ssaf-orchestrator-make")
+ open(orchestrator, "w").close()
+ for name, value in (
+ ("find_orchestrator", mock.Mock(return_value=orchestrator)),
+ ("run_orchestrator", mock.Mock(return_value=(0, self._results()))),
+ ):
+ patcher = mock.patch.object(make_orchestrator, name, value)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def _results(self):
+ edits = os.path.join(self.tmp, "prg.edits.yaml")
+ with open(edits, "w") as handle:
+ handle.write(
+ "---\n"
+ "MainSourceFile: ''\n"
+ "Replacements:\n"
+ ' - FilePath: "src/a.cpp"\n'
+ " Offset: 0\n"
+ " Length: 5\n"
+ ' ReplacementText: "bounded_ptr<int> "\n'
+ "...\n"
+ )
+ report = os.path.join(self.tmp, "prg.report.sarif")
+ with open(report, "w") as handle:
+ json.dump(
+ {
+ "runs": [
+ {
+ "results": [
+ {
+ "level": "note",
+ "message": {"text": "m"},
+ "ruleId": "r",
+ }
+ ]
+ }
+ ]
+ },
+ handle,
+ )
+ return {"source-edits": edits, "transformation-report": report}
+
+ def _main(self, *extra):
+ stdout, stderr = io.StringIO(), io.StringIO()
+ with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
+ status = driver.main(
+ ["--transformation=cpp-bounded-buffers", "-o", "out"]
+ + list(extra)
+ + ["--", "make"]
+ )
+ return status, stdout.getvalue(), stderr.getvalue()
+
+ def test_a_run_writes_the_patch_and_the_report_and_names_them(self):
+ status, stdout, stderr = self._main()
+ self.assertEqual(0, status)
+ patch_path = os.path.join(self.out, "cpp-bounded-buffers.patch")
+ report_path = os.path.join(self.out, "cpp-bounded-buffers.sarif")
+ self.assertEqual("patch: %s\nreport: %s\n" % (patch_path, report_path), stdout)
+ self.assertEqual("clang-ssaf-driver: note: m [r]\n1 r reported\n", stderr)
+ with open(patch_path, "rb") as handle:
+ self.assertIn(b"+bounded_ptr<int> p;\n", handle.read())
+ with open(report_path) as handle:
+ self.assertEqual(1, len(json.load(handle)["runs"]))
+ with open("src/a.cpp", "rb") as handle:
+ self.assertEqual(b"int *p;\n", handle.read())
+
+ def test_a_failed_orchestrator_run_writes_nothing(self):
+ make_orchestrator.run_orchestrator.return_value = (2, {})
+ status, stdout, stderr = self._main()
+ self.assertEqual(2, status)
+ self.assertEqual("", stdout)
+ self.assertEqual(
+ "clang-ssaf-driver: error: clang-ssaf-orchestrator-make failed (exit 2)\n",
+ stderr,
+ )
+ self.assertEqual([], os.listdir(self.out))
+
+ def test_a_run_without_source_edits_is_a_problem(self):
+ make_orchestrator.run_orchestrator.return_value = (0, {})
+ status, _, stderr = self._main()
+ self.assertEqual(1, status)
+ self.assertIn("reported no source edits", stderr)
+
+ def test_a_missing_orchestrator_stops_the_run_before_it_starts(self):
+ make_orchestrator.find_orchestrator.return_value = os.path.join(
+ self.tmp, "nothing-here"
+ )
+ status, _, stderr = self._main()
+ self.assertEqual(1, status)
+ self.assertIn("cannot find clang-ssaf-orchestrator-make", stderr)
+ make_orchestrator.run_orchestrator.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py b/clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py
new file mode 100644
index 00000000000000..ec406fc4051f2c
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_make_orchestrator.py
@@ -0,0 +1,137 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import io
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+from clang_ssaf_driver import make_orchestrator, options
+from clang_ssaf_driver.transformations import TRANSFORMATIONS
+
+
+class Command(unittest.TestCase):
+ def test_the_orchestrator_runs_the_recipe_under_this_python(self):
+ command_line_opts, _ = options.resolve(
+ [
+ "--transformation=cpp-bounded-buffers",
+ "-o",
+ "out",
+ "--ssaf-clang=/x/clang",
+ "make",
+ "-j8",
+ ]
+ )
+ self.assertEqual(
+ [
+ sys.executable,
+ "/prefix/bin/clang-ssaf-orchestrator-make",
+ "--extract-summaries=PointerFlow,TypeConstrainedPointers,"
+ "UnsafeBufferUsage,VirtualMethod",
+ "--analysis=UnsafeBufferReachableAnalysisResult",
+ "--transformation=cpp-bounded-buffers",
+ "--output-dir=out",
+ "--ssaf-clang=/x/clang",
+ "--",
+ "make",
+ "-j8",
+ ],
+ make_orchestrator.orchestrator_command(
+ "/prefix/bin/clang-ssaf-orchestrator-make",
+ TRANSFORMATIONS["cpp-bounded-buffers"],
+ command_line_opts,
+ ),
+ )
+
+ def test_the_orchestrator_is_the_one_installed_beside_the_driver(self):
+ with mock.patch.object(
+ make_orchestrator,
+ "__file__",
+ "/prefix/lib/clang_ssaf_driver/make_orchestrator.py",
+ ):
+ self.assertEqual(
+ "/prefix/bin/clang-ssaf-orchestrator-make",
+ make_orchestrator.find_orchestrator(),
+ )
+
+
+class Results(unittest.TestCase):
+ def test_a_result_line_is_read(self):
+ self.assertEqual(
+ ("source-edits", "/out/prg.edits.yaml"),
+ make_orchestrator.read_result(b"source-edits: /out/prg.edits.yaml\n"),
+ )
+
+ def test_nothing_else_is(self):
+ for line in (
+ b"clang++ -c -o build/a.o src/a.cpp\n",
+ b"link-unit: /p/prg\n",
+ b"source-edits:\n",
+ b"make: source-edits: /x\n",
+ ):
+ self.assertIsNone(make_orchestrator.read_result(line))
+
+ def test_a_path_that_is_not_utf8_survives(self):
+ _, path = make_orchestrator.read_result(b"source-edits: /out/\xff.yaml\n")
+ self.assertEqual(b"/out/\xff.yaml", os.fsencode(path))
+
+
+class Running(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+
+ def _run(self, body):
+ script = os.path.join(self.tmp, "orchestrator")
+ with open(script, "w") as handle:
+ handle.write("import os, signal, sys\nout = sys.stdout.buffer\n" + body)
+ relay = io.BytesIO()
+ status, results = make_orchestrator.run_orchestrator(
+ [sys.executable, script], False, relay
+ )
+ return status, results, relay.getvalue()
+
+ def test_everything_is_relayed_and_the_last_results_are_kept(self):
+ printed = (
+ b"make: entering\n"
+ b"source-edits: /old\n"
+ b"\xff not utf8\n"
+ b"source-edits: /out/prg.edits.yaml\n"
+ b"transformation-report: /out/prg.report.sarif\n"
+ b"conflict-report: /out/prg.conflict-report.sarif\n"
+ b"output-dir: /out\n"
+ )
+ status, results, relayed = self._run("out.write(%r)\n" % printed)
+ self.assertEqual(0, status)
+ self.assertEqual(printed, relayed)
+ self.assertEqual(
+ {
+ "source-edits": "/out/prg.edits.yaml",
+ "transformation-report": "/out/prg.report.sarif",
+ "conflict-report": "/out/prg.conflict-report.sarif",
+ },
+ results,
+ )
+
+ def test_the_exit_status_passes_through(self):
+ status, _, _ = self._run("sys.exit(3)\n")
+ self.assertEqual(3, status)
+
+ def test_death_by_a_signal_is_the_shells_status_for_it(self):
+ status, _, _ = self._run("os.kill(os.getpid(), signal.SIGTERM)\n")
+ self.assertEqual(128 + 15, status)
+
+ def test_a_result_glued_to_the_builds_last_output_is_not_read(self):
+ printed = b"no newline" + b"source-edits: /x\n"
+ status, results, relayed = self._run("out.write(%r)\n" % printed)
+ self.assertEqual(0, status)
+ self.assertEqual(printed, relayed)
+ self.assertEqual({}, results)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_options.py b/clang/tools/clang-ssaf-driver/tests/test_options.py
new file mode 100644
index 00000000000000..4c01684748f8db
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_options.py
@@ -0,0 +1,72 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import io
+import unittest
+from unittest import mock
+
+from clang_ssaf_driver import options
+
+
+class Resolve(unittest.TestCase):
+ def _resolve(self, *extra):
+ return options.resolve(["--transformation=cpp-bounded-buffers"] + list(extra))
+
+ def test_a_valid_command_line_yields_the_options_as_typed(self):
+ command_line_opts, problem = self._resolve("-o", "out", "--", "make", "-j8")
+ self.assertIsNone(problem)
+ self.assertEqual(["make", "-j8"], command_line_opts.build_command)
+ self.assertEqual("cpp-bounded-buffers", command_line_opts.transformation)
+ self.assertEqual("out", command_line_opts.output_dir)
+ self.assertEqual([], command_line_opts.orchestrator_args)
+
+ def test_the_separator_before_the_build_command_is_optional(self):
+ command_line_opts, problem = self._resolve("make", "-j8")
+ self.assertIsNone(problem)
+ self.assertEqual(["make", "-j8"], command_line_opts.build_command)
+
+ def test_an_option_after_the_build_command_is_the_builds(self):
+ command_line_opts, _ = self._resolve("make", "--verbose")
+ self.assertEqual(["make", "--verbose"], command_line_opts.build_command)
+ self.assertFalse(command_line_opts.verbose)
+
+ def test_no_build_command_is_a_usage_error(self):
+ _, problem = self._resolve("--")
+ self.assertEqual(options.USAGE_ERROR, problem.exit_status)
+ self.assertEqual("no build command given", problem.message)
+
+ def test_the_transformation_is_required_and_must_be_known(self):
+ for argv in (["--", "make"], ["--transformation=nonexistent", "--", "make"]):
+ with mock.patch("sys.stderr", io.StringIO()):
+ with self.assertRaises(SystemExit) as raised:
+ options.resolve(argv)
+ self.assertEqual(options.USAGE_ERROR, raised.exception.code)
+
+ def test_the_orchestrator_gets_each_forwarded_option_with_its_value(self):
+ command_line_opts, _ = self._resolve(
+ "--ssaf-clang=/x/clang",
+ "--ssaf-conflict-merge",
+ "/x/merge",
+ "--ssaf-extra-clang-arg=-DX",
+ "--ssaf-extra-clang-arg=-DY",
+ "-k",
+ "-v",
+ "--",
+ "make",
+ )
+ self.assertEqual(
+ [
+ "--ssaf-clang=/x/clang",
+ "--ssaf-conflict-merge=/x/merge",
+ "--ssaf-extra-clang-arg=-DX",
+ "--ssaf-extra-clang-arg=-DY",
+ "--keep-going",
+ "--verbose",
+ ],
+ command_line_opts.orchestrator_args,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_patch.py b/clang/tools/clang-ssaf-driver/tests/test_patch.py
new file mode 100644
index 00000000000000..758455f36e66c1
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_patch.py
@@ -0,0 +1,249 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from clang_ssaf_driver import patch
+from clang_ssaf_driver.source_edits import Replacement
+
+
+def _has_gnu_or_bsd_patch():
+ try:
+ version = subprocess.run(
+ ["patch", "--version"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except OSError:
+ return False
+ return version.returncode == 0
+
+
+class ApplyingEdits(unittest.TestCase):
+ def _apply(self, original, edits):
+ return patch.apply_edits(original, sorted(edits))
+
+ def test_replacements_deletions_and_insertions(self):
+ self.assertEqual(
+ (b"bounded_ptr<int> p; q[4]/*T*/;", None),
+ self._apply(
+ b"int *p; q[4];",
+ [(0, 5, "bounded_ptr<int> "), (12, 0, "/*T*/")],
+ ),
+ )
+ self.assertEqual((b"int q;", None), self._apply(b"int q[4];", [(5, 3, "")]))
+
+ def test_an_insertion_goes_before_a_replacement_at_its_offset(self):
+ self.assertEqual(
+ (b"(bounded_ptr<int> )p", None),
+ self._apply(b"int *p", [(0, 0, "("), (0, 5, "bounded_ptr<int> )")]),
+ )
+
+ def test_an_insertion_at_the_end_of_a_replacement_follows_it(self):
+ self.assertEqual((b"Yx", None), self._apply(b"ab", [(0, 2, "Y"), (2, 0, "x")]))
+
+ def test_overlapping_edits_are_refused(self):
+ _, problem = self._apply(b"abcdef", [(0, 3, "x"), (2, 2, "y")])
+ self.assertEqual("edits overlap at byte 2", problem)
+ _, problem = self._apply(b"abcdef", [(1, 0, "x"), (1, 0, "y")])
+ self.assertEqual("edits overlap at byte 1", problem)
+
+ def test_an_edit_past_the_end_is_refused(self):
+ _, problem = self._apply(b"abc", [(2, 2, "x")])
+ self.assertEqual("an edit at byte 2 runs past the end of the file", problem)
+
+ def test_the_text_goes_in_as_utf8(self):
+ self.assertEqual(
+ ("éb".encode("utf-8"), None), self._apply(b"ab", [(0, 1, "é")])
+ )
+
+
+class Collecting(unittest.TestCase):
+ def setUp(self):
+ self.tmp = os.path.realpath(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.root = os.path.join(self.tmp, "project")
+ os.makedirs(os.path.join(self.root, "src"))
+
+ def _file(self, relative, content):
+ path = os.path.join(self.root, relative)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "wb") as handle:
+ handle.write(content)
+ return path
+
+ def _collect(self, replacements, conflicts=None):
+ collected, problem = patch.collect(replacements, self.root, conflicts or {})
+ self.assertIsNone(problem)
+ return collected
+
+ def test_edits_are_grouped_by_file_and_sorted(self):
+ self._file("src/b.cpp", b"int *b;\n")
+ self._file("src/a.cpp", b"int *a;\n")
+ collected = self._collect(
+ [
+ Replacement("src/b.cpp", 0, 5, "bounded_ptr<int> "),
+ Replacement("src/a.cpp", 0, 5, "bounded_ptr<int> "),
+ ]
+ )
+ self.assertEqual(
+ ["src/a.cpp", "src/b.cpp"], [f.relative_path for f in collected.files]
+ )
+ self.assertEqual(b"bounded_ptr<int> a;\n", collected.files[0].edited)
+ self.assertEqual(([], []), (collected.outside, collected.blocked))
+
+ def test_every_spelling_of_a_file_is_one_file_and_an_edit_counts_once(self):
+ header = self._file("src/h.h", b"int *g;\n")
+ os.link(header, os.path.join(self.root, "src", "linked.h"))
+ edit = (0, 5, "bounded_ptr<int> ")
+ collected = self._collect(
+ [
+ Replacement("src/h.h", *edit),
+ Replacement("src/../src/h.h", *edit),
+ Replacement(header, *edit),
+ Replacement("src/linked.h", *edit),
+ ]
+ )
+ self.assertEqual(["src/h.h"], [f.relative_path for f in collected.files])
+ self.assertEqual([edit], collected.files[0].edits)
+
+ def test_an_edit_that_overlaps_a_reported_conflict_is_left_out(self):
+ header = self._file("src/h.h", b"0123456789abcdef")
+ collected = self._collect(
+ [
+ Replacement("src/h.h", 0, 2, "A"),
+ Replacement("src/h.h", 2, 0, "B"),
+ Replacement("src/h.h", 4, 4, "C"),
+ Replacement("src/h.h", 7, 0, "D"),
+ Replacement("src/h.h", 10, 2, "E"),
+ ],
+ {header: [(2, 9)]},
+ )
+ self.assertEqual(
+ [(0, 2, "A"), (2, 0, "B"), (10, 2, "E")], collected.files[0].edits
+ )
+ self.assertEqual([("src/h.h", 2)], collected.blocked)
+
+ def test_a_file_whose_edits_all_conflict_is_left_out(self):
+ header = self._file("src/h.h", b"int *g;\n")
+ collected = self._collect(
+ [Replacement("src/h.h", 0, 5, "bounded_ptr<int> ")], {header: [(0, 5)]}
+ )
+ self.assertEqual([], collected.files)
+ self.assertEqual([("src/h.h", 1)], collected.blocked)
+
+ def test_a_file_outside_the_project_is_left_out(self):
+ self._file("src/a.cpp", b"int *a;\n")
+ elsewhere = os.path.join(self.tmp, "elsewhere.h")
+ with open(elsewhere, "wb") as handle:
+ handle.write(b"int *x;\n")
+ collected = self._collect(
+ [
+ Replacement("src/a.cpp", 0, 5, "bounded_ptr<int> "),
+ Replacement(elsewhere, 0, 5, "bounded_ptr<int> "),
+ ]
+ )
+ self.assertEqual(["src/a.cpp"], [f.relative_path for f in collected.files])
+ self.assertEqual([elsewhere], collected.outside)
+
+ def test_every_file_that_cannot_be_edited_is_reported_at_once(self):
+ self._file("src/a.cpp", b"abc")
+ collected, problem = patch.collect(
+ [
+ Replacement("src/a.cpp", 2, 5, "x"),
+ Replacement("src/missing.cpp", 0, 0, "x"),
+ ],
+ self.root,
+ {},
+ )
+ self.assertIsNone(collected)
+ self.assertEqual("the source edits cannot be applied:", problem.message)
+ self.assertEqual(
+ [
+ "src/a.cpp: an edit at byte 2 runs past the end of the file",
+ "src/missing.cpp: No such file or directory",
+ ],
+ list(problem.details),
+ )
+
+
+class Rendering(unittest.TestCase):
+ def setUp(self):
+ self.tmp = os.path.realpath(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+
+ def _render(self, original, edits, name="src/a.cpp"):
+ edited, problem = patch.apply_edits(original, edits)
+ self.assertIsNone(problem)
+ path = os.path.join(self.tmp, name)
+ return patch.render([patch.FileEdits(name, path, edits, original, edited)])
+
+ def test_a_unified_diff_with_paths_under_a_and_b(self):
+ self.assertEqual(
+ b"--- a/src/a.cpp\n"
+ b"+++ b/src/a.cpp\n"
+ b"@@ -1,3 +1,3 @@\n"
+ b" int f();\n"
+ b"-int *p;\n"
+ b"+bounded_ptr<int> p;\n"
+ b" int g();\n",
+ self._render(
+ b"int f();\nint *p;\nint g();\n", [(9, 5, "bounded_ptr<int> ")]
+ ),
+ )
+
+ def test_a_missing_final_newline_is_marked(self):
+ self.assertEqual(
+ b"--- a/src/a.cpp\n"
+ b"+++ b/src/a.cpp\n"
+ b"@@ -1 +1 @@\n"
+ b"-int *p;\n"
+ b"\\ No newline at end of file\n"
+ b"+bounded_ptr<int> p;\n"
+ b"\\ No newline at end of file\n",
+ self._render(b"int *p;", [(0, 5, "bounded_ptr<int> ")]),
+ )
+
+ def test_line_ends_and_bytes_are_kept_as_they_are(self):
+ rendered = self._render(
+ b"\xff\r\nint *p;\r\nold\rmac\n", [(3, 5, "bounded_ptr<int> ")]
+ )
+ self.assertIn(b"-int *p;\r\n+bounded_ptr<int> p;\r\n", rendered)
+ self.assertIn(b" old\rmac\n", rendered)
+
+ def test_no_edits_is_an_empty_patch(self):
+ self.assertEqual(b"", patch.render([]))
+
+ @unittest.skipUnless(_has_gnu_or_bsd_patch(), "needs a GNU or BSD patch")
+ def test_patch_applies_what_was_rendered(self):
+ cases = {
+ "src/a.cpp": (b"int f();\nint *p;\nint g();\n", [(9, 5, "q ")]),
+ "src/b.cpp": (b"int *p;", [(0, 5, "bounded_ptr<int> ")]),
+ "src/c.cpp": (b"a\r\nint *p;\r\nb\rc\n", [(3, 5, "x")]),
+ }
+ files = []
+ for name, (original, edits) in sorted(cases.items()):
+ path = os.path.join(self.tmp, name)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "wb") as handle:
+ handle.write(original)
+ edited, _ = patch.apply_edits(original, edits)
+ files.append(patch.FileEdits(name, path, edits, original, edited))
+ subprocess.run(
+ ["patch", "-p1", "-s"],
+ input=patch.render(files),
+ cwd=self.tmp,
+ check=True,
+ )
+ for each in files:
+ with open(each.path, "rb") as handle:
+ self.assertEqual(each.edited, handle.read())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_report.py b/clang/tools/clang-ssaf-driver/tests/test_report.py
new file mode 100644
index 00000000000000..ba60c6928590e6
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_report.py
@@ -0,0 +1,204 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import json
+import os
+import shutil
+import tempfile
+import unittest
+
+from clang_ssaf_driver import report
+
+
+def _location(uri, **region):
+ return {"physicalLocation": {"artifactLocation": {"uri": uri}, "region": region}}
+
+
+def _log(*results):
+ return {"runs": [{"results": list(results)}]}
+
+
+class Loading(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+
+ def _write(self, name, document):
+ path = os.path.join(self.tmp, name)
+ with open(path, "w") as handle:
+ if isinstance(document, str):
+ handle.write(document)
+ else:
+ json.dump(document, handle)
+ return path
+
+ def test_the_runs_of_both_reports_make_one_log(self):
+ transformation = self._write(
+ "t.sarif", {"runs": [{"tool": {"driver": {"name": "clang-ssaf"}}}]}
+ )
+ conflicts = self._write(
+ "c.sarif",
+ {"runs": [{"tool": {"driver": {"name": "clang-ssaf-src-edit-merge"}}}]},
+ )
+ log, problem = report.load([transformation, conflicts])
+ self.assertIsNone(problem)
+ self.assertEqual("2.1.0", log["version"])
+ self.assertEqual(
+ ["clang-ssaf", "clang-ssaf-src-edit-merge"],
+ [run["tool"]["driver"]["name"] for run in log["runs"]],
+ )
+
+ def test_an_absent_report_adds_nothing(self):
+ conflicts = self._write("c.sarif", {"runs": [{"results": []}]})
+ log, problem = report.load([None, conflicts])
+ self.assertIsNone(problem)
+ self.assertEqual([{"results": []}], log["runs"])
+
+ def test_what_is_not_a_sarif_log_is_a_problem(self):
+ _, problem = report.load([self._write("list.sarif", "[]")])
+ self.assertIn("not a SARIF log", problem.message)
+ _, problem = report.load([self._write("bad.sarif", "{")])
+ self.assertIn("cannot read the report", problem.message)
+
+ def test_the_log_is_written_as_json(self):
+ log, _ = report.load([])
+ path = os.path.join(self.tmp, "out.sarif")
+ report.write(path, log)
+ with open(path) as handle:
+ self.assertEqual(log, json.load(handle))
+
+
+class Diagnostics(unittest.TestCase):
+ def setUp(self):
+ self.root = os.path.realpath(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.root, True)
+ os.makedirs(os.path.join(self.root, "src"))
+ with open(os.path.join(self.root, "src", "shared.h"), "wb") as handle:
+ handle.write(b"ab\ncdef\n")
+ self.shared = "file://" + os.path.join(self.root, "src", "shared.h")
+
+ def test_a_finding_reads_like_a_compiler_diagnostic(self):
+ log = _log(
+ {
+ "level": "note",
+ "locations": [_location(self.shared, startLine=3, startColumn=7)],
+ "message": {"text": "not yet rewritten"},
+ "ruleId": "cpp-bounded-buffers-skipped",
+ }
+ )
+ self.assertEqual(
+ [
+ "src/shared.h:3:7: note: not yet rewritten "
+ "[cpp-bounded-buffers-skipped]",
+ "1 cpp-bounded-buffers-skipped reported",
+ ],
+ report.diagnostics(log, self.root),
+ )
+
+ def test_a_conflict_is_placed_by_its_bytes_and_followed_by_its_candidates(self):
+ log = _log(
+ {
+ "level": "error",
+ "locations": [_location(self.shared, byteOffset=5)],
+ "message": {"text": "dropped"},
+ "relatedLocations": [
+ dict(
+ _location(self.shared, byteOffset=3, byteLength=2),
+ message={"text": 'candidate edit: "x"'},
+ )
+ ],
+ "ruleId": "conflict",
+ }
+ )
+ self.assertEqual(
+ [
+ "src/shared.h:2:3: error: dropped [conflict]",
+ 'src/shared.h:2:1: note: candidate edit: "x"',
+ "1 conflict reported",
+ ],
+ report.diagnostics(log, self.root),
+ )
+
+ def test_a_file_outside_the_project_keeps_its_whole_path(self):
+ log = _log(
+ {
+ "level": "note",
+ "locations": [_location("file:///usr/include/my%20lib.h", startLine=1)],
+ "message": {"text": "m"},
+ "ruleId": "r",
+ }
+ )
+ self.assertEqual(
+ "/usr/include/my lib.h:1:1: note: m [r]",
+ report.diagnostics(log, self.root)[0],
+ )
+
+ def test_a_finding_without_a_location_is_the_drivers_own(self):
+ self.assertEqual(
+ "clang-ssaf-driver: warning: m [r]",
+ report.diagnostics(_log({"message": {"text": "m"}, "ruleId": "r"}), "/")[0],
+ )
+
+ def test_the_findings_are_counted_per_rule(self):
+ finding = {"locations": [_location(self.shared, startLine=1)]}
+ log = {
+ "runs": [
+ {"results": [dict(finding, ruleId="b"), dict(finding, ruleId="a")]},
+ {"results": [dict(finding, ruleId="b")]},
+ ]
+ }
+ self.assertEqual("1 a, 2 b reported", report.diagnostics(log, self.root)[-1])
+
+ def test_no_findings_print_nothing(self):
+ self.assertEqual([], report.diagnostics(_log(), self.root))
+
+ def test_a_merge_tools_path_is_read_as_written(self):
+ path = os.path.join(self.root, "src", "a#b?c.h")
+ with open(path, "wb") as handle:
+ handle.write(b"x\ny\n")
+ log = _log(
+ {
+ "locations": [_location("file://" + path, byteOffset=2)],
+ "message": {"text": "m"},
+ "ruleId": "r",
+ }
+ )
+ self.assertEqual(
+ "src/a#b?c.h:2:1: warning: m [r]", report.diagnostics(log, self.root)[0]
+ )
+
+
+class ConflictRanges(unittest.TestCase):
+ def test_every_candidate_of_every_conflict_is_a_range(self):
+ log = {
+ "runs": [
+ {"results": [{"locations": [_location("file:///p/a.h", startLine=1)]}]},
+ {
+ "results": [
+ {
+ "locations": [_location("file:///p/h.h", byteOffset=4)],
+ "relatedLocations": [
+ _location("file:///p/h.h", byteOffset=4, byteLength=3),
+ _location("file:///p/h.h", byteOffset=6, byteLength=9),
+ ],
+ },
+ {
+ "relatedLocations": [
+ _location(
+ "file:///p/my%20c.h", byteOffset=0, byteLength=1
+ )
+ ]
+ },
+ ]
+ },
+ ]
+ }
+ self.assertEqual(
+ {"/p/h.h": [(4, 7), (6, 15)], "/p/my c.h": [(0, 1)]},
+ report.conflict_ranges(log),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/clang/tools/clang-ssaf-driver/tests/test_source_edits.py b/clang/tools/clang-ssaf-driver/tests/test_source_edits.py
new file mode 100644
index 00000000000000..d40122b827c973
--- /dev/null
+++ b/clang/tools/clang-ssaf-driver/tests/test_source_edits.py
@@ -0,0 +1,141 @@
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import os
+import shutil
+import tempfile
+import unittest
+
+from clang_ssaf_driver import source_edits
+from clang_ssaf_driver.source_edits import Replacement
+
+
+def _entry(file_path, offset, length, text):
+ return [
+ " - FilePath: %s" % file_path,
+ " Offset: %d" % offset,
+ " Length: %d" % length,
+ " ReplacementText: %s" % text,
+ ]
+
+
+class Reading(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.path = os.path.join(self.tmp, "edits.yaml")
+
+ def _read(self, lines, line_end=b"\n"):
+ with open(self.path, "wb") as handle:
+ handle.write(line_end.join(line.encode("utf-8") for line in lines))
+ handle.write(line_end)
+ return source_edits.read_source_edits(self.path)
+
+ def _document(self, *entries):
+ lines = ["---", "MainSourceFile: ''", "Replacements:"]
+ for entry in entries:
+ lines.extend(entry)
+ return lines + ["..."]
+
+ def test_every_scalar_the_merge_tool_writes_is_read(self):
+ written = [
+ ("plain", "plain"),
+ ("a\tb", "a\tb"),
+ ("' lead'", " lead"),
+ ("'it''s'", "it's"),
+ ("''", ""),
+ ("'4'", "4"),
+ ("'true'", "true"),
+ ('"line\\nbreak"', "line\nbreak"),
+ ("'tab\tand\"quote\\'", 'tab\tand"quote\\'),
+ ('"\\x01\x7f"', "\x01\x7f"),
+ ('"\u00e9"', "\u00e9"),
+ ('"\U0001F600"', "\U0001F600"),
+ ('"\\L\\N\\_"', "\u2028\x85\xa0"),
+ ]
+ replacements, problem = self._read(
+ self._document(
+ *[
+ _entry("'src/f.cpp'", index * 5, 1, text)
+ for index, (text, _) in enumerate(written)
+ ]
+ )
+ )
+ self.assertIsNone(problem)
+ self.assertEqual(
+ [
+ Replacement("src/f.cpp", index * 5, 1, text)
+ for index, (_, text) in enumerate(written)
+ ],
+ replacements,
+ )
+
+ def test_every_escape_of_a_double_quoted_scalar_is_read(self):
+ replacements, problem = self._read(
+ self._document(
+ _entry(
+ "f.cpp",
+ 0,
+ 0,
+ '"\\0\\a\\b\\t\\n\\v\\f\\r\\e\\"\\\\\\x41\\u00e9\\U0001F600"',
+ )
+ )
+ )
+ self.assertIsNone(problem)
+ self.assertEqual(
+ '\0\a\b\t\n\v\f\r\x1b"\\A\u00e9\U0001F600', replacements[0].text
+ )
+
+ def test_no_replacements_is_an_empty_list(self):
+ replacements, problem = self._read(
+ ["---", "MainSourceFile: ''", "Replacements: []", "..."]
+ )
+ self.assertIsNone(problem)
+ self.assertEqual([], replacements)
+
+ def test_crlf_line_ends_are_read(self):
+ replacements, problem = self._read(
+ self._document(_entry("f.cpp", 1, 2, "x")), line_end=b"\r\n"
+ )
+ self.assertIsNone(problem)
+ self.assertEqual([Replacement("f.cpp", 1, 2, "x")], replacements)
+
+ def _problem(self, lines):
+ replacements, problem = self._read(lines)
+ self.assertIsNone(replacements)
+ return problem.message
+
+ def test_a_truncated_file_is_malformed(self):
+ self.assertEqual(
+ "malformed source edits: %s:7: expected '...'" % self.path,
+ self._problem(self._document(_entry("f.cpp", 1, 2, "x"))[:-1]),
+ )
+
+ def test_a_malformed_line_is_reported_with_its_number(self):
+ lines = self._document(_entry("f.cpp", 1, 2, "x"))
+ lines[4] = " Offset: one"
+ self.assertEqual(
+ "malformed source edits: %s:5: expected a number, got 'one'" % self.path,
+ self._problem(lines),
+ )
+
+ def test_an_unknown_escape_is_malformed(self):
+ self.assertIn(
+ "unknown escape '\\q'",
+ self._problem(self._document(_entry("f.cpp", 1, 2, '"\\q"'))),
+ )
+
+ def test_a_replacement_missing_a_field_is_malformed(self):
+ self.assertIn(
+ "replacement without 'ReplacementText'",
+ self._problem(self._document(_entry("f.cpp", 1, 2, "x")[:3])),
+ )
+
+ def test_a_file_that_cannot_be_read_is_a_problem(self):
+ _, problem = source_edits.read_source_edits(self.path)
+ self.assertIn("cannot read the source edits", problem.message)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/llvm/utils/gn/secondary/clang/test/BUILD.gn b/llvm/utils/gn/secondary/clang/test/BUILD.gn
index e4cb35528cf8bb..1da964cb6933ea 100644
--- a/llvm/utils/gn/secondary/clang/test/BUILD.gn
+++ b/llvm/utils/gn/secondary/clang/test/BUILD.gn
@@ -166,6 +166,7 @@ group("test") {
"//clang/tools/clang-scan-deps",
"//clang/tools/clang-ssaf-analyzer",
"//clang/tools/clang-ssaf-conflict-merge",
+ "//clang/tools/clang-ssaf-driver",
"//clang/tools/clang-ssaf-format",
"//clang/tools/clang-ssaf-linker",
"//clang/tools/clang-ssaf-orchestrator-make",
diff --git a/llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn b/llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn
new file mode 100644
index 00000000000000..5f37317eb6061f
--- /dev/null
+++ b/llvm/utils/gn/secondary/clang/tools/clang-ssaf-driver/BUILD.gn
@@ -0,0 +1,40 @@
+import("//llvm/utils/gn/build/write_cmake_config.gni")
+import("//llvm/version.gni")
+
+# Python tool. Copies its scripts into the build dir, like scan-build.
+group("clang-ssaf-driver") {
+ deps = [
+ ":bin",
+ ":lib",
+ ":version",
+
+ # The driver runs the orchestrator installed beside it.
+ "//clang/tools/clang-ssaf-orchestrator-make",
+ ]
+}
+
+copy("bin") {
+ sources = [ "bin/clang-ssaf-driver" ]
+ outputs = [ "$root_build_dir/bin/{{source_file_part}}" ]
+}
+
+copy("lib") {
+ sources = [
+ "lib/clang_ssaf_driver/__init__.py",
+ "lib/clang_ssaf_driver/driver.py",
+ "lib/clang_ssaf_driver/logging_diagnostics.py",
+ "lib/clang_ssaf_driver/make_orchestrator.py",
+ "lib/clang_ssaf_driver/options.py",
+ "lib/clang_ssaf_driver/patch.py",
+ "lib/clang_ssaf_driver/report.py",
+ "lib/clang_ssaf_driver/source_edits.py",
+ "lib/clang_ssaf_driver/transformations.py",
+ ]
+ outputs = [ "$root_build_dir/lib/clang_ssaf_driver/{{source_file_part}}" ]
+}
+
+write_cmake_config("version") {
+ input = "lib/clang_ssaf_driver/version.py.in"
+ output = "$root_build_dir/lib/clang_ssaf_driver/version.py"
+ values = [ "CLANG_VERSION=$llvm_version" ]
+}
More information about the llvm-branch-commits
mailing list