Skip to content

Commit 10dbab8

Browse files
committed
Merge commit '20ffd580e0becb3b4806e9be8bfa14ab3325bf3b' into worktree-fix-449-p4-retirement
2 parents 83ced9d + 20ffd58 commit 10dbab8

14 files changed

Lines changed: 1363 additions & 105 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ jobs:
109109
# runs. Tracked separately; do not let it gate unrelated PRs.
110110
# NB: `binary()` (test-binary name), not `test()` — `test()` would
111111
# match the test function name, which is different from the binary.
112-
run: cargo nextest run --all-features --workspace -E '!binary(x0x_0041_synthetic_kill_restart)'
112+
run: bash scripts/ci/nextest-isolated.sh --all-features --workspace -- -E '!binary(x0x_0041_synthetic_kill_restart)'
113113

114114
- name: Upload test results
115115
if: always()
@@ -187,8 +187,11 @@ jobs:
187187
# tranche 2 (#141): 65.0 → 65.6 (exec-ACL denials; actual 66.61%)
188188
# tranche 3 (#124): 65.6 → 65.7 (storage/identity errors; actual 66.70%)
189189
run: |
190+
cargo llvm-cov show-env --sh > "$RUNNER_TEMP/coverage-env.sh"
191+
source "$RUNNER_TEMP/coverage-env.sh"
190192
cargo llvm-cov clean --workspace
191-
cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info --fail-under-lines 65.7 nextest -E '!binary(x0x_0041_synthetic_kill_restart) & !binary(x0x_0041_prefer_newest_test) & !binary(named_group_join_metadata_event)'
193+
bash scripts/ci/nextest-isolated.sh --all-features --workspace -- -E '!binary(x0x_0041_synthetic_kill_restart) & !binary(x0x_0041_prefer_newest_test) & !binary(named_group_join_metadata_event)'
194+
cargo llvm-cov report --workspace --lcov --output-path lcov.info --fail-under-lines 65.7
192195
python3 scripts/check-coverage-thresholds.py --lcov lcov.info --thresholds coverage-thresholds.toml --enforce-global
193196
194197
- name: Upload LCOV report
@@ -298,12 +301,12 @@ jobs:
298301

299302
- name: Run parity gate
300303
run: |
301-
cargo nextest run --all-features \
304+
bash scripts/ci/nextest-isolated.sh --all-features \
302305
--test api_manifest \
303306
--test parity_cli \
304307
--test api_coverage \
305308
--test gui_smoke \
306-
--test gui_named_group_parity
309+
--test gui_named_group_parity --
307310
308311
- name: Upload GUI coverage report
309312
if: always()
@@ -328,7 +331,7 @@ jobs:
328331
uses: taiki-e/install-action@just
329332

330333
- name: Authority check (same recipe as `just check`)
331-
run: just deploy-check
334+
run: python3 scripts/ci/isolated-runtime.py just deploy-check
332335

333336
- name: Control self-test (each disclosed condition must flip red)
334-
run: just deploy-check-selftest
337+
run: python3 scripts/ci/isolated-runtime.py just deploy-check-selftest

.github/workflows/integration.yml

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ jobs:
3131
- uses: dtolnay/rust-toolchain@stable
3232
- uses: Swatinem/rust-cache@v2
3333
- name: Run guardian tests
34-
run: cargo test --all-features --test api_coverage
34+
run: |
35+
cargo test --all-features --test api_coverage --no-run
36+
python3 scripts/ci/isolated-runtime.py cargo test --offline --locked --all-features --test api_coverage
3537
3638
# ── Property tests (every PR, ~2min) ──────────────────────────────────
3739
proptest:
@@ -69,11 +71,11 @@ jobs:
6971
# --build-jobs 2: belt-and-braces cap on parallel linking, since each
7072
# proptest binary links the (large) x0x crate.
7173
run: |
72-
cargo nextest run --all-features --build-jobs 2 --lib \
74+
bash scripts/ci/nextest-isolated.sh --all-features --build-jobs 2 --lib \
7375
--test proptest_connectivity --test proptest_crdt --test proptest_files \
7476
--test proptest_groups --test proptest_kv --test proptest_mls \
7577
--test proptest_presence --test proptest_trust \
76-
-E 'binary(proptest_connectivity) | binary(proptest_crdt) | binary(proptest_files) | binary(proptest_groups) | binary(proptest_kv) | binary(proptest_mls) | binary(proptest_presence) | binary(proptest_trust) | test(/^proptest_/) | test(/proptest/)'
78+
-- -E 'binary(proptest_connectivity) | binary(proptest_crdt) | binary(proptest_files) | binary(proptest_groups) | binary(proptest_kv) | binary(proptest_mls) | binary(proptest_presence) | binary(proptest_trust) | test(/^proptest_/) | test(/proptest/)'
7779
7880
# ── Integration tests — split into parallel jobs (issue #197) ──────────
7981
# The old monolithic `integration` job ran ~10 suites sequentially
@@ -96,6 +98,8 @@ jobs:
9698
name: Integration (core)
9799
runs-on: ubuntu-latest
98100
timeout-minutes: 20
101+
env:
102+
X0X_RUNTIME_TIMEOUT_SECONDS: 1200
99103
steps:
100104
- uses: actions/checkout@v5
101105
with:
@@ -111,9 +115,9 @@ jobs:
111115
- uses: Swatinem/rust-cache@v2
112116
- uses: taiki-e/install-action@nextest
113117
- name: Run daemon API integration tests
114-
run: cargo nextest run --all-features --test daemon_api_integration -- --ignored
118+
run: bash scripts/ci/nextest-isolated.sh --all-features --test daemon_api_integration -- -- --ignored
115119
- name: Run named group D4 signed commit integration tests
116-
run: cargo nextest run --all-features --test named_group_d4_apply -- --ignored
120+
run: bash scripts/ci/nextest-isolated.sh --all-features --test named_group_d4_apply -- -- --ignored
117121

118122
# Groups: the heavy named-group daemon suite (28 daemon-backed tests),
119123
# isolated on its own runner so it cannot stack on core's cold build.
@@ -123,6 +127,8 @@ jobs:
123127
name: Integration (groups)
124128
runs-on: ubuntu-latest
125129
timeout-minutes: 20
130+
env:
131+
X0X_RUNTIME_TIMEOUT_SECONDS: 1200
126132
steps:
127133
- uses: actions/checkout@v5
128134
with:
@@ -138,13 +144,15 @@ jobs:
138144
- uses: Swatinem/rust-cache@v2
139145
- uses: taiki-e/install-action@nextest
140146
- name: Run named group integration tests
141-
run: cargo nextest run --all-features --test named_group_integration --run-ignored ignored-only
147+
run: bash scripts/ci/nextest-isolated.sh --all-features --test named_group_integration -- --run-ignored ignored-only
142148

143149
# Net: GUI smoke + WebSocket + kv bootstrap + local-topic routing.
144150
integration-net:
145151
name: Integration (net)
146152
runs-on: ubuntu-latest
147153
timeout-minutes: 20
154+
env:
155+
X0X_RUNTIME_TIMEOUT_SECONDS: 1200
148156
steps:
149157
- uses: actions/checkout@v5
150158
with:
@@ -160,13 +168,13 @@ jobs:
160168
- uses: Swatinem/rust-cache@v2
161169
- uses: taiki-e/install-action@nextest
162170
- name: Run GUI smoke tests
163-
run: cargo nextest run --all-features --test gui_smoke
171+
run: bash scripts/ci/nextest-isolated.sh --all-features --test gui_smoke --
164172
- name: Run WebSocket tests
165-
run: cargo nextest run --all-features --test ws_integration -- --ignored
173+
run: bash scripts/ci/nextest-isolated.sh --all-features --test ws_integration -- -- --ignored
166174
- name: Run KvStore first-join bootstrap tests (issue 96)
167-
run: cargo nextest run --all-features --test kv_first_join_bootstrap --test-threads 1 -- --ignored
175+
run: bash scripts/ci/nextest-isolated.sh --all-features --test kv_first_join_bootstrap -- --test-threads 1 -- --ignored
168176
- name: Run local-topic routing tests (issue 89)
169-
run: cargo nextest run --all-features --test local_topics --test-threads 1 -- --ignored
177+
run: bash scripts/ci/nextest-isolated.sh --all-features --test local_topics -- --test-threads 1 -- --ignored
170178

