Skip to content

Commit 6918a23

Browse files
committed
Merge FEATURE-ci-darwin-test-contracts: preserve native test and loopback contracts
2 parents a5a7235 + 3f916ff commit 6918a23

7 files changed

Lines changed: 301 additions & 11 deletions

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,43 @@ jobs:
118118
"$RUNNER_TEMP/clients/bin/python" -m pip install -r test/integ/http2-client-requirements.txt
119119
- name: Complete native and installed TLS off/on proof
120120
run: |
121+
peer_alias_owned=false
122+
cleanup_peer_alias() {
123+
status=$?
124+
trap - EXIT HUP INT TERM
125+
if "$peer_alias_owned"; then
126+
if ! sudo ifconfig lo0 inet 127.0.0.2 -alias; then
127+
echo 'peer-policy: failed to remove owned lo0 alias 127.0.0.2' >&2
128+
if [ "$status" -eq 0 ]; then status=1; fi
129+
fi
130+
fi
131+
exit "$status"
132+
}
133+
trap cleanup_peer_alias EXIT
134+
trap 'exit 129' HUP
135+
trap 'exit 130' INT
136+
trap 'exit 143' TERM
137+
if ! loopback=$(ifconfig lo0 inet); then
138+
echo 'peer-policy: failed to query lo0 IPv4 addresses' >&2
139+
exit 1
140+
fi
141+
if ! awk '$1 == "inet" && $2 == "127.0.0.2" { found=1 } END { exit !found }' <<< "$loopback"; then
142+
if ! sudo ifconfig lo0 inet 127.0.0.2 netmask 255.255.255.255 alias; then
143+
echo 'peer-policy: failed to create lo0 alias 127.0.0.2' >&2
144+
exit 1
145+
fi
146+
peer_alias_owned=true
147+
fi
148+
if ! python3 - <<'PY'
149+
import socket
150+
for address in ('127.0.0.1', '127.0.0.2'):
151+
with socket.socket() as peer:
152+
peer.bind((address, 0))
153+
PY
154+
then
155+
echo 'peer-policy: both loopback source addresses must bind before proof' >&2
156+
exit 1
157+
fi
121158
prefix=()
122159
if ! python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",443)); s.close()'; then
123160
prefix=(sudo env "PATH=$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory "GIT_CONFIG_VALUE_0=$PWD")

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,5 +530,149 @@ def test_all_modified_workflows_pin_actions(self):
530530
self.assertRegex(use, r'@[0-9a-f]{40}$')
531531

532532

533+
@unittest.skipIf(os.name == 'nt', 'Darwin proof uses a POSIX shell')
534+
class DarwinPeerAliasLifecycle(unittest.TestCase):
535+
def execute(self, *, preexisting=False, failure='', signal_name=''):
536+
with tempfile.TemporaryDirectory() as directory:
537+
root = Path(directory)
538+
tools = root / 'tools'
539+
tools.mkdir()
540+
(root / 'scripts').mkdir()
541+
state = root / 'alias'
542+
if preexisting:
543+
state.touch()
544+
capture = root / 'calls.jsonl'
545+
boundary = '''import json, os, pathlib, sys
546+
sys.excepthook = sys.__excepthook__
547+
def record(value):
548+
with open(os.environ['CAPTURE'], 'a') as stream:
549+
stream.write(json.dumps(value) + '\\n')
550+
state = pathlib.Path(os.environ['ALIAS_STATE'])
551+
failure = os.environ['FAILURE']
552+
'''
553+
scripts = {
554+
'ifconfig': boundary + '''
555+
args = sys.argv[1:]
556+
record(['ifconfig'] + args)
557+
if args == ['lo0', 'inet']:
558+
if failure == 'query': sys.exit(43)
559+
print('lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST>')
560+
print(' inet 127.0.0.1 netmask 0xff000000')
561+
print(' inet 127.0.0.20 netmask 0xffffffff')
562+
if state.exists(): print(' inet 127.0.0.2 netmask 0xffffffff')
563+
elif args == ['lo0', 'inet', '127.0.0.2', 'netmask', '255.255.255.255', 'alias']:
564+
assert not state.exists(), 'preexisting alias must be preserved'
565+
if failure == 'create': sys.exit(47)
566+
state.touch()
567+
elif args == ['lo0', 'inet', '127.0.0.2', '-alias']:
568+
assert state.exists(), 'cleanup needs an owned alias'
569+
if 'cleanup' in failure: sys.exit(53)
570+
state.unlink()
571+
else:
572+
raise AssertionError(args)
573+
''',
574+
'sudo': 'import os, sys\nos.execvp(sys.argv[1], sys.argv[1:])\n',
575+
'brew': "print('/fixture/homebrew')\n",
576+
}
577+
for name, code in scripts.items():
578+
tool = tools / name
579+
tool.write_text('#!' + sys.executable + '\n' + code)
580+
tool.chmod(0o755)
581+
(tools / 'python3').symlink_to(sys.executable)
582+
# Execute the workflow's actual preflight Python, replacing only
583+
# the OS socket boundary so Linux cannot mask Darwin's bind refusal.
584+
(root / 'socket.py').write_text(boundary + '''
585+
class socket:
586+
def __enter__(self): return self
587+
def __exit__(self, *args): self.close()
588+
def close(self): pass
589+
def bind(self, address):
590+
record(['bind', *address])
591+
if address[0] == '127.0.0.2' and (not state.exists() or failure == 'preflight'):
592+
raise OSError(49, 'Cannot assign requested address')
593+
''')
594+
(root / 'scripts/run-v3-release-ci.py').write_text(boundary + '''
595+
import signal
596+
record(['proof'] + sys.argv[1:])
597+
assert state.exists(), 'proof requires the second peer source address'
598+
if os.environ['SIGNAL_NAME']:
599+
os.kill(os.getppid(), getattr(signal, os.environ['SIGNAL_NAME']))
600+
if 'proof' in failure: sys.exit(61)
601+
''')
602+
lane = workflow('v3-release-acceptance.yml')
603+
job = lane['jobs']['darwin']
604+
step = next(step for step in job['steps']
605+
if step.get('name') == 'Complete native and installed TLS off/on proof')
606+
env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
607+
PYTHONPATH=str(root), CAPTURE=str(capture), ALIAS_STATE=str(state),
608+
FAILURE=failure, SIGNAL_NAME=signal_name, RUNNER_TEMP=str(root),
609+
V3_CXX='/selected/clang++', V3_CC='/selected/clang', V3_PACKAGE_TOOLCHAIN='/selected')
610+
result = subprocess.run(workflow_shell(lane, job, step) + [step['run']], cwd=root,
611+
env=env, capture_output=True, text=True, timeout=10)
612+
calls = [json.loads(line) for line in capture.read_text().splitlines()] if capture.exists() else []
613+
return result, calls, state.exists()
614+
615+
def assert_preflight(self, calls):
616+
proof = next(i for i, call in enumerate(calls) if call[0] == 'proof')
617+
for address in ('127.0.0.1', '127.0.0.2'):
618+
self.assertIn(['bind', address, 0], calls[:proof])
619+
620+
def test_owned_alias_surrounds_proof_and_both_actual_bind_preflights(self):
621+
result, calls, remains = self.execute()
622+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
623+
self.assert_preflight(calls)
624+
self.assertEqual(calls[0], ['ifconfig', 'lo0', 'inet'])
625+
self.assertEqual(calls[-1], ['ifconfig', 'lo0', 'inet', '127.0.0.2', '-alias'])
626+
self.assertFalse(remains)
627+
proof = next(call for call in calls if call[0] == 'proof')
628+
self.assertIn('/selected/clang++', proof)
629+
630+
def test_preexisting_alias_survives_success_and_proof_failure(self):
631+
for failure, status in (('', 0), ('proof', 61)):
632+
with self.subTest(failure=failure):
633+
result, calls, remains = self.execute(preexisting=True, failure=failure)
634+
self.assertEqual(result.returncode, status, result.stdout + result.stderr)
635+
self.assert_preflight(calls)
636+
self.assertEqual([call for call in calls if call[0] == 'ifconfig'], [['ifconfig', 'lo0', 'inet']])
637+
self.assertTrue(remains)
638+
639+
def test_setup_and_preflight_fail_visibly_before_proof(self):
640+
for failure in ('query', 'create', 'preflight'):
641+
with self.subTest(failure=failure):
642+
result, calls, remains = self.execute(failure=failure)
643+
self.assertNotEqual(result.returncode, 0)
644+
self.assertIn('peer-policy', result.stderr)
645+
self.assertFalse(any(call[0] == 'proof' for call in calls))
646+
self.assertFalse(remains)
647+
cleanup = ['ifconfig', 'lo0', 'inet', '127.0.0.2', '-alias']
648+
self.assertEqual(cleanup in calls, failure == 'preflight')
649+
650+
def test_preexisting_alias_survives_failed_bind_preflight(self):
651+
result, calls, remains = self.execute(preexisting=True, failure='preflight')
652+
self.assertNotEqual(result.returncode, 0)
653+
self.assertIn('peer-policy', result.stderr)
654+
self.assertFalse(any(call[0] == 'proof' for call in calls))
655+
self.assertEqual([call for call in calls if call[0] == 'ifconfig'], [['ifconfig', 'lo0', 'inet']])
656+
self.assertTrue(remains)
657+
658+
def test_owned_alias_is_cleaned_after_proof_failure_and_signals(self):
659+
for failure, signal_name, status in (('proof', '', 61), ('', 'SIGHUP', 129),
660+
('', 'SIGINT', 130), ('', 'SIGTERM', 143)):
661+
with self.subTest(failure=failure, signal=signal_name):
662+
result, calls, remains = self.execute(failure=failure, signal_name=signal_name)
663+
self.assertEqual(result.returncode, status, result.stdout + result.stderr)
664+
self.assertEqual(calls[-1], ['ifconfig', 'lo0', 'inet', '127.0.0.2', '-alias'])
665+
self.assertFalse(remains)
666+
667+
def test_cleanup_failure_is_visible_and_preserves_prior_failure(self):
668+
for failure, status in (('cleanup', 1), ('proof-cleanup', 61)):
669+
with self.subTest(failure=failure):
670+
result, calls, remains = self.execute(failure=failure)
671+
self.assertEqual(result.returncode, status, result.stdout + result.stderr)
672+
self.assertIn('peer-policy', result.stderr)
673+
self.assertEqual(calls[-1], ['ifconfig', 'lo0', 'inet', '127.0.0.2', '-alias'])
674+
self.assertTrue(remains)
675+
676+
533677
if __name__ == '__main__':
534678
unittest.main()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# FEATURE-ci-darwin-test-contracts
2+
3+
Status: Complete (local implementation/validation/finalization; native CI pending)
4+
Base: local v3 `a5a72356741300e5da16b94090f0f26a67a2d1f7`
5+
6+
Repair four native-reproduced test/setup assumptions without changing production
7+
runtime, quota accounting, APIs, peer addresses or unrelated workflows.
8+
9+
- [x] Test returned continuation behavior with an explicitly owned coroutine frame.
10+
- [x] Inject all five actual QPACK precommit allocations and prove blocked request nonpublication, with a successful control.
11+
- [x] Size the shared response fixture budget from actual reserved string capacity.
12+
- [x] Prepare and clean up only the Darwin selected-archive proof's owned loopback alias; execute lifecycle regressions.
13+
- [x] Run focused local checks and record native verification limits.
14+
- [x] Caller validation and finalization.
15+
16+
Evidence: primary `.debug/ci-darwin-test-contracts-8m2spsq4/journal.md`.
17+
Native RED batch 40055653509 is closed. Diagnostic refs f6475daf/88cd82fd
18+
must never merge or cherry-pick into v3. Header/outbox incidents remain closed
19+
with uncertain cause; this task does not retry or repair them. Native post-fix
20+
Darwin, Windows and BSD checks remain incomplete and CI/v3 PR owned.
21+
22+
Local verification: each of the three focused C++ targets passes with GNU and
23+
actual libc++20 on Linux (47 cases / 2901 checks per standard library, no skips).
24+
The lifetime target and concurrency implementation pass Clang ASan/UBSan with
25+
leak and stack checking enabled (3 cases / 1816 checks), using supported execution
26+
outside sandbox ptrace. Workflow tests pass (28 tests, including 6 lifecycle tests
27+
covering 13 absent/preexisting/failure/signal scenarios). Changed-file cpplint,
28+
actionlint, workflow Bash syntax, Python compilation and diff whitespace pass.
29+
30+
TDD uses the retained native RED for the three platform-specific fixture failures;
31+
new workflow lifecycle regressions failed before the setup change and pass after it.
32+
Original GCC sized-delete sanitizer setup and sandbox LSan failures are preserved
33+
in the journal; neither is reported as a passing run. Two Automake wrappers were
34+
interrupted during unrelated prerequisite compilation before tests; verification
35+
then executed only the three requested binaries. No production owner expansion,
36+
full baseline suite, native rerun, CI control or publication occurred.
37+
38+
Groundwork validation `b98a1e2fed06734a1161df488d03ed79` sealed PASS for
39+
implementation `bcd8f6143e84902bc612c0790392b03190908b0c`: one iteration,
40+
nine independent approvals, zero fixes and one unworked finding persisted by
41+
the helper. The report is preserved without caller reading it. Native post-fix
42+
platform execution remains incomplete and CI/v3 PR owned; overall CI remains
43+
unsuccessful. Header/outbox causes remain uncertain and CLOSED.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Unworked Review Issues
2+
3+
**Run:** 2026-10-23 20:34:22
4+
**Task:** manual-validation
5+
**Total:** 1 (0 critical, 0 major, 1 minor)
6+
7+
## Minor
8+
9+
1. [ ] **security-reviewer** | `.github/workflows/v3-release-acceptance.yml:142` | resource-cleanup
10+
A trapped signal after successful alias creation but before peer_alias_owned=true leaves the newly created privileged alias behind (CWE-459). Bash processes the pending trap before the ownership assignment, so EXIT cleanup sees false. One bounded controlled-boundary check using the actual workflow and existing fixture, with os.kill(os.getppid(), 15) immediately after the fake ifconfig state.touch(), returned 143 with only query/create calls and alias_remains=True. This misses the accepted interrupted-command cleanup contract; impact is limited to loopback state on the ephemeral runner.
11+
*Recommendation:* Defer exiting on HUP/INT/TERM during creation until the creation result and ownership have been recorded, then honor the pending signal through cleanup. Add a lifecycle case that signals immediately after creation and verifies removal while retaining preexisting-alias preservation.

