Skip to content

[IR] Inline a "zeroize-stack" callee into an equally protected caller - #17

Merged
kumarak merged 1 commit into
enforced_secrecy_mainfrom
zeroize-inline-into-protected-caller
Aug 18, 2026
Merged

[IR] Inline a "zeroize-stack" callee into an equally protected caller#17
kumarak merged 1 commit into
enforced_secrecy_mainfrom
zeroize-inline-into-protected-caller

Conversation

@claude

@claude claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Requested by Akshay Kumar · Slack thread

Before / After

Before. The base branch documents one inlining rule and implements a different one.

The squash of #4 carried in two things at once. Its code change made the rule callee-side with no same-attribute exemption — checkZeroizeStack in llvm/lib/IR/Attributes.cpp ended in return !Callee.hasFnAttribute("zeroize-stack");, ignoring the caller entirely, so a function carrying "zeroize-stack" is refused to every caller. Its LangRef text, which touches no code, says the opposite: a protected callee "is inlined only into a caller that carries it as well, and only where the caller's mode clears at least as much of the frame", with "used" the widest mode and "sensitive" the only narrower one. The commit message states the callee-side rule; the documentation in the same commit states the exemption. Right now the compiler does not do what the merged documentation says it does.

After. The compiler does what that documentation says. A protected callee is inlined into a protected caller whose mode clears at least as much, and refused otherwise — including when something asks for the inline anyway.

What it does

Seven cases, all covered by tests:

callee caller result
annotated unannotated refused
used used inlined
sensitive sensitive inlined
sensitive used inlined
used sensitive refused
unannotated annotated inlined
alwaysinline callee + alwaysinline call site unannotated refused

The last one is new — nothing exercised alwaysinline before.

How

checkZeroizeStack now reads both sides: no callee attribute means no promise and it goes anywhere; otherwise the caller must carry the attribute; and where both carry it the caller must not ask for less. "used" is the widest mode and LangRef gives any unrecognized value that same widest meaning, so "sensitive" is the only value the comparison has to name. Attributes.td needs no change — the rule is already registered as a CompatRule, so the rewritten body reaches every consumer of AttributeFuncs::areInlineCompatible, LTO and ThinLTO included.

The compatibility table alone is not enough, because getAttributeBasedInliningDecision in llvm/lib/Analysis/InlineCost.cpp returns for an alwaysinline call site before it consults that table. This follows the strictfp precedent exactly: AttributeFuncs::isStrictFPInlineCompatible exists so checkStrictFP can be called inside that early block, so the zeroize predicate is exposed the same way and called on the next line. AlwaysInliner.cpp routes through the same function, so one call covers both inliners. Confirmed load-bearing: with that one call disabled, the alwaysinline case inlines and the test fails.

Three things a reviewer should decide, not inherit

1. Mode strictness was not settled in the meeting. The requirement that the caller clear at least as much as the callee did not come from the discussion. It arrived with the LangRef text that rode in on the squash, as the only reading that preserves what the callee promised. It is implemented here because the merged documentation now states it, not because it was agreed.

2. Which marking the caller test reads is unanswered, and the spelling is inverted. This check reads the positive function-level attribute "zeroize-stack" and compares its value against the literal string "sensitive". That is the only determinate choice available: it is the only function-level marking in the tree, and the object-level marking cannot answer "is this caller protected" because it is attached per alloca. But note the inversion — the discussion used the positive sense throughout, while the object-level marking's IR spelling is negative: #3 is titled "Add !sensitive metadata for sensitive stack objects" and what it actually landed is MD_nozeroize / "nozeroize" in FixedMetadataKinds.def, tested as nozeroize-metadata.ll. So "the marking" is genuinely ambiguous rather than merely unstated. Flagging the choice rather than leaving it to be inferred from the diff; correct it if the intended sense is the other one.

3. The opt-in warning is deliberately not here. LangRef says an implementation "may report a warning when it declines to inline for this reason, and should not report an error", and that warning is worth having — but it needs a flag name, which is still an open question, and it needs a place to live. There is also a design constraint worth recording: this compatibility check runs per call site, inside a cost function, so a naive diagnostic would fire repeatedly for the same caller/callee pair and would need deduplicating before it is usable. That affects whether the flag is worth having at all, which is why it belongs here rather than only in chat. No flag has been invented.

Do not merge this yet

