Skip to content

regex: Constrain the lifetime of Regex::AsStr(), Match::Inner() and RegexFlagWithOptions::pattern() - #257

Open
dhivadhiva wants to merge 3 commits into
google:mainfrom
dhivadhiva:regex-lifetime-bound
Open

dhivadhiva wants to merge 3 commits into
google:mainfrom
dhivadhiva:regex-lifetime-bound

Conversation

@dhivadhiva

@dhivadhiva dhivadhiva commented Nov 8, 2026 •

Copy link
Copy Markdown

Fixes #256.

Regex::AsStr(), Match::Inner() and RegexFlagWithOptions::pattern() return a view or reference into storage owned by the object, but had no lifetime annotation. So calling them on a temporary compiled silently and dangled:

absl::string_view p = Regex::Compile(pattern)->AsStr();  // dangled

This applies the same guards used in zip (feed713), deflate (4eb75b8), exif (b2d80ab) and xml (7f76098):

  • Regex::AsStr() and Match::Inner(): const&-qualified, ABSL_ATTRIBUTE_LIFETIME_BOUND added, and the const&& overload deleted.
  • RegexFlagWithOptions::pattern(): ABSL_ATTRIBUTE_LIFETIME_BOUND only. The rvalue overload is kept on purpose: absl::GetFlag() returns a copy, and consuming the view inside the full-expression (LOG(INFO) << absl::GetFlag(FLAGS_re).pattern();) is common and safe. Binding the result to a local is now diagnosed, because a copy of the flag can end up as the last owner of the Regex once the flag is set again.

Each declaration documents what the compiler does and doesn't catch, following the wording used in zip/converters.h.

Testing

Built with the full CMake setup (Crubit f144a4f, Rust nightly-2026-11-07, clang 21.1.8):

  • regex, regex_example and regex_benchmark build, and regex_example runs.
  • main.cc, regex.cc and regex_benchmark.cc compile with -Werror=dangling.
  • A clang -verify test (below), run against the generated bindings, passes on this branch. On main, every one of its 23 expected diagnostics is missing.
    • Rejected now, accepted on main:
      • AsStr(), Inner() and pattern() reached from a temporary, as listed in the description above.
      • *absl::GetFlag(...), ->AsStr(), .get(), .begin() and .end() on a flag copy, kept in a local.
      • A range from FindAll() kept from a temporary Regex, and a range-for over Regex::Compile(p)->CaptureNames(), FindAll(), FindAllCaptures() or either Split() overload (C++20).
      • CaptureNames(), FindAll(), FindAllCaptures(), Split() and GetAll() on an rvalue Regex or Captures (deleted overloads).
    • Still compiles: lvalue calls, consuming within the full-expression (including passing a range to a function), absl::GetFlag(FLAGS_re)->IsMatch(s), range-for over a RegexListFlag copy, ParseNumeric through Inner(), and AbslUnparseFlag for both flag types.
lifetime_verify.cc
// clang -verify test for the regex lifetime annotations, including the ones
// added in review (flag accessors and the borrowing ranges).
//   clang++ -std=gnu++20 -fsyntax-only -Xclang -verify \
//     -Xclang -verify-ignore-unexpected=note <include flags>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>

#include "absl/flags/flag.h"
#include "absl/strings/string_view.h"
#include "regex.h"
#include "regex_flag.h"

using security::regex::Captures;
using security::regex::Match;
using security::regex::Regex;
using security::regex::RegexFlag;
using security::regex::RegexListFlag;

ABSL_FLAG(RegexFlag, re, RegexFlag::OrDie("a+"), "");
ABSL_FLAG(RegexListFlag, re_list, RegexListFlag::OrDie({"a", "b"}), "");

// ---- Already in the PR: AsStr(), Inner(), pattern() -----------------------

