Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions include/analysis/IRValueUtils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace llvm
{
class AllocaInst;
class CallBase;
class ConstantInt;
class Function;
class StoreInst;
Expand Down Expand Up @@ -35,6 +36,12 @@ namespace ctrace::stack::analysis
Unknown
};

/// The function a call reaches directly, through pointer casts and aliases: on ELF targets,
/// clang emits a complete-object constructor or destructor defined out of its class as an
/// alias of the base-object one, and calls the alias. Null for an indirect call, and for a
/// call through an alias that the link may replace (weak): its target is not known.
llvm::Function* directCallee(const llvm::CallBase& call);

std::string deriveAllocaName(const llvm::AllocaInst* AI);

bool isLikelyCompilerTemporaryName(llvm::StringRef name);
Expand Down
241 changes: 239 additions & 2 deletions run_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2347,7 +2347,7 @@ def checked_output(inputs):

# Old summaries have no uncertainty metadata and must be rebuilt, not reused.
for path, summary in zip(cache_files, summaries):
summary["schema"] = "resource-summary-cache-v3"
summary["schema"] = "resource-summary-cache-v4"
for fn in summary["functions"]:
for kind in ("normal", "exceptional"):
exit_summary = fn["ownership"][kind]
Expand All @@ -2357,7 +2357,7 @@ def checked_output(inputs):
if checked_output(inputs) != first:
return fail_check("obsolete summaries changed the wrapper diagnostics")
# Intermediate fixpoint keys need not be reused; active keys must be rewritten.
if not any(json.loads(path.read_text())["schema"] == "resource-summary-cache-v4" for path in cache_files):
if not any(json.loads(path.read_text())["schema"] == "resource-summary-cache-v5" for path in cache_files):
return fail_check("obsolete ownership summaries were not rebuilt")
print(" ✅ ownership wrapper metadata preserved locally, across TUs and in the cache\n")
return True
Expand Down Expand Up @@ -3890,6 +3890,241 @@ def check_diagnostic_paths_follow_the_input() -> bool:
return ok


def check_calls_through_aliases() -> bool:
"""
#179: on ELF targets, clang calls a constructor or a destructor defined out of its class
through an alias (C1 -> C2, D1 -> D2), a direct call that Mach-O makes without one. The
findings, and whether each max stack is known, must be the same for x86-64 and AArch64 Linux
as for macOS: an object that only reaches its constructor does not escape, the call does not
hide that the constructor initializes it, and a constructor that keeps this is reported. A
constructor defined in another file must resolve through its alias too. The max stacks
themselves may differ: Mach-O keeps C1 as a function that calls C2, a frame that ELF folds
into the alias.
"""
print("=== Testing calls through constructor and destructor aliases, per target ===")
targets = ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "arm64-apple-macosx")
smt_args = [
"--smt=on",
"--smt-backend=z3",
"--smt-mode=single",
f"--smt-rules={','.join(_all_smt_rules())}",
f"--smt-timeout-ms={SMT_FIXTURE_TIMEOUT_MS}",
]

def observed(sources: list[Path], target: str, extra: list[str]):
result = run_analyzer(
[*map(str, sources), "--format=json", f"--compile-arg=--target={target}", *extra]
)
try:
payload = json.loads(result.stdout or "")
except json.JSONDecodeError:
return None
# Constructors and destructors are left out: Mach-O lists C1 and D1, ELF only aliases.
stacks = {
fn.get("name"): "known" if fn.get("maxStackUnknown") is False else "unknown"
for fn in payload.get("functions", [])
if not str(fn.get("name", "")).startswith("_ZN")
}
findings = sorted(
{
(str(d.get("ruleId", "")).split(".", 1)[0], d.get("location", {}).get("function"))
for d in payload.get("diagnostics", [])
}
)
return stacks, findings

