Skip to content

Commit 65cd16b

Browse files
committed
FEATURE-ci-macos-unwind: bind Darwin unwind trust to selected LLVM
Export the selected compiler prefix and pass it explicitly through both Mac package workflows, including sudo execution. Classify only canonical numeric libunwind dylibs directly in that prefix's lib/unwind directory, retaining full graph inspection and strict provider policy. Verified TDD policy and workflow regressions, 40 audit tests, 7 platform tests, 4 package-workflow tests, 14 release-workflow tests, actionlint and syntax/diff checks. Native macOS package/archive checks remain CI-owned.
1 parent 6a1709d commit 65cd16b

7 files changed

Lines changed: 196 additions & 4 deletions

‎.github/workflows/v3-package-validation.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
if ! python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",443)); s.close()'; then
7373
prefix=(sudo env "PATH=$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory "GIT_CONFIG_VALUE_0=$PWD")
7474
fi
75-
"${prefix[@]}" python3 scripts/check-v3-installed-consumer.py --output "$RUNNER_TEMP/package-evidence/matrix" --provider-prefix "$RUNNER_TEMP/package-evidence/prerequisites/provider" --pkg-config-dir "$(brew --prefix)/share/pkgconfig" --client-python "$RUNNER_TEMP/package-evidence/clients/bin/python" --node node --compiler "$V3_CXX" --cc "$V3_CC" --jobs 2
75+
"${prefix[@]}" python3 scripts/check-v3-installed-consumer.py --output "$RUNNER_TEMP/package-evidence/matrix" --provider-prefix "$RUNNER_TEMP/package-evidence/prerequisites/provider" --pkg-config-dir "$(brew --prefix)/share/pkgconfig" --client-python "$RUNNER_TEMP/package-evidence/clients/bin/python" --node node --compiler "$V3_CXX" --cc "$V3_CC" --toolchain-prefix "$V3_PACKAGE_TOOLCHAIN" --jobs 2
7676
- name: Make privileged proof readable by artifact upload
7777
if: always()
7878
run: |

‎.github/workflows/v3-release-acceptance.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ jobs:
122122
if ! python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",443)); s.close()'; then
123123
prefix=(sudo env "PATH=$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory "GIT_CONFIG_VALUE_0=$PWD")
124124
fi
125-
"${prefix[@]}" python3 scripts/run-v3-release-ci.py platform --selection "$RUNNER_TEMP/selected/selected.json" --archive "$RUNNER_TEMP/selected/"libhttpserver-*.tar.gz --provider-prefix "$RUNNER_TEMP/prerequisites/provider" --client-python "$RUNNER_TEMP/clients/bin/python" --node node --compiler "$V3_CXX" --cc "$V3_CC" --output "$RUNNER_TEMP/platform" --pkg-config-dir "$(brew --prefix)/share/pkgconfig"
125+
"${prefix[@]}" python3 scripts/run-v3-release-ci.py platform --selection "$RUNNER_TEMP/selected/selected.json" --archive "$RUNNER_TEMP/selected/"libhttpserver-*.tar.gz --provider-prefix "$RUNNER_TEMP/prerequisites/provider" --client-python "$RUNNER_TEMP/clients/bin/python" --node node --compiler "$V3_CXX" --cc "$V3_CC" --toolchain-prefix "$V3_PACKAGE_TOOLCHAIN" --output "$RUNNER_TEMP/platform" --pkg-config-dir "$(brew --prefix)/share/pkgconfig"
126126
- name: Make privileged proof readable by artifact upload
127127
if: always()
128128
run: |

‎scripts/audit-v3-installed-package.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ def runtime(path):
7272
return bool(toolchain and location.is_relative_to(Path(toolchain).resolve()) and
7373
any(fnmatch.fnmatch(location.name.lower(), pattern) for pattern in
7474
('libstdc++-*.dll', 'libgcc_s_*.dll', 'libwinpthread-*.dll')))
75+
if platform.system() == 'Darwin':
76+
toolchain = os.environ.get('V3_PACKAGE_TOOLCHAIN')
77+
location = Path(path).resolve()
78+
if (toolchain and location.parent == Path(toolchain).resolve() / 'lib/unwind'
79+
and re.fullmatch(r'libunwind(?:\.[0-9]+)+\.dylib', location.name)):
80+
return True
7581
trusted_roots = ('/libexec', '/usr/lib', '/lib', '/lib64', '/usr/lib64', '/usr/local/lib/gcc', '/opt/homebrew/Cellar/gcc', '/opt/homebrew/Cellar/llvm')
7682
return (any(fnmatch.fnmatch(Path(path).name, pattern) for pattern in RUNTIME)
7783
and any(Path(path).is_relative_to(Path(root)) for root in trusted_roots))

‎scripts/ci-macos-toolchain.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ CPP
3636
"$toolchain_dir/clang++" --version > "$toolchain_dir/compiler.log"
3737
"$toolchain_dir/clang++" -std=c++20 "$toolchain_dir/cancellation.cpp" -o "$toolchain_dir/cancellation"
3838
"$toolchain_dir/cancellation"
39-
printf 'V3_CXX=%s\nV3_CC=%s\n' "$toolchain_dir/clang++" "$llvm_prefix/bin/clang" >> "$GITHUB_ENV"
39+
printf 'V3_CXX=%s\nV3_CC=%s\nV3_PACKAGE_TOOLCHAIN=%s\n' "$toolchain_dir/clang++" "$llvm_prefix/bin/clang" "$llvm_prefix" >> "$GITHUB_ENV"

‎scripts/test-v3-installed-package.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def setUp(self):
8585
environment.start()
8686
self.addCleanup(environment.stop)
8787
os.environ.pop('V3_PACKAGE_PREREQUISITES', None)
88+
os.environ.pop('V3_PACKAGE_TOOLCHAIN', None)
8889
self.tmp = tempfile.TemporaryDirectory()
8990
self.addCleanup(self.tmp.cleanup)
9091
self.root = Path(self.tmp.name)
@@ -212,6 +213,84 @@ def test_selected_native_provider_only(self):
212213
self.assertTrue(self.violations(graph, tls='no', surface='native'))
213214
self.assertTrue(audit.graph_violations(graph, 'strict', 'yes', 'native', self.policy, self.root / 'other-provider'))
214215

216+
def test_selected_darwin_unwind_accepts_observed_llvm_closure_only(self):
217+
prefix = Path('/opt/homebrew/Cellar/llvm/23.1.0')
218+
cxx = str(prefix / 'lib/c++/libc++.1.0.dylib')
219+
abi = str(prefix / 'lib/c++/libc++abi.1.0.dylib')
220+
unwind = str(prefix / 'lib/unwind/libunwind.1.0.dylib')
221+
graph = {'nodes': [{'id': path} for path in ('consumer', cxx, abi, unwind)],
222+
'edges': [{'parent': parent, 'name': child, 'child': child} for parent, child in
223+
[('consumer', cxx), (cxx, abi), (cxx, unwind), (abi, unwind)]]}
224+
with patch.object(audit.platform, 'system', return_value='Darwin'):
225+
for selected in ('', str(prefix.parent / '22.0.0'), str(prefix)):
226+
with self.subTest(selected=selected), patch.dict(os.environ, V3_PACKAGE_TOOLCHAIN=selected):
227+
errors = self.violations(graph)
228+
if selected == str(prefix):
229+
self.assertEqual([], errors)
230+
else:
231+
self.assertEqual([f'undeclared dependency (aggregate/strict): {parent} -> {unwind}'
232+
for parent in (cxx, abi)], errors)
233+
234+
def test_selected_darwin_unwind_requires_canonical_directory_and_numeric_name(self):
235+
prefix = self.root / 'llvm/23.1.0'
236+
directory = prefix / 'lib/unwind'
237+
directory.mkdir(parents=True)
238+
target = directory / 'libunwind.1.0.dylib'
239+
target.write_text('selected runtime')
240+
alias = self.root / 'selected llvm'
241+
alias.symlink_to(prefix, target_is_directory=True)
242+
link = directory / 'libunwind.dylib'
243+
link.symlink_to(target.name)
244+
with patch.object(audit.platform, 'system', return_value='Darwin'), \
245+
patch.dict(os.environ, V3_PACKAGE_TOOLCHAIN=str(alias)):
246+
self.assertTrue(audit.runtime(str(target)))
247+
self.assertTrue(audit.runtime(str(link)))
248+
for path in (prefix / 'lib/libunwind.1.0.dylib', prefix / 'lib/c++/libunwind.1.0.dylib',
249+
prefix.parent / 'other/lib/unwind/libunwind.1.0.dylib',
250+
directory / '../libunwind.1.0.dylib', '/usr/lib/libunwind.1.dylib',
251+
directory / 'libunwind.bad.dylib',
252+
directory / 'libunwind.1.dylib.extra', directory / 'libunwind.1..0.dylib',
253+
directory / 'libcurl.4.dylib'):
254+
with self.subTest(path=str(path)):
255+
self.assertFalse(audit.runtime(str(path)))
256+
outside = self.root / 'libunwind.2.dylib'
257+
outside.touch()
258+
escape = directory / 'libunwind.2.dylib'
259+
escape.symlink_to(outside)
260+
self.assertFalse(audit.runtime(str(escape)))
261+
target.unlink()
262+
directory.rename(prefix / 'lib/sibling')
263+
directory.symlink_to(prefix / 'lib/sibling', target_is_directory=True)
264+
(directory / target.name).touch()
265+
self.assertFalse(audit.runtime(str(directory / target.name)))
266+
267+
def test_selected_unwind_still_inspects_hashes_and_rejects_transitive_provider(self):
268+
prefix = self.root / 'llvm'
269+
directory = prefix / 'lib/unwind'
270+
directory.mkdir(parents=True)
271+
consumer, unwind, forbidden = self.root / 'consumer', directory / 'libunwind.1.dylib', self.root / 'libcrypto.dylib'
272+
links = {str(consumer): [str(unwind)], str(unwind): [str(forbidden)], str(forbidden): []}
273+
for path in links:
274+
Path(path).write_text('inspected ' + path)
275+
inspected = []
276+
def inspect(path):
277+
inspected.append(path)
278+
return [(child, child) for child in links[path]]
279+
with patch.object(audit.platform, 'system', return_value='Darwin'), \
280+
patch.dict(os.environ, V3_PACKAGE_TOOLCHAIN=str(prefix)):
281+
graph = audit.dependency_graph([str(consumer)], inspect)
282+
self.assertEqual(set(links), set(inspected))
283+
self.assertEqual({path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in links},
284+
{node['id']: node['sha256'] for node in graph['nodes']})
285+
self.assertEqual(['unselected native provider: ' + str(forbidden)], audit.graph_violations(
286+
graph, 'strict', 'yes', 'aggregate', self.policy, self.root / 'provider'))
287+
forbidden.rename(self.root / 'libmicrohttpd.dylib')
288+
with self.assertRaisesRegex(audit.AuditError, 'unresolved dependency'):
289+
audit.dependency_graph([str(consumer)], inspect)
290+
with patch.object(audit.platform, 'system', return_value='Linux'), \
291+
patch.dict(os.environ, V3_PACKAGE_TOOLCHAIN=str(prefix)):
292+
self.assertFalse(audit.runtime(str(unwind)))
293+
215294
def test_unknown_system_library_is_not_allowed(self):
216295
graph = {'nodes': [{'id': 'consumer'}, {'id': '/usr/lib/libsurprise.dylib'}],
217296
'edges': [{'parent': 'consumer', 'name': '/usr/lib/libsurprise.dylib', 'child': '/usr/lib/libsurprise.dylib'}]}

‎scripts/test-v3-platform-ci.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import argparse
55
import importlib.util
66
import os
7+
import re
78
import shlex
89
import shutil
910
import sys
1011
from pathlib import Path
1112
import subprocess
1213
import tempfile
14+
import textwrap
1315
import unittest
1416
from unittest.mock import patch
1517

@@ -31,7 +33,7 @@ def test_native_platform_matrix_receives_matching_provider_cli(self):
3133
args = argparse.Namespace(selection=selection, output=root / 'output',
3234
archive=root / 'source.tar.gz', provider_prefix=root / 'provider',
3335
compiler='clang++', cc='clang', make='make', jobs=2,
34-
client_python='selected-python', node='node', pkg_config_dir=[], toolchain_prefix=None)
36+
client_python='selected-python', node='node', pkg_config_dir=[], toolchain_prefix=root / 'selected llvm')
3537
observed = []
3638

3739
class MatrixBoundary(Exception):
@@ -51,6 +53,8 @@ def command(argv, cwd, env, **kwargs):
5153
with self.assertRaises(MatrixBoundary):
5254
entrypoint.platform_proof(args)
5355
self.assertEqual(len(observed), 1)
56+
argv = observed[0][0]
57+
self.assertEqual(argv[argv.index('--toolchain-prefix') + 1], root / 'selected llvm')
5458
cli = 'openssl.exe' if os.name == 'nt' else 'openssl'
5559
self.assertEqual(observed[0][1]['V3_OPENSSL'], str(root / 'provider/bin' / cli))
5660
self.assertEqual(observed[0][1]['LD_LIBRARY_PATH'], str(root / 'provider/lib'))
@@ -97,6 +101,7 @@ def test_wrapper_selects_matching_library_and_preserves_arguments(self):
97101
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
98102
selected = dict(line.split('=', 1) for line in Path(env['GITHUB_ENV']).read_text().splitlines())
99103
self.assertEqual(selected['V3_CC'], str(llvm / 'bin/clang'))
104+
self.assertEqual(selected.get('V3_PACKAGE_TOOLCHAIN'), str(llvm))
100105
result = subprocess.run([selected['V3_CXX'], 'path with spaces.cpp', '-o', str(root / 'consumer')], env=env)
101106
self.assertEqual(result.returncode, 0)
102107
calls = [json.loads(line) for line in Path(env['INVOCATIONS']).read_text().splitlines()]
@@ -110,6 +115,47 @@ def test_wrapper_selects_matching_library_and_preserves_arguments(self):
110115
self.assertIn('-L' + str(llvm / 'lib/c++'), calls[-1])
111116
self.assertIn('-Wl,-rpath,' + str(llvm / 'lib/c++'), calls[-1])
112117

118+
def test_workflows_pass_selected_prefix_even_when_sudo_cleans_environment(self):
119+
for workflow, job, entrypoint in (
120+
('v3-package-validation.yml', 'macos', 'scripts/check-v3-installed-consumer.py'),
121+
('v3-release-acceptance.yml', 'darwin', 'scripts/run-v3-release-ci.py')):
122+
text = (ROOT / '.github/workflows' / workflow).read_text()
123+
job_text = re.split(r'^ [a-z0-9_-]+:', text.split(' ' + job + ':', 1)[1], maxsplit=1, flags=re.M)[0]
124+
lines = job_text.splitlines()
125+
invocation = next(i for i, line in enumerate(lines) if entrypoint in line and '${prefix[@]}' in line)
126+
start = next(i for i in range(invocation, -1, -1) if 'run: |' in lines[i])
127+
block = textwrap.dedent('\n'.join(lines[start + 1:invocation + 1]))
128+
for privileged in (False, True):
129+
with self.subTest(workflow=workflow, privileged=privileged), tempfile.TemporaryDirectory() as directory:
130+
root = Path(directory)
131+
result, env, llvm = self.prepare(root)
132+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
133+
selected = dict(line.split('=', 1) for line in Path(env['GITHUB_ENV']).read_text().splitlines())
134+
# Exercise the workflow's shell and argv; only native tools are substituted.
135+
python = root / 'tools/python3'
136+
python.write_text('#!' + sys.executable + '\n' + textwrap.dedent("""\
137+
import json, os, sys
138+
if sys.argv[1] == '-c':
139+
sys.exit(int(os.environ['PRIVILEGED']))
140+
with open(os.environ['CAPTURE'], 'w') as stream:
141+
json.dump({'argv': sys.argv[1:], 'toolchain_environment': os.environ.get('V3_PACKAGE_TOOLCHAIN')}, stream)
142+
"""))
143+
python.chmod(0o755)
144+
sudo = root / 'tools/sudo'
145+
sudo.write_text('#!/bin/sh\nexec env -i CAPTURE="$CAPTURE" "$@"\n')
146+
sudo.chmod(0o755)
147+
result = subprocess.run(['bash', '-e', '-c', block], cwd=ROOT,
148+
env=dict(env, **selected, PRIVILEGED=str(int(privileged)), CAPTURE=str(root / 'captured.json')),
149+
capture_output=True, text=True)
150+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
151+
captured = json.loads((root / 'captured.json').read_text())
152+
argv = captured['argv']
153+
self.assertEqual(argv[argv.index('--toolchain-prefix') + 1] if '--toolchain-prefix' in argv else None, str(llvm))
154+
self.assertEqual(argv[argv.index('--compiler') + 1], selected['V3_CXX'])
155+
self.assertEqual(argv[argv.index('--cc') + 1], selected['V3_CC'])
156+
if privileged:
157+
self.assertIsNone(captured['toolchain_environment'])
158+
113159
def test_nonfinal_modes_do_not_receive_runtime_linker_flags(self):
114160
with tempfile.TemporaryDirectory() as directory:
115161
root = Path(directory)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# FEATURE-ci-macos-unwind: selected Darwin LLVM unwind runtime
2+
3+
Status: Implemented; local focused checks pass. Caller-owned Groundwork validation
4+
and finalization are pending. Native macOS package/archive execution remains
5+
CI / v3 PR owned. Base: `v3` at
6+
`6a1709daf6d5b51dac18a85c50030aeddd4cc570`.
7+
8+
Mac package job120396472543 in run39948363870 resolved LLVM23 libc++ and
9+
libc++abi dependencies on `lib/unwind/libunwind.1.0.dylib`, then rejected both
10+
edges as undeclared during the first private strict aggregate audit. The
11+
original bounded diagnosis and native log remain unchanged at root
12+
`.debug/FEATURE-ci-macos-unwind/run-taafd3p6/`.
13+
14+
The compiler selector now exports `V3_PACKAGE_TOOLCHAIN` from the same LLVM
15+
prefix that supplies its compiler and C++ runtime. Both Mac workflows pass
16+
that selection explicitly via the existing `--toolchain-prefix` option, so
17+
privileged execution retains it even when sudo filters the environment.
18+
Darwin runtime classification accepts only numerically versioned libunwind
19+
dylibs whose resolved parent is the canonical selected prefix's `lib/unwind`.
20+
Selected-prefix symlinks and dylib aliases to those files work; other directories,
21+
versions, malformed names, unrelated libraries and symlink escapes reject.
22+
Global runtime patterns, dyld-cache leaves and graph inspection remain unchanged.
23+
24+
Action items:
25+
26+
- [x] Create the isolated Groundwork worktree from the recorded v3 head;
27+
preserve the Windows entropy repair, bauth worktree and other prior fixes.
28+
- [x] Capture failing actual graph-policy tests reproducing the two native edges
29+
and failing compiler/workflow propagation tests before production edits.
30+
- [x] Export the selected LLVM prefix and pass it explicitly through both Mac
31+
package and selected-archive workflow commands, including their sudo paths.
32+
- [x] Restrict Darwin unwind classification to the canonical selected directory
33+
and numeric versioned basename; preserve strict provider and forbidden-library
34+
checks, real-file inspection, hashes and transitive traversal.
35+
- [x] Refute trust-boundary doubts using canonicalization/escape/negative controls,
36+
executed workflow shell blocks with a cleaned privileged environment, actual
37+
release dispatcher forwarding, and graph/hash/provider regressions.
38+
- [x] Run affected audit, platform, package-workflow and release-workflow tests;
39+
actionlint, Python compilation, Bash syntax and diff checks pass.
40+
- [x] Retain raw RED/GREEN logs, corrected fixture setup, doubts and command
41+
results under root `.debug/FEATURE-ci-macos-unwind/repair-selected-prefix/`.
42+
- [ ] Caller: Groundwork validation and local finalization into v3.
43+
- [ ] CI / v3 PR: genuine native macOS package and selected-archive execution.
44+
45+
Local verification: 40 installed-package policy/provenance tests, 7 platform
46+
execution tests, 4 package-workflow tests and 14 release-workflow contract tests
47+
pass. The new workflow test executes the actual Mac shell command blocks in
48+
both direct and simulated environment-cleaning sudo paths, with substituted
49+
native tool boundaries; it checks selected compiler and prefix arguments.
50+
These are policy/propagation fixtures, not native macOS installed-package proof.
51+
No full native baseline, CI rerun, Linux parity investigation, validation loop,
52+
merge or publication was performed. Native gates remain incomplete until CI.
53+
54+
Project notes: Autotools/C++20 uses bootstrap, out-of-tree configure and make.
55+
This scripts-only task needs no dependency installation; focused tests use
56+
Python unittest and the workflow suite's existing PyYAML dependency. Platform
57+
fixtures substitute only native executable/process boundaries. Python files
58+
have no documented standalone linter; changed-file `py_compile`, Bash syntax,
59+
existing actionlint1.7.7 and `git diff --check` passed. Worktree branch/path came
60+
verbatim from the Groundwork identity helper; Git metadata writes required
61+
sandbox escalation. Agent memory was initially empty.

0 commit comments

Comments
 (0)