From 585b43d3abc7172bc000908cf999e6ed954494bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 4 Oct 2026 20:09:04 -0500 Subject: [PATCH] fix: validate Pi fault fixture against canonical closure pin --- tests/runner-e2e/README.md | 4 ++ tests/runner-e2e/pi-provider-fault.py | 31 +++++++++-- tests/runner-e2e/pi-provider-fault.test.py | 64 ++++++++++++++++++++-- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 6f12639e351..548e230a1b6 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1461,6 +1461,10 @@ death is excluded because this exact ownership mechanism requires Linux pidfd. The Python helper calibration uses a synthetic transport and a real title-changing Node child; it proves fault ownership only, never paid Pi lifecycle behavior. Run it on native Linux with pinned Node on PATH: `python3 tests/runner-e2e/pi-provider-fault.test.py`. +The fault helper validates the profile pin against canonical normalized closure +entries, matching production admission. JSON formatting is not part of that pin. +The same free suite checks canonical hash admission, malformed metadata and +changed entries before any signal is sent. This adds one cell to the pending Pi Product matrix (26: 13 local, 13 Daytona); no qualification or live success is implied by discovery or oracle tests. diff --git a/tests/runner-e2e/pi-provider-fault.py b/tests/runner-e2e/pi-provider-fault.py index d5cb349728f..78dc8163d1f 100644 --- a/tests/runner-e2e/pi-provider-fault.py +++ b/tests/runner-e2e/pi-provider-fault.py @@ -72,6 +72,31 @@ def digest(data): return hashlib.sha256(data).hexdigest() +def parse_closure_metadata(content, expected_pin): + # Match production parseNativeAcpxDistributionEntries: the profile pins + # canonical entries, not the formatting or outer JSON file bytes. + manifest = json.loads(content) + entries = manifest.get('entries') if isinstance(manifest, dict) else None + require(isinstance(entries, list) and 0 < len(entries) <= 30000, 'closure_inventory') + normalized, previous, total = [], '', 0 + for entry in entries: + require(isinstance(entry, dict) and set(entry) == {'path', 'sha256', 'size', 'executable'}, 'closure_entry_shape') + path = entry['path'] + require(isinstance(path, str) and 0 < len(path) <= 4096 and not os.path.isabs(path) + and not re.search(r'[\x00-\x1f\x7f\\]', path) + and all(part not in ('', '.', '..') for part in path.split('/')) + and path > previous and path != 'manifest.json' and not path.startswith('.paperclip-'), 'closure_entry_path') + require(isinstance(entry['sha256'], str) and re.fullmatch(r'[a-f0-9]{64}', entry['sha256']) + and type(entry['size']) is int and 0 <= entry['size'] <= 384 * 1024 * 1024 + and type(entry['executable']) is bool, 'closure_entry_metadata') + total += entry['size']; require(total <= 1024 * 1024 * 1024, 'closure_tree_bound') + previous = path + normalized.append({key: entry[key] for key in ('path', 'sha256', 'size', 'executable')}) + canonical = json.dumps(normalized, separators=(',', ':'), ensure_ascii=False).encode('utf8') + require(re.fullmatch(r'[a-f0-9]{64}', expected_pin) and digest(canonical) == expected_pin, 'closure_pin') + return normalized + + def inspect(config): require(set(config) == {'root', 'binding', 'runtimeEnvironmentLeaseId', 'runnerdSha256', 'closureSha256'}, 'config_keys') binding, expected = config['binding'], config['root'] @@ -90,10 +115,8 @@ def inspect(config): executable = os.stat(f'/proc/{root["pid"]}/exe') require((executable.st_dev, executable.st_ino) == runner_inode and 'sha256:' + digest(runner_bytes) == config['runnerdSha256'], 'runner_executable') closure_bytes, _ = checked_file(PACK + '/provider-assets/pi/linux-x64/native-closure.json', 4 * 1024 * 1024) - require(digest(closure_bytes) == config['closureSha256'], 'closure_pin') - manifest = json.loads(closure_bytes) - entries = {e['path']: e for e in manifest['entries']} - require(len(entries) == len(manifest['entries']), 'closure_duplicate') + admitted_entries = parse_closure_metadata(closure_bytes, config['closureSha256']) + entries = {e['path']: e for e in admitted_entries} for name in (NODE, ENTRY, EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js'): require(name in entries and re.fullmatch(r'[a-f0-9]{64}', entries[name]['sha256']), 'closure_entry') pids = [int(p) for p in os.listdir('/proc') if p.isdigit() and int(p) > 1] diff --git a/tests/runner-e2e/pi-provider-fault.test.py b/tests/runner-e2e/pi-provider-fault.test.py index dff4d0149d2..06fbb59f43d 100644 --- a/tests/runner-e2e/pi-provider-fault.test.py +++ b/tests/runner-e2e/pi-provider-fault.test.py @@ -32,14 +32,68 @@ def check_cancelled(): NAMES = [fault.NODE, fault.ENTRY, fault.EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js'] +class ClosureMetadataTests(unittest.TestCase): + def setUp(self): + self.entries = [ + {'path': name, 'sha256': fault.digest(name.encode()), 'size': len(name), 'executable': name == fault.NODE} + for name in sorted(NAMES) + ] + self.canonical = json.dumps(self.entries, separators=(',', ':'), ensure_ascii=False).encode() + self.pin = fault.digest(self.canonical) + self.manifest = {'entries': self.entries} + + def test_canonical_entries_pin_admits_metadata(self): + raw = json.dumps(self.manifest, indent=2).encode() + self.assertNotEqual(fault.digest(raw), self.pin) + self.assertEqual(fault.parse_closure_metadata(raw, self.pin), self.entries) + + def test_metadata_formatting_and_property_order_preserve_pin(self): + reordered = {'entries': [dict(reversed(list(entry.items()))) for entry in self.entries]} + for manifest in [self.manifest, reordered]: + for indent in [None, 2, 4]: + with self.subTest(indent=indent): + self.assertEqual(fault.parse_closure_metadata(json.dumps(manifest, indent=indent).encode(), self.pin), self.entries) + + def test_raw_metadata_hash_is_not_a_closure_pin(self): + raw = json.dumps(self.manifest, indent=2).encode() + with self.assertRaisesRegex(RuntimeError, 'closure_pin'): + fault.parse_closure_metadata(raw, fault.digest(raw)) + + def test_changed_entry_is_rejected_by_the_pinned_digest(self): + changed = json.loads(json.dumps(self.manifest)) + changed['entries'][0]['sha256'] = '0' * 64 + with self.assertRaisesRegex(RuntimeError, 'closure_pin'): + fault.parse_closure_metadata(json.dumps(changed).encode(), self.pin) + + def test_unsafe_duplicate_unsorted_and_invalid_metadata_fail_closed(self): + mutations = [ + lambda m: m['entries'][0].update(path='../escape'), + lambda m: m['entries'][0].update(path='.paperclip-native-entry.cjs'), + lambda m: m['entries'].insert(1, m['entries'][0].copy()), + lambda m: m['entries'].reverse(), + lambda m: m['entries'][0].update(size=True), + lambda m: m['entries'][0].update(executable=1), + lambda m: m['entries'][0].update(extra='foreign'), + ] + for index, mutate in enumerate(mutations): + changed = json.loads(json.dumps(self.manifest)) + mutate(changed) + with self.subTest(index=index), self.assertRaises(RuntimeError): + fault.parse_closure_metadata(json.dumps(changed).encode(), self.pin) + for invalid in [None, [], {}, {'entries': []}]: + with self.subTest(invalid=invalid), self.assertRaises(RuntimeError): + fault.parse_closure_metadata(json.dumps(invalid).encode(), self.pin) + + class IdentityTests(unittest.TestCase): def setUp(self): self.files = {DIST + '/' + n: (n.encode(), (1, 100 + i)) for i, n in enumerate(NAMES)} self.files[RUNTIME + '/bin/paperclip-runnerd'] = (b'runner', (1, 90)) - closure = json.dumps({'entries': [{'path': n, 'sha256': fault.digest(n.encode()), 'size': len(n)} for n in NAMES]}).encode() + entries = [{'path': n, 'sha256': fault.digest(n.encode()), 'size': len(n), 'executable': n == fault.NODE} for n in sorted(NAMES)] + closure = json.dumps({'entries': entries}, indent=2).encode() self.files[fault.PACK + '/provider-assets/pi/linux-x64/native-closure.json'] = (closure, (1, 80)) self.config = {'root': ROOT, 'binding': {'remoteCwd': '/workspace', 'runId': 'run'}, 'runtimeEnvironmentLeaseId': 'workspace-id', - 'runnerdSha256': 'sha256:' + fault.digest(b'runner'), 'closureSha256': fault.digest(closure)} + 'runnerdSha256': 'sha256:' + fault.digest(b'runner'), 'closureSha256': fault.digest(json.dumps(entries, separators=(',', ':'), ensure_ascii=False).encode())} self.table = {21: ROOT, 22: PARENT, 23: TARGET} self.args = {21: [RUNTIME + '/bin/paperclip-runnerd', '--run-id', 'run', '--environment-lease-id', 'workspace-id', '--lifecycle-mode', 'per_turn', '--state-dir', RUNTIME + '/sessions/' + 'a' * 64 + '/runner'], 22: [DIST + '/' + fault.NODE, '--require', DIST + '/' + fault.GUARD, DIST + '/pi-entry.cjs'], 23: ['pi']} @@ -120,8 +174,8 @@ class PidfdTests(unittest.TestCase): (distribution / fault.ENTRY).write_text('process.title="pi"; require("node:fs").writeFileSync(' + json.dumps(str(ready)) + ',JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);') (distribution / 'pi-entry.cjs').write_text('const p=require("node:child_process").spawn(process.execPath,["--require",' + json.dumps(str(distribution / fault.GUARD)) + ',' + json.dumps(str(distribution / fault.ENTRY)) + '],{stdio:"ignore"});p.on("exit",(code,signal)=>require("node:fs").writeFileSync(' + json.dumps(str(exited)) + ',JSON.stringify({code,signal})));process.on("SIGTERM",()=>{if(p.exitCode!==null||p.signalCode!==null)process.exit(0);p.once("exit",()=>process.exit(0));p.kill("SIGTERM")});setInterval(()=>{},1000);') entries = [] - for name in NAMES: - p = distribution / name; content = p.read_bytes(); entries.append({'path': name, 'sha256': fault.digest(content), 'size': len(content)}) + for name in sorted(NAMES): + p = distribution / name; content = p.read_bytes(); entries.append({'path': name, 'sha256': fault.digest(content), 'size': len(content), 'executable': name == fault.NODE}) closure = json.dumps({'entries': entries}).encode(); (pack / 'provider-assets/pi/linux-x64/native-closure.json').write_bytes(closure) for directory, dirs, files in os.walk(snapshot): for f in files: @@ -143,7 +197,7 @@ class PidfdTests(unittest.TestCase): self.assertTrue(ready.exists()); target = json.loads(ready.read_text())['pid'] self.assertEqual(fault.argv(target), ['pi'], 'real Node process.title must overwrite argv') boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip() - config = {'root': fault.proc(root_child.pid, boot), 'binding': {'remoteCwd': str(workspace), 'runId': 'fixture'}, 'runtimeEnvironmentLeaseId': 'workspace-id', 'runnerdSha256': 'sha256:' + fault.digest((runtime / 'bin/paperclip-runnerd').read_bytes()), 'closureSha256': fault.digest(closure)} + config = {'root': fault.proc(root_child.pid, boot), 'binding': {'remoteCwd': str(workspace), 'runId': 'fixture'}, 'runtimeEnvironmentLeaseId': 'workspace-id', 'runnerdSha256': 'sha256:' + fault.digest((runtime / 'bin/paperclip-runnerd').read_bytes()), 'closureSha256': fault.digest(json.dumps(entries, separators=(',', ':'), ensure_ascii=False).encode())} with patch.object(fault, 'PACK', str(pack)): admitted = fault.inspect(config) self.assertEqual(admitted['target']['pid'], target)