‎test/unit/http3_qpack_test.cpp‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
#include <cstdlib>
44
#include <new>
55
#include <limits>
6+
#include <string>
67
#include "./littletest.hpp"
78
namespace {
89
int allocation_countdown = -1;
10+
bool allocation_failed = false;
911
} // namespace
1012
void* operator new(std::size_t size) {
1113
if (allocation_countdown == 0) {
14+
allocation_failed = true;
1215
allocation_countdown = -1; throw std::bad_alloc();
1316
}
1417
if (allocation_countdown > 0) --allocation_countdown;
@@ -125,17 +128,37 @@ LT_BEGIN_AUTO_TEST(http3_qpack_suite, local_settings_advertise_admitted_limits)
125128
LT_CHECK_EQ(capacity, 128U); LT_CHECK_EQ(blocked, 2U); LT_CHECK_EQ(connect, 1U);
126129
LT_END_AUTO_TEST(local_settings_advertise_admitted_limits)
127130
LT_BEGIN_AUTO_TEST(http3_qpack_suite, instruction_allocation_failure_is_internal_and_does_not_publish)
128-
for (int stage = 0; stage < 3; ++stage) {
131+
// Exceed supported string inline capacities for both decode and table copies.
132+
const std::string name(32, 'a'), value(32, 'b');
133+
LT_ASSERT(name.size() > std::string().capacity());
134+
const auto encoded = hd::qpack_encode_encoder_instruction(
135+
{hd::qpack_encoder_instruction_kind::insert_literal, 0, false, name, value}, {100, 100, 100});
136+
LT_ASSERT(encoded.status.ok());
137+
const auto wire = std::as_bytes(std::span(encoded.value));
138+
// Control, decoded name/value, table name/value copies, prepared table node.
139+
for (int stage = -1; stage < 5; ++stage) {
129140
auto root = budget(); hd::quic_storage_pool pool(10000, 30000, root); auto limits = dynamic_limits(); limits.qpack_capacity = 256;
130-
hd::http3_connection c(pool.data(), pool.critical(), limits); c.attach_stream(2);
141+
hd::http3_connection c(pool.data(), pool.critical(), limits); c.attach_stream(0); c.attach_stream(2);
131142
LT_CHECK(!feed(c, 2, bytes({2, 0x3f, 0xe1, 1})).error);
132-
auto wire = bytes({0x54, 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 20,
133-
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b'});
143+
LT_CHECK(!feed(c, 0, bytes({1, 3, 2, 0, 0x80})).error);
144+
LT_CHECK_EQ(root.in_use(hs::resource::blocked_headers), 1U);
145+
LT_CHECK(!c.event(0));
134146
LT_CHECK(!feed(c, 2, std::span(wire).first(wire.size() - 1)).error);
147+
allocation_failed = false;
135148
allocation_countdown = stage;
136149
auto result = feed(c, 2, std::span(wire).last(1)); allocation_countdown = -1;
137-
LT_ASSERT(result.error); LT_CHECK_EQ(result.error->wire_code, 0x102U);
138-
LT_CHECK(!c.event(0));
150+
LT_CHECK_EQ(allocation_failed, stage >= 0);
151+
if (stage < 0) {
152+
LT_CHECK(!result.error);
153+
LT_ASSERT(c.event(0));
154+
LT_ASSERT_EQ(c.event(0)->fields.size(), 1U);
155+
LT_CHECK_EQ(c.event(0)->fields[0].name, name);
156+
LT_CHECK_EQ(c.event(0)->fields[0].value, value);
157+
LT_CHECK_EQ(root.in_use(hs::resource::blocked_headers), 0U);
158+
} else {
159+
LT_ASSERT(result.error); LT_CHECK_EQ(result.error->wire_code, 0x102U);
160+
LT_CHECK(!c.event(0));
161+
}
139162
}
140163
LT_END_AUTO_TEST(instruction_allocation_failure_is_internal_and_does_not_publish)
141164
LT_BEGIN_AUTO_TEST(http3_qpack_suite, encoded_byte_limit_persists_until_event_release)

‎test/unit/resource_limits_test.cpp‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,10 @@ LT_BEGIN_AUTO_TEST(resource_limits_suite, h1_output_retirement_restores_shared_c
179179
LT_CHECK_EQ(budget.in_use(hs::resource::response_queue_bytes), 0u);
180180
LT_END_AUTO_TEST(h1_output_retirement_restores_shared_capacity_for_a_long_response)
181181
LT_BEGIN_AUTO_TEST(resource_limits_suite, h1_partial_output_keeps_retained_prefix_charged_until_refill_or_drain)
182-
auto budget = root(hs::resource::response_queue_bytes, 512);
182+
std::string allocation;
183+
allocation.reserve(512);
184+
const auto retained_capacity = allocation.capacity();
185+
auto budget = root(hs::resource::response_queue_bytes, retained_capacity);
183186
hd::http1_response_outbox outbox({512, 512}, budget);
184187
auto& sink = outbox.open(1, {});
185188
http::request_head request; request.request_protocol = http::protocol::http_1_1;
@@ -188,10 +191,10 @@ LT_BEGIN_AUTO_TEST(resource_limits_suite, h1_partial_output_keeps_retained_prefi
188191
outbox.consume_front(outbox.queued_bytes());
189192
const std::array<std::byte, 512> payload{};
190193
LT_ASSERT(sink.push(payload).kind == hd::body_push::accepted);
191-
LT_CHECK_EQ(budget.in_use(hs::resource::response_queue_bytes), 512u);
194+
LT_CHECK_EQ(budget.in_use(hs::resource::response_queue_bytes), retained_capacity);
192195
LT_CHECK_EQ(outbox.consume_front(511), 511u);
193196
LT_CHECK_EQ(outbox.queued_bytes(), 1u);
194-
LT_CHECK_EQ(budget.in_use(hs::resource::response_queue_bytes), 512u);
197+
LT_CHECK_EQ(budget.in_use(hs::resource::response_queue_bytes), retained_capacity);
195198
hd::http1_response_outbox sibling({512, 512}, budget);
196199
auto& excess = sibling.open(1, {});
197200
LT_CHECK(excess.start(request, http::status::from_code(200), fields).code() == http::outcome_code::limit_exceeded);

0 commit comments

Comments
 (0)