with tempfile.TemporaryDirectory(prefix="ct_alias_cross_file_") as tmp:
cross = Path(tmp)
(cross / "resolver.hpp").write_text(
"struct Resolver\n{\n explicit Resolver(int value);\n int seed;\n};\n"
)
(cross / "resolver.cpp").write_text(
'#include "resolver.hpp"\n\nResolver::Resolver(int value) : seed(value) {}\n'
)
(cross / "run.cpp").write_text(
'#include "resolver.hpp"\n\nint run(int value)\n{\n'
" const Resolver resolver(value);\n return resolver.seed;\n}\n"
)
escape = "StackPointerEscape"
# (label, sources, extra arguments, functions whose max stack must be known, findings
# expected on every target).
cases = [
("escape-stack/ctor-alias-no-escape.cpp", None, [], ["_Z3runi"], []),
(
"false-positive-repro/ctor-dtor-alias.cpp",
None,
[],
["_Z3usev", "_Z5framev"],
[("UninitializedLocalRead", "_Z3usev")],
),
("escape-stack/ctor-alias-stores-this.cpp", None, [], ["_Z3runi"], [(escape, "_Z3runi")]),
(
"escape-stack/ctor-alias-passes-this.cpp",
None,
[],
[],
[(escape, "_Z3runiPFvP8ResolverE")],
),
(
"constructor defined in another file",
[cross / "resolver.cpp", cross / "run.cpp"],
[f"--compile-arg=-I{cross}"],
["_Z3runi"],
[],
),
]
ok = True
for pass_name, pass_args in (("default", []), ("smt-z3", smt_args)):
for name, sources, case_args, known_stacks, expected in cases:
label = f"{pass_name}, {name}"
files = sources or [RUN_CONFIG.test_dir / name]
runs = {t: observed(files, t, [*case_args, *pass_args]) for t in targets}
problems = []
if any(run is None for run in runs.values()):
missing = sorted(t for t, r in runs.items() if r is None)
problems.append(f"no JSON output: {missing}")
else:
first = runs[targets[0]]
for target in targets[1:]:
if runs[target] != first:
problems.append(f"{target}: {runs[target]}, {targets[0]}: {first}")
for target, (stacks, findings) in runs.items():
unknown = [f for f in known_stacks if stacks.get(f) != "known"]
if unknown:
problems.append(f"{target}: max stack not known for {unknown}")
if findings != expected:
problems.append(f"{target}: findings {findings}, expected {expected}")
if problems:
ok = False
print(f" ❌ {label}")
for problem in problems:
print(f" {problem}")
else:
print(f" ✅ {label}: {runs[targets[0]][1]}")

# Aliases the source declares, which clang supports on ELF targets only. An alias the link
# may replace (weak) does not designate its aliasee for sure: the caller's max stack stays
# unknown, whether or not the definition that replaces it is analyzed too. A plain alias does.
with tempfile.TemporaryDirectory(prefix="ct_alias_attribute_") as tmp:
root = Path(tmp)
(root / "weak-alias.c").write_text(
"int fallback(int n) { return n; }\n"
'int replaceable(int n) __attribute__((weak, alias("fallback")));\n'
"int caller(int n) { return replaceable(n); }\n"
)
(root / "plain-alias.c").write_text(
"int fallback(int n) { return n; }\n"
'int replaceable(int n) __attribute__((alias("fallback")));\n'
"int caller(int n) { return replaceable(n); }\n"
)
(root / "override.c").write_text(
"int replaceable(int n)\n{\n volatile unsigned char buffer[256];\n"
" buffer[0] = (unsigned char)n;\n return buffer[0];\n}\n"
)
alias_cases = [
("weak alias and the definition that replaces it", ["weak-alias.c", "override.c"], "unknown"),
("weak alias alone", ["weak-alias.c"], "unknown"),
("plain alias", ["plain-alias.c"], "known"),
]
for pass_name, pass_args in (("default", []), ("smt-z3", smt_args)):
for name, files, status in alias_cases:
for target in targets[:2]:
run = observed([root / f for f in files], target, pass_args)
got = run[0].get("caller") if run else "no JSON output"
if got == status:
print(f" ✅ {pass_name}, {name}, {target}: caller max stack {got}")
else:
ok = False
print(f" ❌ {pass_name}, {name}, {target}: caller max stack {got}, expected {status}")
print()
return ok


