blob: b164474b3d606ef046e34047c2a5d516fa13b120 [file]
#!/usr/bin/env python3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
A tool to identify and remove unnecessary UNSAFE_TODO() wrappers in Chromium.
This script automates the verification process by locating all instances of
UNSAFE_TODO(), determining if the wrapper is redundant, and confirming that the
code is compiled in the current build configuration.
Because this process requires approximately 10k-40k compiler invocations, we
can't afford the latency of siso/remoteexec/autoninja for each. Thus, this tool
compiles locally a single file from the compile_commands.json database generated
by GN. This works because UNSAFE_TODO() is a compile-time check. The only
caveat are UNSAFE_TODO in templates instantiations, which are not verified by
this method.
The script supports state persistence via a JSON file, enabling the analysis to
resume without losing progress. It also supports multiple builders, allowing
verification of the same UNSAFE_TODO() across different configurations.
Usage:
./unsafe_todo_remover.py # Run analysis
./unsafe_todo_remover.py --apply # Apply fixes from out.json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
from collections import defaultdict
from collections.abc import Sequence
# --- Configuration ---
# State is persisted in this file. This allows resuming the analysis
# without losing progress.
STATE_FILE = "out.json"
# A set of builders covering most of the chromium C++ code.
#
# See infra/config/generated/builders/gn_args_locations.json for reference.
BUILDERS = [
# Linux builders
('tryserver.chromium.linux', 'linux-rel'),
('tryserver.chromium.linux', 'linux_chromium_tsan_rel_ng'),
('chromium.linux', 'Linux Builder (dbg)'),
('chromium.memory', 'Linux ASan LSan Builder'),
# Chromeos builders
('chromium.chromiumos', 'linux-cfm-rel'),
('chromium.chromiumos', 'linux-chromeos-rel'),
('tryserver.chromium.chromiumos', 'chromeos-libfuzzer-asan-rel'),
# Android builders
('chromium.android', 'android-x86-rel'),
('chromium', 'android-arm64-archive-rel'),
('chromium.android', 'android-14-x64-rel'),
('chromium.android', 'android-x86-rel'),
('chromium.fuzz', 'android-arm64-libfuzzer-hwasan'),
('tryserver.chromium.android', 'android-cronet-arm-dbg'),
('tryserver.chromium.android', 'android-webview-13-x64-dbg'),
# Fuchsia
('chromium.fuchsia', 'Deterministic Fuchsia (dbg)'),
# Mac
('chromium.mac', 'mac-arm64-dbg'),
('chromium.mac', 'mac-arm64-rel'),
('tryserver.chromium.mac', 'mac-rel'),
('tryserver.chromium.mac', 'mac-rel'),
# Dawn
('tryserver.chromium.dawn', 'dawn-win11-arm64-deps-rel'),
# Windows builders
('chromium.win', 'linux-win-cross-rel'),
('chromium.memory', 'win-asan'),
('chromium.win', 'Win Builder'),
('chromium.win', 'Win x64 Builder (dbg)'),
('chromium.win', 'linux-win-cross-rel'),
('chromium.win', 'win-arm64-rel'),
('tryserver.chromium.win', 'win-arm64-compile-dbg'),
('tryserver.chromium.win', 'win-libfuzzer-asan-rel'),
('tryserver.chromium.win', 'win-rel'),
('tryserver.chromium.win', 'win_optional_gpu_tests_rel'),
# Fuchsia cast
('chromium.fuchsia', 'fuchsia-x64-cast-receiver-rel'),
# Android cast
('chromium.android', "android-cast-arm-dbg"),
]
EXCLUDED_DIRECTORIES = [
# Non production code:
"agents/",
"tools/",
"build/",
# UNSAFE_TODO in template.
#
# C++ templates can explode into many instantiations, making it impractical
# to verify all of them. This script only build the file that contains the
# UNSAFE_TODO, so template instantiations in other files are not verified.
# Thus, we exclude files known to have UNSAFE_TODO in templates.
"base/strings/string_tokenizer.h",
"chrome/browser/media/router/discovery/discovery_network_list_posix.cc",
"components/zucchini/io_utils.h",
"components/zucchini/patch_utils.h",
"components/zucchini/rel32_utils.h",
"device/fido/cbor_extract.h",
"gpu/command_buffer/common/cmd_buffer_common.h",
"gpu/command_buffer/common/gles2_cmd_utils.h",
"mojo/core/broker_messages.h",
"mojo/public/cpp/bindings/lib/array_internal.h",
"mojo/public/cpp/bindings/lib/array_serialization.h",
"third_party/blink/renderer/core/css/parser/css_tokenizer_input_stream.h",
"third_party/blink/renderer/platform/fonts/opentype/open_type_types.h",
"ui/gfx/x/property_cache.h",
# UNSAFE_TODO wrapping specific macros.
#
# Those macros expands sometimes into code that triggers
# -WUnsafe-buffer-usage or not, depending on the platform. These variations
# are not supported by the script, so we exclude them.
"chrome/browser/media/router/discovery/discovery_network_list_posix.cc",
"net/base/network_interfaces_posix.cc",
"net/dns/address_info.cc",
"net/dns/loopback_only.cc",
"media/gpu/test/video_test_helpers.cc",
"net/cert/ct_objects_extractor.cc",
"sandbox/linux/syscall_broker/broker_simple_message.cc",
]
# List of functions that are considered unsafe and should not be unwrapped.
# There is not need to check the content of UNSAFE_TODO for these functions,
# as they are always unsafe. This is used to mark them as LIBC in the state
# file, which means they cannot be removed.
# This list is based on the Clang source code.
UNSAFE_LIBC_FUNCTIONS = {
"atof",
"atoi",
"atol",
"atoll",
"bcopy",
"bsearch",
"bzero",
"fgets",
"fgetws",
"fputs",
"fputws",
"fread",
"fwrite",
"gets",
"memccpy",
"memchr",
"memcmp",
"memcpy",
"memmove",
"mempcpy",
"memset",
"puts",
"qsort",
"strcasecmp",
"strcat",
"strchr",
"strcmp",
"strcoll",
"strcpy",
"strcspn",
"strdup",
"strerror_r",
"strerror_s",
"stricmp",
"strlcat",
"strlcpy",
"strlen",
"strncat",
"strncmp",
"strncpy",
"strndup",
"strnlen",
"strpbrk",
"strrchr",
"strspn",
"strstr",
"strtod",
"strtof",
"strtoimax",
"strtok",
"strtol",
"strtold",
"strtoll",
"strtoul",
"strtoull",
"strtoumax",
"strxfrm",
"wmemchr",
"wmemcmp",
"wmemcpy",
"wmemmove",
"wmemset",
}
# Regex to match: optional scope (:: or std::), one of the functions, followed
# by word boundary. Used to check the content inside UNSAFE_TODO(...).
LIBC_REGEX = re.compile(r'^\s*(?:(?:std)?::)?(?:' +
'|'.join(re.escape(f)
for f in UNSAFE_LIBC_FUNCTIONS) + r')\b')
# --- Helpers ---
class Colors:
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
MAGENTA = '\033[95m'
BLUE = '\033[94m'
RESET = '\033[0m'
def log(category: str, message: str):
"""Prints a categorized, colored message to stderr."""
cat_upper = category.upper()
color = Colors.RESET
if cat_upper in ("ERR", "FAIL", "ERROR"):
color = Colors.RED
elif cat_upper in ("WARN", "WARNING"):
color = Colors.YELLOW
elif cat_upper in ("OK", "SUCCESS", "PASS"):
color = Colors.GREEN
elif cat_upper in ("INFO", "SCAN", "EDIT", "FMT") or cat_upper.isdigit():
color = Colors.CYAN
elif cat_upper in ("BUILD", "GN", "BUILDER"):
color = Colors.MAGENTA
print(f"{color}[{cat_upper}]{Colors.RESET} {message}", file=sys.stderr)
def run_command(cmd: Sequence[str],
cwd: str | None = None,
capture_output: bool = True) -> subprocess.CompletedProcess:
"""Executes a subprocess command."""
try:
return subprocess.run(cmd,
cwd=cwd,
text=True,
capture_output=capture_output,
check=False)
except Exception as e:
log("ERR", f"Command execution failed: {cmd} -> {e}")
return subprocess.CompletedProcess(cmd, 1, "", str(e))
def is_excluded(path: str) -> bool:
"""Checks if a path should be ignored based on project rules."""
# 1. Only modify C++ files
valid_extensions = ('.cc', '.h', '.cpp', '.mm', '.hh')
if not path.endswith(valid_extensions):
return True
# 2. Specific directory exclusions
if path.startswith(tuple(EXCLUDED_DIRECTORIES)):
return True
# 3. Third party logic: Exclude all third_party EXCEPT third_party/blink
if path.startswith("third_party/"):
if not path.startswith("third_party/blink/"):
return True
return False
# --- Core Logic ---
class StateManager:
"""Manages the persistence of analysis results."""
def __init__(self, filepath: str):
self.filepath = filepath
# Structure: { file_key: { builder_name: status } }
self.data: dict[str, dict[str, str]] = {}
self.load()
def load(self):
if os.path.exists(self.filepath):
try:
with open(self.filepath, 'r') as f:
self.data = json.load(f)
except json.JSONDecodeError:
log("WARN", f"Corrupt {self.filepath}, starting fresh.")
def save(self):
with open(self.filepath, 'w') as f:
json.dump(self.data, f, indent=2, sort_keys=True)
def get_builder_status(self, key: str, builder: str) -> str | None:
"""Returns the status of a specific builder for the given key."""
entry = self.data.get(key, {})
# Handle legacy format where value was a string
if isinstance(entry, str):
return None
return entry.get(builder)
def set_status(self, key: str, builder: str, status: str):
"""Sets the status for a specific builder."""
if key not in self.data or not isinstance(self.data[key], dict):
self.data[key] = {}
self.data[key][builder] = status
self.save()
def is_resolved(self, key: str) -> bool:
"""
Returns True if the UNSAFE_TODO has a definitive result
(SUCCESS or FAIL) from *any* builder, or is marked as LIBC.
"""
if key not in self.data:
return False
entry = self.data[key]
if isinstance(entry, str):
# Legacy format support
return entry in ["SUCCESS", "FAIL", "LIBC"]
vals = entry.values()
return "SUCCESS" in vals or "FAIL" in vals or "LIBC" in vals
class SourceLocation:
"""Represents a specific UNSAFE_TODO instance."""
def __init__(self, filepath: str, line: int, col: int, content: str):
self.filepath = filepath
self.line = line
self.col = col
self.original_content = content
# Unique key for state tracking: "path/to/file.cc:10:5"
self.key = f"{filepath}:{line}:{col}"
def __repr__(self):
return f"{self.filepath}:{self.line}"
class BuildEnvironment:
"""Manages the GN/Ninja build environment."""
def __init__(self, builder_group: str, builder_config: str):
self.group = builder_group
self.config = builder_config
# Sanitize directory name to avoid spaces/parens.
# Allow alphanumeric, underscore, dash. Replace others with underscore.
safe_dirname = re.sub(r'[^a-zA-Z0-9_-]', '_', builder_config)
self.build_dir = os.path.join("out", safe_dirname)
self.is_ready = False
self.compile_db: dict[str, str] = {}
def ensure_ready(self):
"""Sets up the build directory if it doesn't exist."""
if self.is_ready:
return
if not os.path.exists(self.build_dir):
log("BUILD", f"Setting up builder: {self.config}...")
cmd = [
"./tools/mb/mb.py", "gen", "-m", self.group, "-b", self.config,
self.build_dir
]
res = run_command(cmd)
if res.returncode != 0:
log("ERR",
f"Failed to generate build files for {self.config}: {res.stderr}")
raise RuntimeError("MB gen failed")
# Always regenerate compile_commands.json to ensure it's fresh
log("GN", f"Generating compile_commands.json for {self.config}...")
res = run_command(
["gn", "gen", self.build_dir, "--export-compile-commands"])
if res.returncode != 0:
log("ERR", f"Failed to export compile commands: {res.stderr}")
raise RuntimeError("GN gen failed")
# Build everything to ensure generated files (headers, etc.) are present.
log("BUILD",
f"Building all targets in {self.config} (this may take a while)...")
run_command(["autoninja", "-C", self.build_dir], capture_output=False)
self._load_compile_db()
self.is_ready = True
def _load_compile_db(self):
"""Loads the compile_commands.json database."""
db_path = os.path.join(self.build_dir, "compile_commands.json")
try:
with open(db_path, 'r') as f:
data = json.load(f)
self.compile_db = {}
for entry in data:
directory = entry.get('directory', '.')
filepath = entry.get('file', '')
if not filepath:
continue
abs_path = os.path.abspath(os.path.join(directory, filepath))
self.compile_db[abs_path] = entry.get('command', '')
except (IOError, json.JSONDecodeError) as e:
log("WARN", f"Failed to load compile_commands.json: {e}")
def has_file(self, filepath: str) -> bool:
"""Checks if the file exists in the compile database."""
abs_path = os.path.abspath(filepath)
return abs_path in self.compile_db
def find_compilable_source(self, filepath: str) -> str | None:
"""Returns a file that can be compiled for the given filepath."""
abs_path = os.path.abspath(filepath)
if abs_path in self.compile_db:
return filepath
# Header fallback (Source/Header proxy)
if filepath.endswith(('.h', '.hh', '.hpp')):
base = os.path.splitext(filepath)[0]
for ext in ['.cc', '.cpp', '.mm']:
surrogate = base + ext
abs_surrogate = os.path.abspath(surrogate)
if abs_surrogate in self.compile_db:
return surrogate
return None
def compile_file(self, filepath: str) -> bool:
"""
Compiles a single file using the `compile_commands.json` database.
"""
compilable_source = self.find_compilable_source(filepath)
if not compilable_source:
return False
cmd_list = shlex.split(self.compile_db[os.path.abspath(compilable_source)])
res = run_command(cmd_list, cwd=self.build_dir, capture_output=False)
return res.returncode == 0
class CodeModifier:
"""Handles parsing and modifying source files."""
@staticmethod
def find_unsafe_todos() -> Sequence[SourceLocation]:
"""Scans the codebase for UNSAFE_TODO macros."""
log("SCAN", "Scanning for UNSAFE_TODO instances...")
cmd = [
"git", "grep", "-n", "--column", "-w", "UNSAFE_TODO", "--", "*.cc",
"*.h", "*.cpp", "*.mm", "*.hh"
]
res = run_command(cmd)
lines = res.stdout.strip().splitlines()
locations = []
for line in lines:
parts = line.split(":", 3) # path:line:col:content
if len(parts) < 4:
continue
fpath = parts[0]
linenum = int(parts[1])
colnum = int(parts[2])
if is_excluded(fpath):
continue
locations.append(SourceLocation(fpath, linenum, colnum, ""))
return locations
@staticmethod
def _read_file(filepath: str) -> Sequence[str]:
with open(filepath, 'r') as f:
return f.readlines()
@staticmethod
def _write_file(filepath: str, lines: Sequence[str]):
with open(filepath, 'w') as f:
f.writelines(lines)
@staticmethod
def replace_macro(loc: SourceLocation):
"""
Simple string replacement on the specific line.
We replace UNSAFE_TODO with UNSAFE_XXXX.
"""
lines = CodeModifier._read_file(loc.filepath)
line_idx = loc.line - 1
col_idx = loc.col - 1
original_line = lines[line_idx]
lines[line_idx] = (original_line[:col_idx] + "UNSAFE_XXXX" +
original_line[col_idx + len("UNSAFE_TODO"):])
CodeModifier._write_file(loc.filepath, lines)
@staticmethod
def _remove_wrapper_from_content(content: str, line: int, col: int) -> str:
"""
Internal logic to remove one wrapper from string content.
Note: Relying on parenthesis matching is "good" enough here, most of the
time. Failures will simply skip the removal.
"""
# Find the line start offset
lines = content.splitlines(keepends=True)
offset = 0
for i in range(line - 1):
offset += len(lines[i])
offset += (col - 1)
# Verify we are at UNSAFE_TODO
if not content[offset:].startswith("UNSAFE_TODO"):
log("WARN", f"Offset mismatch at {line}:{col}. Skipping...")
return content
# Find opening paren
open_paren = content.find("(", offset)
if open_paren == -1:
return content
# Find matching closing paren
balance = 1
close_paren = -1
for i in range(open_paren + 1, len(content)):
if content[i] == "(":
balance += 1
elif content[i] == ")":
balance -= 1
if balance == 0:
close_paren = i
break
if close_paren == -1:
return content
# Extract inner content
inner = content[open_paren + 1:close_paren]
# Reconstruct
new_content = content[:offset] + inner + content[close_paren + 1:]
return new_content
@staticmethod
def remove_wrapper(loc: SourceLocation):
"""Removes UNSAFE_TODO(...) wrapper, keeping inner content."""
try:
with open(loc.filepath, 'r') as f:
content = f.read()
new_content = CodeModifier._remove_wrapper_from_content(
content, loc.line, loc.col)
if content != new_content:
with open(loc.filepath, 'w') as f:
f.write(new_content)
except Exception as e:
log("ERR", f"Failed to modify {loc}: {e}")
@staticmethod
def is_libc_call(loc: SourceLocation) -> bool:
"""Checks if the UNSAFE_TODO wraps a known unsafe libc function."""
try:
with open(loc.filepath, 'r') as f:
content = f.read()
# Find start offset based on line/col
lines = content.splitlines(keepends=True)
offset = 0
for i in range(loc.line - 1):
offset += len(lines[i])
offset += (loc.col - 1)
if not content[offset:].startswith("UNSAFE_TODO"):
return False
open_paren = content.find("(", offset)
if open_paren == -1: return False
balance = 1
close_paren = -1
for i in range(open_paren + 1, len(content)):
if content[i] == "(": balance += 1
elif content[i] == ")": balance -= 1
if balance == 0:
close_paren = i
break
if close_paren == -1: return False
inner = content[open_paren + 1:close_paren].strip()
return bool(LIBC_REGEX.match(inner))
except Exception:
return False
@staticmethod
def remove_wrappers(locs: Sequence[SourceLocation]):
"""Removes multiple wrappers from the same file efficiently."""
if not locs:
return
filepath = locs[0].filepath
try:
with open(filepath, 'r') as f:
content = f.read()
# Sort in reverse order (bottom-up, right-to-left) to preserve offsets
# of earlier modifications.
sorted_locs = sorted(locs, key=lambda x: (x.line, x.col), reverse=True)
original_content = content
for loc in sorted_locs:
content = CodeModifier._remove_wrapper_from_content(
content, loc.line, loc.col)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
except Exception as e:
log("ERR", f"Failed to batch modify {filepath}: {e}")
@staticmethod
def revert_file(filepath: str):
"""Restores file using git checkout."""
run_command(["git", "checkout", filepath])
# --- Main Analysis Loop ---
def analyze_todos(args):
state = StateManager(STATE_FILE)
todos = CodeModifier.find_unsafe_todos()
log("INFO", f"Found {len(todos)} UNSAFE_TODO candidates.")
# Pre-scan for LIBC functions
log("INFO", "Checking for known LIBC functions...")
libc_count = 0
for todo in todos:
if state.is_resolved(todo.key):
continue
if CodeModifier.is_libc_call(todo):
state.set_status(todo.key, "scanner", "LIBC")
libc_count += 1
if libc_count > 0:
log("INFO", f"Marked {libc_count} UNSAFE_TODOs as LIBC (non-removable).")
# Group TODOs by file
todos_by_file = defaultdict(list)
for todo in todos:
if not state.is_resolved(todo.key):
todos_by_file[todo.filepath].append(todo)
log("INFO", f"{len(todos_by_file)} files remain unresolved.")
# Initialize builders
builders = [BuildEnvironment(g, c) for g, c in BUILDERS]
for builder in builders:
try:
builder.ensure_ready()
except Exception as e:
log("ERR", f"Skipping builder {builder.config} due to setup failure: {e}")
continue
log("BUILDER", f"Processing files on builder: {builder.config}...")
processed_files_count = 0
filenames = list(todos_by_file.keys())
for filepath in filenames:
file_todos = todos_by_file[filepath]
# 1. Check if the file is already resolved
unresolved_todos = [t for t in file_todos if not state.is_resolved(t.key)]
if not unresolved_todos:
continue
# 2. Check if this builder has already run for these TODOs
todos_to_check = [
t for t in unresolved_todos
if not state.get_builder_status(t.key, builder.config)
]
if not todos_to_check:
continue
processed_files_count += 1
log(str(processed_files_count),
f"Processing {filepath} ({len(todos_to_check)} items)...")
try:
# --- STEP 0: Skip files that can't be compiled ---
if not builder.find_compilable_source(filepath):
log("WARN",
f"Cannot compile {filepath} on {builder.config}. Skipping.")
for todo in todos_to_check:
state.set_status(todo.key, builder.config, "SKIP")
continue
# --- STEP 1: Individual Checks ---
for todo in todos_to_check:
# Inverted Check Logic:
# 1. First, check if removing the wrapper compile.
# 2. Second, check if the line is active under this builder.
# 1. Remove wrapper
CodeModifier.remove_wrapper(todo)
safety_pass = builder.compile_file(filepath)
CodeModifier.revert_file(filepath)
if not safety_pass:
# Removing the wrapper broke the build. It is required.
log("FAIL", f"Line {todo.line}: Unsafe (wrapper required).")
state.set_status(todo.key, builder.config, "FAIL")
continue
# 2. Rename UNSAFE_TODO -> UNSAFE_XXXX to verify usage.
CodeModifier.replace_macro(todo)
usage_pass = builder.compile_file(filepath)
CodeModifier.revert_file(filepath)
if usage_pass:
# UNSAFE_XXXX compiled successfully -> Macro content is dead code on
# this builder (compiler didn't see error). We record SKIP so other
# builders can try to verify it.
state.set_status(todo.key, builder.config, "SKIP")
else:
# UNSAFE_XXXX failed to compile -> Code is live. Since safety_pass
# was True (removal worked), this is a confirmed Success.
log("OK", f"Line {todo.line}: Safe.")
state.set_status(todo.key, builder.config, "SUCCESS")
except KeyboardInterrupt:
log("WARN", f"Interrupted. Reverting {filepath}...")
CodeModifier.revert_file(filepath)
sys.exit(1)
except Exception as e:
log("ERR", f"Unexpected error processing {filepath}: {e}")
CodeModifier.revert_file(filepath)
def apply_fixes(args):
"""Reads out.json and applies changes for SUCCESS entries."""
state = StateManager(STATE_FILE)
if not state.data:
log("WARN", "No state file found or file empty. Run analysis first.")
return
# Group by file to minimize IO and handle multiple edits per file
files_to_edit = {}
# Iterate over all keys in state
for key, builder_status_map in state.data.items():
# Check for legacy format
if not isinstance(builder_status_map, dict):
status = builder_status_map
is_success = (status == "SUCCESS")
else:
# Check if ANY builder succeeded
is_success = "SUCCESS" in builder_status_map.values()
if not is_success:
continue
parts = key.rsplit(":", 2)
fpath = parts[0]
line = int(parts[1])
col = int(parts[2])
if fpath not in files_to_edit:
files_to_edit[fpath] = []
files_to_edit[fpath].append(SourceLocation(fpath, line, col, ""))
log("INFO", f"Applying fixes to {len(files_to_edit)} files...")
for fpath, locs in files_to_edit.items():
log("EDIT", f"Updating {fpath} ({len(locs)} fixes)...")
CodeModifier.remove_wrappers(locs)
# Run git cl format on modified files
if files_to_edit:
log("FMT", "Running git cl format...")
run_command(["git", "cl", "format"] + list(files_to_edit.keys()))
def main():
parser = argparse.ArgumentParser(description="Cleanup UNSAFE_TODO macros")
parser.add_argument("--apply",
action="store_true",
help="Apply fixes based on out.json")
args = parser.parse_args()
if args.apply:
apply_fixes(args)
else:
analyze_todos(args)
if __name__ == "__main__":
main()