void DanglingAccessors() {
  absl::string_view a = Regex::Compile("a+")->AsStr();  // expected-warning {{temporary whose address is used as value of local variable 'a' will be destroyed at the end of the full-expression}}
  absl::string_view b = (*Regex::Compile("a+")).AsStr();  // expected-error {{call to deleted member function 'AsStr'}}
  absl::string_view c = Regex::Compile("a+").value().AsStr();  // expected-error {{call to deleted member function 'AsStr'}}
  auto re = Regex::Compile("a+");
  const auto& d = (*re->Find("aa")).Inner();  // expected-error {{call to deleted member function 'Inner'}}
  absl::string_view e = absl::GetFlag(FLAGS_re).pattern();  // expected-warning {{temporary whose address is used as value of local variable 'e' will be destroyed at the end of the full-expression}}
  (void)a; (void)b; (void)c; (void)d; (void)e;
}

// ---- Review: flag accessors -----------------------------------------------

void DanglingFlags() {
  // The header's own "BAD!" example.
  const Regex& a = *absl::GetFlag(FLAGS_re);  // expected-warning {{temporary bound to local reference 'a' will be destroyed at the end of the full-expression}}
  // The reviewer's example: gets past AsStr() const&& = delete via operator->.
  absl::string_view b = absl::GetFlag(FLAGS_re)->AsStr();  // expected-warning {{temporary whose address is used as value of local variable 'b' will be destroyed at the end of the full-expression}}
  const Regex* c = absl::GetFlag(FLAGS_re).get();  // expected-warning {{temporary whose address is used as value of local variable 'c' will be destroyed at the end of the full-expression}}
  auto d = absl::GetFlag(FLAGS_re_list).begin();  // expected-warning {{temporary whose address is used as value of local variable 'd' will be destroyed at the end of the full-expression}}
  auto e = absl::GetFlag(FLAGS_re_list).end();  // expected-warning {{temporary whose address is used as value of local variable 'e' will be destroyed at the end of the full-expression}}
  (void)a; (void)b; (void)c; (void)d; (void)e;
}

// ---- Review: ranges that borrow *this -------------------------------------

void DanglingRanges() {
  // Through absl::StatusOr::operator->.
  auto a = Regex::Compile("a+")->FindAll("aa");  // expected-warning {{temporary whose address is used as value of local variable 'a' will be destroyed at the end of the full-expression}}
  // The reviewer's example: pre-C++23 range-for destroys the Regex first.
  for (auto n : Regex::Compile("(a)")->CaptureNames()) (void)n;  // expected-warning {{temporary implicitly bound to local reference will be destroyed at the end of the full-expression}}
  for (Match m : Regex::Compile("a+")->FindAll("aa")) (void)m;  // expected-warning {{temporary implicitly bound to local reference will be destroyed at the end of the full-expression}}
  for (auto c : Regex::Compile("(a)")->FindAllCaptures("a")) (void)c;  // expected-warning {{temporary implicitly bound to local reference will be destroyed at the end of the full-expression}}
  for (auto p : Regex::Compile(",")->Split("a,b")) (void)p;  // expected-warning {{temporary implicitly bound to local reference will be destroyed at the end of the full-expression}}
  for (auto p : Regex::Compile(",")->Split("a,b", 1)) (void)p;  // expected-warning {{temporary implicitly bound to local reference will be destroyed at the end of the full-expression}}

  // Rvalue Regex / Captures: deleted const&& overloads.
  auto b = (*Regex::Compile("(a)")).CaptureNames();  // expected-error {{call to deleted member function 'CaptureNames'}}
  auto c = Regex::Compile("a+").value().FindAll("aa");  // expected-error {{call to deleted member function 'FindAll'}}
  auto d = (*Regex::Compile("(a)")).FindAllCaptures("a");  // expected-error {{call to deleted member function 'FindAllCaptures'}}
  auto e = (*Regex::Compile(",")).Split("a,b");  // expected-error {{call to deleted member function 'Split'}}
  auto f = (*Regex::Compile(",")).Split("a,b", 1);  // expected-error {{call to deleted member function 'Split'}}
  auto re = Regex::Compile("(a)");
  for (auto m : (*re->FindCaptures("a")).GetAll()) (void)m;  // expected-error {{call to deleted member function 'GetAll'}}
  auto g = re->FindCaptures("a").value().GetAll();  // expected-error {{call to deleted member function 'GetAll'}}
}

// ---- Still compiles --------------------------------------------------------

template <typename R>
size_t Count(R&& r) {
  size_t n = 0;
  for (auto&& x : r) { (void)x; ++n; }
  return n;
}

void StillAllowed(const Regex& lv, const RegexFlag& flag) {
  // Lvalues are unaffected.
  absl::string_view a = lv.AsStr();
  for (Match m : lv.FindAll("aa")) (void)m;
  for (auto n : lv.CaptureNames()) (void)n;
  auto kept = lv.Split("a,b");
  for (auto p : kept) (void)p;
  std::optional<Captures> caps = lv.FindCaptures("a");
  for (auto m : caps->GetAll()) (void)m;
  const Regex& b = *flag;
  absl::string_view c = flag->AsStr();
  // Consuming inside the full-expression is fine.
  std::string d(Regex::Compile("a+")->AsStr());
  size_t n1 = Count(Regex::Compile("a")->FindAll("aaa"));
  size_t n2 = Count(Regex::Compile(",")->Split("a,b,c", 2));
  bool m1 = absl::GetFlag(FLAGS_re)->IsMatch("aa");
  bool m2 = (*absl::GetFlag(FLAGS_re)).IsMatch("aa");
  std::string e(absl::GetFlag(FLAGS_re)->AsStr());
  std::string f(absl::GetFlag(FLAGS_re).pattern());
  // Range-for keeps the flag copy alive for the whole loop.
  for (const auto& r : absl::GetFlag(FLAGS_re_list)) (void)r->IsMatch("a");
  // Arg's numeric parsing goes through Match::Inner() on an lvalue.
  int32_t i = 0;
  std::optional<Match> m = lv.Find("12");
  bool ok = security::regex::internal::ParseNumeric<10>(*m, &i);
  // Unparsing goes through pattern() / begin() / end() on lvalues.
  std::string u1 = absl::UnparseFlag(absl::GetFlag(FLAGS_re));
  std::string u2 = absl::UnparseFlag(absl::GetFlag(FLAGS_re_list));
  (void)a; (void)b; (void)c; (void)n1; (void)n2; (void)m1; (void)m2; (void)ok;
}

// Known gap: std::optional::operator-> is not annotated, so these still
// compile. The GetAll() case is documented on Captures::GetAll().
void KnownGap(const Regex& re) {
  const auto& inner = re.Find("aa")->Inner();
  auto all = re.FindCaptures("a")->GetAll();
  (void)inner;
}

As in 7f76098, re.Find(...)->Inner() still gets past the compiler, because std::optional::operator-> isn't annotated. The header can't close that one.

I know the README says the project isn't set up for contributions yet. If it's easier to land this internally, feel free to close this PR and take the change.

…egexFlagWithOptions::pattern()

These accessors return a view or reference into storage owned by the
object, but had no lifetime annotation, so calling them on a temporary
compiled silently and dangled:

    absl::string_view p = Regex::Compile(pattern)->AsStr();  // dangled

Apply the same guards used for zip, deflate, exif and xml:

- Regex::AsStr() and Match::Inner(): const&-qualify, add
  ABSL_ATTRIBUTE_LIFETIME_BOUND, and delete the const&& overload.
- RegexFlagWithOptions::pattern(): add ABSL_ATTRIBUTE_LIFETIME_BOUND, but
  keep the rvalue overload, since absl::GetFlag() returns a copy and
  consuming the view inside the full-expression is common and safe.

Document what the compiler does and does not catch at each declaration.

Fixes google#256
@dhivadhiva

Copy link
Copy Markdown
Author

Testing update: I've now checked this against real Crubit, not stand-in headers. I used Crubit f144a4f and nightly-2026-11-07, the pair ci/resolve_toolchain.sh resolves today, with clang 21.1.8.

  • The regex binding builds from this branch, and regex_example runs.
  • I compiled lifetime_verify.cc from the description against the real generated regex_cpp_bindings.h, using the same flags CMake uses for regex.cc. It passes: all five unsafe calls are diagnosed, and all the safe ones still compile.
  • The same test on main reports none of the five diagnostics.

@vabr-g vabr-g left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the PR! Looks good in general, I have one comment about amending the build config + two optional nits that are fine to ignore or do in a follow-up.

Comment thread regex/regex.h
#include <utility>
#include <vector>