def check_resource_cache_rebuilds_alias_summaries() -> bool:
"""
Following a call through an alias changes the resource summaries (#179): a wrapper that
returns what an alias of an acquiring function returns now acquires. Summaries cached by a
previous version of the analyzer, under the previous cache schema, must be rebuilt: the
summaries a run with that cache writes must be those a run with an empty cache writes, the
wrapper's acquisition included, not only the same diagnostics. Entries for intermediate
rounds of the fixed point that a run does not read again may stay as they were. The cache
keys hash the IR as printed, which differs between a fresh compile and the compile IR cache:
a first run fills that cache, so that both runs compared take their IR from it.
"""
print("=== Testing that resource summaries cached by a previous schema are rebuilt ===")
ok = True
with tempfile.TemporaryDirectory(prefix="ct_alias_resource_cache_") as tmp:
root = Path(tmp)
(root / "alias.c").write_text(
"static int storage;\n"
"void *create_impl(void) { return &storage; }\n"
'void *alias_create(void) __attribute__((alias("create_impl")));\n'
"void *wrapper(void) { return alias_create(); }\n"
)
(root / "caller.c").write_text(
"void *wrapper(void);\nvoid release_handle(void *);\n"
"void consumer(void) { release_handle(wrapper()); }\n"
)
model = root / "model.txt"
model.write_text("acquire_ret create_impl Handle\nrelease_arg release_handle 0 Handle\n")

def acquires(summary: dict) -> bool:
return any(
e.get("action") == "acquire_ret"
for fn in summary["functions"] if fn["name"] == "wrapper" for e in fn["effects"]
)

for target in ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"):
cold, stale = root / f"cold-{target}", root / f"stale-{target}"

def run(cache: Path):
return run_analyzer_uncached(
[str(root / "alias.c"), str(root / "caller.c"), "--format=json", "--jobs=1",
f"--compile-arg=--target={target}", f"--resource-model={model}",
f"--compile-ir-cache-dir={root / f'ir-{target}'}",
f"--resource-summary-cache-dir={cache}"]
)

run(root / f"warm-up-{target}")
first = run(cold)
expected = {path.name: json.loads(path.read_text()) for path in cold.glob("*.json")}
if first.returncode != 0 or not any(acquires(s) for s in expected.values()):
ok = False
print(f" ❌ {target}: the wrapper's summary does not acquire with an empty cache")
continue
# The cache a previous version left: the previous schema, and a wrapper that does
# not acquire, as it was summarized without following the alias.
stale.mkdir()
for path in cold.glob("*.json"):
summary = json.loads(path.read_text())
summary["schema"] = "resource-summary-cache-v4"
for fn in summary["functions"]:
if fn["name"] == "wrapper":
fn["effects"] = []
fn["ownership"]["normal"]["returns"] = "unknown"
fn["ownership"]["normal"]["returnsKind"] = ""
(stale / path.name).write_text(json.dumps(summary))
second = run(stale)
current = next(iter(expected.values()))["schema"]
written = {
path.name: summary
for path in stale.glob("*.json")
if (summary := json.loads(path.read_text()))["schema"] == current
}
if second.returncode != 0 or second.stdout != first.stdout:
ok = False
print(f" ❌ {target}: the output with the previous cache differs from the one with an empty cache")
elif any(summary != expected.get(name) for name, summary in written.items()) or not any(
acquires(s) for s in written.values()
):
ok = False
print(f" ❌ {target}: the summaries written with the previous cache are not those of an empty one: {written}")
else:
print(f" ✅ {target}: the previous cache is rebuilt to the summaries of an empty one")
print()
return ok