Opened as a draft, as a follow-up on top of the merged #4 rather than an amendment to it. Branched off enforced_secrecy_main at 1f24967c3d83a3f60d5629c7bae4aedb0fed2e61; #4's branch was deleted after the squash, so there is no #4 head to stack on.

Testing

Built with assertions on, X86 only. Worktree at test time: tree 33e550709ce29d16efe2391d7cfa3651ab797611, git status --porcelain empty.

  • ninja check-llvm-transforms-inline — 355 discovered, 289 passed, 0 failed, 65 unsupported, 1 expectedly failed.
  • ninja check-llvm-verifier — 416 discovered, 393 passed, 0 failed, 23 unsupported.
  • Both changed tests confirmed as PASS individually rather than skipped.

AI tool use

This change was written with Claude Code. It was reviewed before it was submitted.


Generated by Claude Code

The attribute's inlining rule as implemented refuses every caller, including
one that carries the attribute itself, while the LangRef text that landed
alongside it documents the opposite: the callee is inlined into a caller that
carries the attribute and whose mode clears at least as much of the frame.
Make the compiler do what the documentation says.

checkZeroizeStack now reads both sides. A callee without the attribute
promises nothing and goes anywhere. A callee carrying it needs a caller
carrying it too, or there is no clear for the callee's frame bytes to be
folded into. Where both carry it, the caller must not ask for less of its
frame than the callee did: "used" is the widest mode and LangRef gives any
unrecognized value that same widest meaning, so "sensitive" is the only mode
that clears less, and the only value the comparison needs to name.

A request to inline does not override the rule, so the check has to run on
the path a request takes. getAttributeBasedInliningDecision returns for an
alwaysinline call site before it consults the attribute compatibility table,
so expose the predicate as AttributeFuncs::isZeroizeStackInlineCompatible and
call it inside that early block, exactly as checkStrictFP is exposed and
called there. The always-inliner pass reaches the same function, so the one
call covers both inliners.

The mode-strictness half of the rule, that the caller clear at least as much
as the callee, was not settled in discussion. It arrived with the LangRef
text as the only reading that preserves what the callee promised.

The caller test reads the positive function-level attribute "zeroize-stack"
and compares its value against the literal "sensitive". Which marking should
answer whether a caller is protected is not settled either. That attribute is
the only function-level marking in the tree, so it is the only determinate
choice, but the object-level marking beside it is spelled in the negative
sense, as nozeroize, while the rule was discussed in the positive one.

No warning is emitted when the refusal fires. LangRef allows one and it is
worth having, but it needs a flag name and a place to live, and the check
runs per call site inside a cost function, so a diagnostic would repeat for
the same function pair until it is deduplicated. Left for a later change.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions

Copy link
Copy Markdown

Hello @claude[bot] 👋

Thank you for submitting a Pull Request (PR) to the LLVM Project. Since this is your first PR, here are a few useful links covering our main contribution policies and review practices.

  • All contributions to LLVM must follow our LLVM AI Tool Use Policy. In particular, if you used AI while working on this PR, remember to add a note to the PR description.
  • The LLVM Code-Review Policy and Practices document contains practical information about the PR process, including how patches are reviewed and accepted, and who can review a PR.
  • Our LLVM Developer Policy describes our expectations for code quality, commit summaries and contains notes on our CI system.

Please reply to this message to confirm that you have read these policies, especially the LLVM AI Tool Use Policy, and that any AI tool usage has been noted in the PR description.


Frequently asked questions

How do I add reviewers?

This PR will be automatically labeled, and the relevant teams will be notified. For some parts of the project, reviewers may also be added automatically.

You can also add reviewers manually using the Reviewers section on this page. If you cannot use that section, it is probably because you do not have write permissions for the repository. In that case, you can request a review by tagging reviewers in a comment using @ followed by their GitHub username.

What if there are no comments?

If you have not received any comments on your PR after a week, you can request a review by pinging the PR with a comment such as “Ping”. The common courtesy ping rate is once a week. Please remember that you are asking for volunteer time from other developers.

Are any special GitHub settings required to contribute to LLVM?

We only require contributors to have a public email address associated with their GitHub commits, see this section of LLVM Developer Policy for details.


If you have questions, feel free to leave a comment on this PR, or ask on LLVM Discord or LLVM Discourse.

Thank you,
The LLVM Community