#include "absl/base/attributes.h"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(regex/CMakeLists.txt:74-82). regex.h and regex_flag.h now include absl/base/attributes.h, so absl::core_headers should be added to the regex and regex_flag deps.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: added absl::core_headers to the regex target. There's no regex_flag target in regex/CMakeLists.txt (regex_flag.h is header-only and nothing here compiles it), so there's nothing to add it to on this side. If your internal BUILD has a separate regex_flag target, it needs the dep there too.

Comment thread regex/regex_flag.h Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: RegexFlagWithOptions::operator*, operator-> and get(), plus RegexListFlagWithOptions::begin()/end(), don't have ABSL_ATTRIBUTE_LIFETIME_BOUND. So absl::string_view p = absl::GetFlag(FLAGS_re)->AsStr(); gets around AsStr() const&& = delete and dangles. The header's own "BAD!" example (line 34) still compiles without a -Wdangling warning. Annotating these accessors fixes it, and calls like absl::GetFlag(FLAGS_re)->IsMatch(s) keep working.

It's OK not to add this here, the current change is already helpful without it. Just a suggestions for a related hardening.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. operator*, operator->, get(), begin() and end() are now ABSL_ATTRIBUTE_LIFETIME_BOUND, and the rvalue overloads stay. With clang 21, your ->AsStr() example and the header's "BAD!" example now get -Wdangling. absl::GetFlag(FLAGS_re)->IsMatch(s) and a range-for over a RegexListFlag still compile cleanly.

Comment thread regex/regex.h Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: CaptureNames(), FindAll(), FindAllCaptures(), Split() and Captures::GetAll() return ranges that borrow *this (and text) on the Rust side. Before C++23, for (auto n : Regex::Compile(pat)->CaptureNames()) destroys the temporary before the loop runs, which is a use-after-free in next(). Consider making them const& ABSL_ATTRIBUTE_LIFETIME_BOUND plus const&& = delete.

Also this is fully optional, OK not to include here, just a suggestion for similar hardening.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done for CaptureNames(), FindAll(), FindAllCaptures(), both Split() overloads and Captures::GetAll(). Your loop example, and the same loop over the other four, now get -Wdangling in C++20, and spellings that give an rvalue Regex hit the deleted overload. In C++23 mode clang doesn't warn on the loop, which is right, since P2718 keeps the temporary alive.

One gap is left, documented on GetAll(): re.FindCaptures(text)->GetAll() goes through std::optional::operator->, like the Inner() case. I left the text borrow unannotated. Find() and FindCaptures() have the same issue, so it seemed better as a separate change.

regex.h and regex_flag.h now include absl/base/attributes.h for
ABSL_ATTRIBUTE_LIFETIME_BOUND, so the regex target depends on
absl::core_headers. regex_flag.h is header-only and has no target in
this CMakeLists.txt.
Apply the same guards to two more groups of accessors, and document at
each declaration what the compiler does and does not catch.

RegexFlagWithOptions::operator*, operator-> and get(), and
RegexListFlagWithOptions::begin() and end(), now carry
ABSL_ATTRIBUTE_LIFETIME_BOUND. Before this, operator-> got around
AsStr() const&& = delete, so this compiled and dangled:

    absl::string_view p = absl::GetFlag(FLAGS_re)->AsStr();

and so did the header's own "BAD!" example. The rvalue overloads stay,
so absl::GetFlag(FLAGS_re)->IsMatch(s) keeps working.

Regex::CaptureNames(), FindAll(), FindAllCaptures(), both Split()
overloads and Captures::GetAll() return ranges that borrow *this on the
Rust side. Before C++23, a range-based for loop destroys the Regex
before the loop body runs:

    for (auto n : Regex::Compile(pat)->CaptureNames()) { ... }

These are now const& ABSL_ATTRIBUTE_LIFETIME_BOUND, and the const&&
overloads are deleted.

Not covered, and documented: the ranges also borrow `text`, which is
not annotated (Find() and FindCaptures() have the same gap), and a
temporary Captures reached through std::optional::operator-> is not
diagnosed.
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.

regex: Regex::AsStr() returns a view into the regex's own pattern with no lifetime annotation, so calls on a temporary dangle silently

2 participants