171179
# ── De-flaked timing suites moved off the PR path (issue #197).
172180
# These are convergence/jitter-prone budgets (the join-metadata suite
@@ -179,6 +187,8 @@ jobs:
179187
name: Integration Timing Suites (weekly)
180188
runs-on: ubuntu-latest
181189
timeout-minutes: 30
190+
env:
191+
X0X_RUNTIME_TIMEOUT_SECONDS: 1800
182192
if: github.event_name == 'schedule'
183193
steps:
184194
- uses: actions/checkout@v5
@@ -197,17 +207,19 @@ jobs:
197207
- name: Build release binaries (shared across suites)
198208
run: cargo build --release --bin x0xd --bin x0x
199209
- name: Run peer-lifecycle daemon tests (de-flaked, ignored-only)
200-
run: cargo nextest run --all-features --test peer_lifecycle_integration --run-ignored ignored-only
210+
run: bash scripts/ci/nextest-isolated.sh --all-features --test peer_lifecycle_integration -- --run-ignored ignored-only
201211
- name: Run x0x_0041 reissue timing test (de-flaked, ignored-only)
202-
run: cargo nextest run --all-features --test x0x_0041_prefer_newest_test --run-ignored ignored-only
212+
run: bash scripts/ci/nextest-isolated.sh --all-features --test x0x_0041_prefer_newest_test -- --run-ignored ignored-only
203213
- name: Run named-group join-metadata convergence tests (de-flaked, ignored-only)
204-
run: cargo nextest run --all-features --test named_group_join_metadata_event --run-ignored ignored-only
214+
run: bash scripts/ci/nextest-isolated.sh --all-features --test named_group_join_metadata_event -- --run-ignored ignored-only
205215

206216
# ── Soak test (weekly, ~90min) ────────────────────────────────────────
207217
soak:
208218
name: Soak Test
209219
runs-on: ubuntu-latest
210220
timeout-minutes: 90
221+
env:
222+
X0X_RUNTIME_TIMEOUT_SECONDS: 5400
211223
if: github.event_name == 'schedule'
212224
steps:
213225
- uses: actions/checkout@v5
@@ -226,7 +238,7 @@ jobs:
226238
- name: Build release binaries
227239
run: cargo build --release --bin x0xd
228240
- name: Run 1-hour soak
229-
run: bash tests/soak/run_soak.sh --duration 1h
241+
run: python3 scripts/ci/isolated-runtime.py bash tests/soak/run_soak.sh --duration 1h
230242
- name: Upload soak results
231243
if: always()
232244
uses: actions/upload-artifact@v5

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ exclude = ["proofs/**", "tests/proof-reports/**", "target/**"]
1919

2020
[dependencies]
2121
anyhow = "1.0"
22-
ant-quic = "0.27.47"
22+
ant-quic = "0.27.50"
2323
# CI pin (2026-09-03): tinyvec 1.13.0 (published 2026-09-03) fails to compile on the
2424
# pinned rustc 1.95.0 ("cannot find macro `vec`"); Cargo.lock is not committed, so
2525
# constrain the transitive dep (via ant-quic) until tinyvec or the toolchain moves.