@kumarak
kumarak marked this pull request as ready for review August 18, 2026 19:02
@kumarak
kumarak merged commit fb0e33f into enforced_secrecy_main Aug 18, 2026
10 of 11 checks passed
@kumarak
kumarak deleted the zeroize-inline-into-protected-caller branch August 18, 2026 19:39
kumarak pushed a commit that referenced this pull request Aug 18, 2026
* [IR] Test the "zeroize-stack" unrecognized-mode inlining rule

Fixes findings from an independent review of the now-merged #17.

LangRef says any "zeroize-stack" mode other than "used" and "sensitive" is
treated as "used", and that an unrecognized mode must not clear less than a
recognized one. checkZeroizeStack rests on that sentence: it names only
"sensitive" and lets every other value mean the widest mode. Nothing tested
it. Every mode string in the tests was "used" or "sensitive", so a mutation
that read an unparseable mode as clearing the least still passed. Three cases
pin the direction the documentation forbids: an unrecognized-mode callee is
refused into a "sensitive" caller exactly as a "used" callee is, an
unrecognized-mode caller accepts a "sensitive" callee, and two different
unrecognized spellings inline into each other because both denote the widest
mode and unequal strings are not a mismatch.

The alwaysinline path was covered in one direction only. Its single case was a
refusal, which is indistinguishable from the flat refusal the rule replaced, so
restoring that flat refusal inside the early alwaysinline block still passed.
Add the permitted direction: an alwaysinline protected callee into a protected
caller whose mode clears at least as much, with the call gone.

The refusals asserted only that the call survived, which would also hold with
inlining switched off or with the call refused for an unrelated cause. A
-pass-remarks-missed=inline run line now pins the reason for each one, with
--implicit-check-not so the list is exhaustive. The alwaysinline refusal pins
the string InlineCost.cpp returns for it, which nothing exercised before.

Rewrap two comment lines that ran to 81 columns.

* [IR] Test the empty "zeroize-stack" attribute value

Fixes a finding from an independent review of #18.

checkZeroizeStack names only "sensitive" and lets every other value mean the
widest mode. An empty value is one of those other values: it is not equal to
"sensitive", so it reaches the inlining decision through the identical
comparison an unrecognized value reaches it through, and is silently treated
as clearing the whole frame. Nothing in the tree gave the attribute an empty
value in a test, so that path was untested in both directions.

Three cases pin it. An empty-valued callee is refused into a "sensitive"
caller, exactly as a "used" or an unrecognized-mode callee is. An empty-valued
caller accepts a "sensitive" callee, which asked for less, so the refusal is
pinned as the widest mode rather than as a blanket refusal of the empty value.
Empty into empty inlines, because two equally non-modal strings are not a
mismatch. The new refusal joins the exhaustive -pass-remarks-missed list, so
it is pinned to the reason the inliner reports rather than merely to the call
surviving.

Reading the empty value as the widest mode is the conservative direction: it
can only cost an inline, never buy one into a caller that clears less. It is
not an endorsement of writing it. LangRef documents the value as required and
nothing in the tree enforces that yet, with enforcement pending in a separate
change; these cases assert today's behaviour so that changing it, whether by
rejecting the empty value outright or by giving it a meaning of its own, is a
decision rather than an accident.

Record that reasoning in a comment at the comparison site and at the test
cases, so it does not depend on this message surviving.

* [IR][Inline] Cover the valueless "zeroize-stack" spelling

The empty and the valueless spellings are the same attribute once parsed:
Attribute::getAsString prints ="value" only for a non-empty value, so
"zeroize-stack"="" prints as the bare "zeroize-stack" and both land in one
attribute group. The valueless form is therefore what -S and bitcode emit,
so cover it as a refusal into a "sensitive" caller alongside the empty one.

Correct the comments that described the empty value as unenforced with
enforcement to come. An absent, an empty, and an unrecognized value all mean
"used", the widest mode, so a value this consumer cannot interpret clears
more of the frame than it must, never less.

Correct the claim that all three rows per mode-value group pin that
direction. Only the refusal into a "sensitive" caller does; the other two pin
that refusal as the widest-mode reading rather than as a blanket refusal of
the value. Note next to the remarks RUN line that its exhaustive
--implicit-check-not makes a new missed-inline remark a failure unless its
REMARK line is added too, and trim the comparison-site comment, which had
grown out of proportion to the code it explains.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants