commit d79afab6903f07ccae1fe5b750fbaa371ea62599
parent 33d98c62e5201d8ce5e5436096615915e6d7e6c3
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Mon, 25 May 2026 13:12:26 -0700
build: enforce public libcfree exports
Diffstat:
5 files changed, 148 insertions(+), 18 deletions(-)
diff --git a/Makefile b/Makefile
@@ -155,7 +155,7 @@ LIB_OBJS = $(patsubst src/%.c,$(BUILD_DIR)/lib/%.o,$(LIB_SRCS)) \
$(LANG_OBJS) \
$(patsubst src/%.S,$(BUILD_DIR)/lib/%.o,$(LIB_ASMS))
LIB_DEPS = $(LIB_OBJS:.o=.d)
-LIB_AR_STAGING = $(BUILD_DIR)/.ar-staging/libcfree
+LIB_RELOC_OBJ = $(BUILD_DIR)/libcfree.o
DRIVER_SRCS = $(wildcard driver/*.c)
ifneq ($(CFREE_LANG_CPP_ENABLED),1)
@@ -177,22 +177,15 @@ bin: $(BIN)
# Replace the archive (`ar rcs` only adds/updates), so removing a .c file
# also removes its .o from the archive on the next build.
-$(LIB_AR): $(LIB_OBJS)
+$(LIB_RELOC_OBJ): $(LIB_OBJS)
@mkdir -p $(dir $@)
@rm -f $@
- @rm -rf $(LIB_AR_STAGING)
- @mkdir -p $(LIB_AR_STAGING)
- @set -e; \
- for obj in $(LIB_OBJS); do \
- rel=$${obj#$(BUILD_DIR)/}; \
- name=$$(printf '%s' "$$rel" | tr '/' '_'); \
- case "$$obj" in \
- /*) target="$$obj" ;; \
- *) target="$$PWD/$$obj" ;; \
- esac; \
- ln -sf "$$target" "$(LIB_AR_STAGING)/$$name"; \
- done
- $(AR) rcs $@ $(LIB_AR_STAGING)/*.o
+ $(LD) -r -o $@ $(LIB_OBJS)
+
+$(LIB_AR): $(LIB_RELOC_OBJ)
+ @mkdir -p $(dir $@)
+ @rm -f $@
+ $(AR) rcs $@ $(LIB_RELOC_OBJ)
$(BIN): $(DRIVER_OBJS) $(LIB_AR)
$(CC) $(HOST_SYSROOT_LDFLAGS) -o $@ $(DRIVER_OBJS) $(LIB_AR)
diff --git a/include/cfree/core.h b/include/cfree/core.h
@@ -16,6 +16,12 @@
#include <stddef.h>
#include <stdint.h>
+#if defined(__GNUC__) || defined(__clang__) || defined(__cfree__)
+#define CFREE_API __attribute__((visibility("default")))
+#else
+#define CFREE_API
+#endif
+
/* Opaque handles shared across component headers. */
typedef struct CfreeCompiler CfreeCompiler;
typedef struct CfreeCompileSession CfreeCompileSession;
diff --git a/scripts/lib_reloc_defined_prefixes.py b/scripts/lib_reloc_defined_prefixes.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Print disallowed global definitions after a relocatable archive link.
+
+The check extracts every object from libcfree.a, links them into one
+relocatable object, and reports any remaining externally visible definition
+whose C symbol name is not in libcfree's public prefix set.
+"""
+
+import argparse
+import os
+import pathlib
+import subprocess
+import sys
+import tempfile
+
+
+ALLOWED_PREFIXES = ("Cfree", "cfree_", "CFREE")
+
+
+def run(args: list[str], **kwargs) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(args, check=True, text=True, **kwargs)
+
+
+def nm_lines(path: pathlib.Path, nm: str) -> list[str]:
+ return run([nm, str(path)], capture_output=True).stdout.splitlines()
+
+
+def parse_nm_defined_globals(lines: list[str]) -> set[str]:
+ syms: set[str] = set()
+ for line in lines:
+ line = line.rstrip()
+ if not line or line.endswith(":"):
+ continue
+ parts = line.split(None, 2)
+ if len(parts) != 3:
+ continue
+ _, type_, name = parts
+ if type_ != "U" and type_.isupper():
+ syms.add(name)
+ return syms
+
+
+def public_c_name(sym: str) -> str:
+ if sym.startswith("_"):
+ stripped = sym[1:]
+ if stripped.startswith(ALLOWED_PREFIXES):
+ return stripped
+ return sym
+
+
+def is_allowed(sym: str) -> bool:
+ return public_c_name(sym).startswith(ALLOWED_PREFIXES)
+
+
+def link_relocatable(
+ archive: pathlib.Path, output: pathlib.Path, ar: str, cc: str
+) -> None:
+ archive = archive.resolve()
+ output = output.resolve()
+ with tempfile.TemporaryDirectory(prefix="cfree-lib-reloc.") as td:
+ work = pathlib.Path(td)
+ run([ar, "x", str(archive)], cwd=work)
+ objects = sorted(work.glob("*.o"))
+ if not objects:
+ raise RuntimeError(f"{archive} did not contain any .o members")
+
+ args = [cc, "-r", "-nostdlib", "-o", str(output)]
+ args.extend(str(obj) for obj in objects)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ run(args, capture_output=True)
+
+
+def disallowed_reloc_defs(
+ archive: pathlib.Path, output: pathlib.Path, ar: str, cc: str, nm: str
+) -> list[str]:
+ link_relocatable(archive, output, ar, cc)
+ return sorted(
+ public_c_name(sym)
+ for sym in parse_nm_defined_globals(nm_lines(output, nm))
+ if not is_allowed(sym)
+ )
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("archive", help="Path to libcfree.a")
+ ap.add_argument(
+ "--output",
+ default="build/libcfree.reloc.o",
+ help="Path for the temporary relocatable-link output",
+ )
+ ap.add_argument("--ar", default=os.environ.get("AR", "ar"), help="ar binary")
+ ap.add_argument("--cc", default=os.environ.get("CC", "cc"), help="C compiler driver")
+ ap.add_argument("--nm", default=os.environ.get("NM", "nm"), help="nm binary")
+ args = ap.parse_args()
+
+ try:
+ bad = disallowed_reloc_defs(
+ pathlib.Path(args.archive),
+ pathlib.Path(args.output),
+ args.ar,
+ args.cc,
+ args.nm,
+ )
+ except (FileNotFoundError, RuntimeError, subprocess.CalledProcessError) as e:
+ print(f"libcfree relocatable symbol check failed: {e}", file=sys.stderr)
+ if isinstance(e, subprocess.CalledProcessError) and e.stderr:
+ print(e.stderr, file=sys.stderr, end="")
+ return 2
+
+ for sym in bad:
+ print(sym)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/link/link_relocatable.c b/src/link/link_relocatable.c
@@ -190,6 +190,8 @@ static ObjSymId rel_copy_symbol(Linker* l, ObjBuilder* out,
ObjSecId sec = OBJ_SEC_NONE;
u64 value = s->value;
u64 align = s->common_align;
+ SymBind bind = (SymBind)s->bind;
+ SymVis vis = (SymVis)s->vis;
ObjSymId id;
if (!force_common && s->section_id != OBJ_SEC_NONE &&
s->section_id < maps[input_idx].nsection) {
@@ -201,7 +203,11 @@ static ObjSymId rel_copy_symbol(Linker* l, ObjBuilder* out,
value = 0;
align = common_align;
}
- id = obj_symbol_ex(out, s->name, (SymBind)s->bind, (SymVis)s->vis,
+ if (rel_sym_is_def(s) && (s->vis == SV_HIDDEN || s->vis == SV_INTERNAL)) {
+ bind = SB_LOCAL;
+ vis = SV_DEFAULT;
+ }
+ id = obj_symbol_ex(out, s->name, bind, vis,
force_common ? SK_COMMON : (SymKind)s->kind, sec, value,
force_common ? common_size : s->size, align);
if (id == OBJ_SYM_NONE)
diff --git a/test/test.mk b/test/test.mk
@@ -493,12 +493,20 @@ test-musl-rv64:
test-glibc-rv64:
@$(MAKE) test-glibc CFREE_LIBC_ARCHES=rv64
-# Fail if libcfree.a depends on any external symbol not in the allowlist.
-# Drift in either direction (new dep, or stale entry) is a failure.
+# Fail if libcfree.a depends on any external symbol not in the allowlist, or
+# if a relocatable link exposes non-public global definitions.
+# External dependency drift in either direction (new dep, or stale entry) is a
+# failure.
LIB_DEPS_ACTUAL = build/libcfree.deps.txt
+LIB_RELOC = build/libcfree.reloc.o
+LIB_RELOC_BAD = build/libcfree.reloc.bad-symbols.txt
test-lib-deps: lib
@mkdir -p $(dir $(LIB_DEPS_ACTUAL))
@python3 scripts/lib_external_deps.py $(LIB_AR) > $(LIB_DEPS_ACTUAL)
@diff -u test/lib_deps.allowlist $(LIB_DEPS_ACTUAL) \
|| { echo "libcfree.a external symbol set drifted from test/lib_deps.allowlist"; exit 1; }
+ @python3 scripts/lib_reloc_defined_prefixes.py $(LIB_AR) \
+ --output $(LIB_RELOC) --ar $(AR) --cc $(CC) > $(LIB_RELOC_BAD)
+ @test ! -s $(LIB_RELOC_BAD) \
+ || { echo "libcfree relocatable link exposes non-public symbols"; cat $(LIB_RELOC_BAD); exit 1; }