scripts/ci/isolated-runtime.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/usr/bin/env python3
2+
"""Run CI tests in a fresh Linux network/PID/mount namespace, never host fallback."""
3+
import json
4+
import os
5+
from pathlib import Path
6+
import shutil
7+
import select
8+
import signal
9+
import time
10+
import subprocess
11+
import sys
12+
import tempfile
13+
14+
# Preserve toolchain/instrumentation, not proxy credentials or host agent sockets.
15+
ENV_KEYS = (
16+
'PATH', 'CARGO_HOME', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'CARGO_TARGET_DIR',
17+
'CARGO_TERM_COLOR', 'RUST_BACKTRACE', 'RUSTFLAGS', 'RUSTDOCFLAGS',
18+
'CARGO_ENCODED_RUSTFLAGS', 'CARGO_ENCODED_RUSTDOCFLAGS', 'RUSTC', 'RUSTDOC',
19+
'RUSTC_WRAPPER', 'RUSTC_WORKSPACE_WRAPPER', 'LLVM_PROFILE_FILE',
20+
'CARGO_INCREMENTAL', 'CARGO_LLVM_COV', 'CARGO_LLVM_COV_TARGET_DIR', 'CARGO_LLVM_COV_BUILD_DIR',
21+
'LLVM_COV', 'LLVM_PROFDATA',
22+
)
23+
24+
25+
def checked(*args):
26+
return subprocess.check_output(args, text=True).strip()
27+
28+
29+
def namespace_state(parent):
30+
current = os.readlink('/proc/self/ns/net')
31+
if current == parent:
32+
raise RuntimeError('network namespace did not change')
33+
links = json.loads(checked('/usr/sbin/ip', '-j', 'link'))
34+
if [link['ifname'] for link in links] != ['lo']:
35+
raise RuntimeError(f'foreign interfaces: {links}')
36+
routes = {family: json.loads(checked('/usr/sbin/ip', family, '-j', 'route',
37+
'show', 'table', 'all'))
38+
for family in ('-4', '-6')}
39+
if any(row.get('dev') != 'lo' or row.get('dst') == 'default' or 'gateway' in row
40+
for rows in routes.values() for row in rows):
41+
raise RuntimeError(f'foreign route: {routes}')
42+
return dict(namespace=current, links=links, routes=routes)
43+
44+
45+
def admitted(config):
46+
state = namespace_state(config['parent_netns'])
47+
status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines()
48+
if ':' in line)
49+
if os.getuid() != config['uid'] or os.geteuid() == 0 or os.getgroups():
50+
raise RuntimeError('runtime did not drop to the unprivileged owner')
51+
for key in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'):
52+
if int(status[key].strip(), 16):
53+
raise RuntimeError(f'{key} is not empty')
54+
if status['NoNewPrivs'].strip() != '1':
55+
raise RuntimeError('no_new_privs is not set')
56+
state.update(uid=os.getuid(), gid=os.getgid(), capabilities={
57+
key: status[key].strip() for key in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb')},
58+
no_new_privs=1)
59+
(Path(config['evidence']) / 'admission.json').write_text(json.dumps(state, indent=2) + '\n')
60+
result = subprocess.run(config['command'], env=config['env'], close_fds=True)
61+
(Path(config['evidence']) / 'exit.json').write_text(json.dumps({'exit': result.returncode}) + '\n')
62+
return result.returncode if result.returncode >= 0 else 128 - result.returncode
63+
64+
65+
def setup(config_file):
66+
config = json.loads(Path(config_file).read_text())
67+
if os.getuid() != 0:
68+
raise RuntimeError('namespace setup requires root')
69+
namespace_state(config['parent_netns'])
70+
subprocess.run(['/usr/bin/mount', '--make-rprivate', '/'], check=True)
71+
subprocess.run(['/usr/bin/mount', '-t', 'tmpfs', '-o', 'mode=1777,nosuid,nodev',
72+
'tmpfs', '/tmp'], check=True)
73+
for name in ('/tmp/x0x-nextest-home', '/tmp/x0x-runtime-home', '/tmp/x0x-runtime-tmp'):
74+
Path(name).mkdir(mode=0o700)
75+
os.chown(name, config['uid'], config['gid'])
76+
subprocess.run(['/usr/sbin/ip', 'link', 'set', 'lo', 'up'], check=True)
77+
namespace_state(config['parent_netns'])
78+
os.execv('/usr/bin/setpriv', [
79+
'setpriv', f"--reuid={config['uid']}", f"--regid={config['gid']}", '--clear-groups',
80+
'--bounding-set=-all', '--inh-caps=-all', '--ambient-caps=-all', '--no-new-privs',
81+
'/usr/bin/python3', str(Path(__file__).resolve()), '--admitted', config_file])
82+
83+
84+
def supervise(config_file):
85+
"""Root monitor: caller pipe EOF, deadline or signals cancel our child only."""
86+
config = json.loads(Path(config_file).read_text())
87+
if os.getuid() != 0:
88+
raise RuntimeError('supervisor requires root')
89+
cancelled = []
90+
for signum in (signal.SIGTERM, signal.SIGINT):
91+
signal.signal(signum, lambda number, _frame: cancelled.append(number))
92+
child = subprocess.Popen([
93+
'/usr/bin/unshare', '--net', '--mount', '--pid', '--fork', '--kill-child',
94+
'--mount-proc', '/usr/bin/python3', str(Path(__file__).resolve()),
95+
'--setup', config_file], stdin=subprocess.DEVNULL, close_fds=True, start_new_session=True)
96+
started = time.monotonic()
97+
reason = None
98+
try:
99+
while child.poll() is None:
100+
if cancelled:
101+
reason = 'signal'
102+
break
103+
if time.monotonic() - started >= config['timeout_seconds']:
104+
reason = 'deadline'
105+
break
106+
if select.select([sys.stdin], [], [], 0.1)[0] and not os.read(0, 1):
107+
reason = 'caller-pipe-closed'
108+
break
109+
finally:
110+
# poll() may reap an already-exited leader. Never signal it afterward.
111+
# While unreaped, its PID/session identity cannot be reused. Namespace
112+
# init inherits this session; killing init removes all its descendants.
113+
if child.poll() is None:
114+
os.killpg(child.pid, signal.SIGTERM)
115+
try:
116+
child.wait(timeout=5)
117+
except subprocess.TimeoutExpired:
118+
os.killpg(child.pid, signal.SIGKILL)
119+
child.wait()
120+
else:
121+
child.wait()
122+
receipt = Path(config['evidence']) / 'supervisor.json'
123+
receipt.write_text(json.dumps(dict(reason=reason, child_pid=child.pid,
124+
child_exit=child.returncode, child_reaped=True,
125+
seconds=time.monotonic() - started)) + '\n')
126+
os.chown(receipt, config['uid'], config['gid'])
127+
if reason == 'deadline':
128+
return 124
129+
if reason is not None:
130+
return 125
131+
return child.returncode if child.returncode >= 0 else 128 - child.returncode
132+
133+
134+
def caller(config_file):
135+
"""Own sudo's lifetime; pipe EOF also covers uncatchable caller SIGKILL."""
136+
interrupted = []
137+
for signum in (signal.SIGTERM, signal.SIGINT):
138+
signal.signal(signum, lambda number, _frame: interrupted.append(number))
139+
child = subprocess.Popen([
140+
'/usr/bin/sudo', '-n', '/usr/bin/python3', str(Path(__file__).resolve()),
141+
'--supervise', str(config_file)], stdin=subprocess.PIPE, close_fds=True)
142+
try:
143+
while child.poll() is None:
144+
if interrupted:
145+
break
146+
time.sleep(0.1)
147+
finally:
148+
child.stdin.close()
149+
# The privileged monitor owns escalation; do not signal root/reused PIDs.
150+
child.wait(timeout=15)
151+
return 128 + interrupted[0] if interrupted else child.returncode
152+
153+
154+
def main():
155+
if len(sys.argv) == 3 and sys.argv[1] == '--supervise':
156+
return supervise(sys.argv[2])
157+
if len(sys.argv) == 3 and sys.argv[1] == '--setup':
158+
setup(sys.argv[2])
159+
if len(sys.argv) == 3 and sys.argv[1] == '--admitted':
160+
return admitted(json.loads(Path(sys.argv[2]).read_text()))
161+
if sys.platform != 'linux' or os.getuid() == 0:
162+
raise RuntimeError('requires a Linux unprivileged runner with sudo for namespace setup')
163+
command = sys.argv[1:]
164+
if not command:
165+
raise RuntimeError('a command is required')
166+
for tool in ('/usr/bin/sudo', '/usr/bin/unshare', '/usr/bin/setpriv', '/usr/sbin/ip', '/usr/bin/mount'):
167+
if not Path(tool).is_file():
168+
raise RuntimeError(f'missing namespace prerequisite: {tool}')
169+
# Resolve once before changing HOME/PATH; no shell evaluation of test arguments.
170+
command[0] = shutil.which(command[0]) or command[0]
171+
env = {key: os.environ[key] for key in ENV_KEYS if key in os.environ}
172+
env.setdefault('CARGO_HOME', str(Path.home() / '.cargo'))
173+
env.setdefault('RUSTUP_HOME', str(Path.home() / '.rustup'))
174+
env.update(HOME='/tmp/x0x-runtime-home', X0X_HOME='/tmp/x0x-runtime-home',
175+
TMPDIR='/tmp/x0x-runtime-tmp', CARGO_NET_OFFLINE='true', RUST_MIN_STACK='16777216')
176+
evidence = Path(tempfile.mkdtemp(prefix='x0x-isolation-', dir=os.environ['RUNNER_TEMP'])).resolve()
177+
if evidence.is_relative_to('/tmp'):
178+
raise RuntimeError('RUNNER_TEMP must remain visible outside private /tmp')
179+
config = dict(command=command, env=env, uid=os.getuid(), gid=os.getgid(),
180+
parent_netns=os.readlink('/proc/self/ns/net'), evidence=str(evidence),
181+
timeout_seconds=int(os.environ.get('X0X_RUNTIME_TIMEOUT_SECONDS', '21600')))
182+
if not 1 <= config['timeout_seconds'] <= 21600:
183+
raise RuntimeError('runtime deadline must be1..21600 seconds')
184+
config_file = evidence / 'runtime.json'
185+
config_file.write_text(json.dumps(config, indent=2) + '\n')
186+
print(f'Isolation evidence: {evidence}', flush=True)
187+
return caller(config_file)
188+
189+
190+
if __name__ == '__main__':
191+
sys.exit(main())

0 commit comments

Comments
 (0)