def check_const_param_abi_split_struct() -> bool:
"""
ConstParameterNotModified names the same source parameters whatever the ABI does with a
Expand Down Expand Up @@ -4838,6 +5073,8 @@ def record_ok(ok: bool):
check_diagnostic_cwe_coverage,
check_diagnostic_paths_follow_the_input,
check_const_param_abi_split_struct,
check_calls_through_aliases,
check_resource_cache_rebuilds_alias_summaries,
check_uninitialized_receiver_abi_split_struct,
check_escape_through_returned_value,
check_sarif_rule_cwe_tags,
Expand Down
14 changes: 3 additions & 11 deletions src/analysis/CommandInjectionAnalysis.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "analysis/CommandInjectionAnalysis.hpp"
#include "analysis/IRValueUtils.hpp"

#include "analysis/AnalyzerUtils.hpp"

Expand All @@ -16,15 +17,6 @@ namespace ctrace::stack::analysis
{
namespace
{
static const llvm::Function* getDirectCallee(const llvm::CallBase& call)
{
if (const llvm::Function* direct = call.getCalledFunction())
return direct;
const llvm::Value* called = call.getCalledOperand();
if (!called)
return nullptr;
return llvm::dyn_cast<llvm::Function>(called->stripPointerCasts());
}

static llvm::StringRef canonicalCalleeName(llvm::StringRef name)
{
Expand Down Expand Up @@ -106,7 +98,7 @@ namespace ctrace::stack::analysis
if (!call)
continue;

const llvm::Function* callee = getDirectCallee(*call);
const llvm::Function* callee = directCallee(*call);
if (!callee)
continue;

Expand Down Expand Up @@ -142,7 +134,7 @@ namespace ctrace::stack::analysis
{
for (const CallBaseT* call : callBases)
{
const llvm::Function* callee = getDirectCallee(*call);
const llvm::Function* callee = directCallee(*call);
if (!callee)
continue;

Expand Down
12 changes: 3 additions & 9 deletions src/analysis/ConstParamAnalysis.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "analysis/ConstParamAnalysis.hpp"
#include "analysis/IRValueUtils.hpp"
#include "analysis/AnalyzerUtils.hpp"

#include <cctype>
Expand Down Expand Up @@ -328,14 +329,7 @@ namespace ctrace::stack::analysis
{
using namespace llvm;

const Function* callee = CB.getCalledFunction();
if (!callee)
{
const Value* called = CB.getCalledOperand();
if (called)
called = called->stripPointerCasts();
callee = dyn_cast<Function>(called);
}
const Function* callee = directCallee(CB);

if (!callee)
return ParamWriteState::Unknown;
Expand Down Expand Up @@ -578,7 +572,7 @@ namespace ctrace::stack::analysis
continue;
if (CB->isCallee(&use))
return true;
const llvm::Function* callee = CB->getCalledFunction();
const llvm::Function* callee = directCallee(*CB);
if (!callee)
continue;
if (const llvm::DISubprogram* SP = callee->getSubprogram())
Expand Down
8 changes: 4 additions & 4 deletions src/analysis/DuplicateIfCondition.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "analysis/DuplicateIfCondition.hpp"
#include "analysis/IRValueUtils.hpp"

#include <algorithm>
#include <cctype>
Expand Down Expand Up @@ -338,11 +339,10 @@ namespace ctrace::stack::analysis
// counts when the other modules make it deterministic.
static const llvm::Function* calledFunction(const llvm::CallBase& call)
{
if (const llvm::Function* callee = call.getCalledFunction())
const llvm::Function* callee = directCallee(call);
if (!callee || callee->getFunctionType() == call.getFunctionType())
return callee;
const auto* declared =
llvm::dyn_cast<llvm::Function>(call.getCalledOperand()->stripPointerCasts());
return declared && isDeterministicElsewhere(*declared) ? declared : nullptr;
return isDeterministicElsewhere(*callee) ? callee : nullptr;
}

static llvm::Value* stripCasts(llvm::Value* v)
Expand Down
Loading
Loading