Skip to content

Commit 0bde1e6

Browse files
committed
[red] reproduce evidence writer inode lifetime gaps
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 8c7a1ff commit 0bde1e6

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: MIT
3+
# Part of PyBLE (https://pyble.dev) — see /LICENSE.
4+
"""Host-only regressions for exclusive evidence-file inode ownership.
5+
6+
Descriptor-lifetime assertions are deterministic on both macOS and Linux;
7+
replacement checks must not depend on how quickly a filesystem reuses inodes.
8+
Synthetic files do not constitute physical qualification evidence.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from contextlib import ExitStack, contextmanager
14+
import importlib.util
15+
import os
16+
from pathlib import Path
17+
import tempfile
18+
import unittest
19+
from unittest import mock
20+
21+
22+
ROOT = Path(__file__).resolve().parents[3]
23+
24+
25+
def load_gate(name):
26+
path = ROOT / "firmware" / "qualification" / (name + ".py")
27+
spec = importlib.util.spec_from_file_location(name + "_inode_test", path)
28+
if spec is None or spec.loader is None:
29+
raise AssertionError("cannot load evidence writer")
30+
module = importlib.util.module_from_spec(spec)
31+
spec.loader.exec_module(module)
32+
return module
33+
34+
35+
WRITERS = (
36+
(load_gate("v060_profile_release_gate"), "_write_exclusive_result", "_remove_created_result"),
37+
(load_gate("v061_hardening_release_gate"), "_write_exclusive", "_remove_created"),
38+
)
39+
40+
41+
@contextmanager
42+
def observe_writer_descriptor(gate):
43+
"""Observe the real descriptor without extending its lifetime for the test."""
44+
state = {"descriptor": None, "closed": False}
45+
real_open, real_close = os.open, os.close
46+
47+
def open_file(path, flags, *args, **kwargs):
48+
descriptor = real_open(path, flags, *args, **kwargs)
49+
if flags & os.O_CREAT:
50+
if state["descriptor"] is not None:
51+
raise AssertionError("writer created more than one output")
52+
state["descriptor"] = descriptor
53+
return descriptor
54+
55+
def close_file(descriptor):
56+
if descriptor == state["descriptor"]:
57+
state["closed"] = True
58+
return real_close(descriptor)
59+
60+
with ExitStack() as stack:
61+
stack.enter_context(mock.patch.object(gate.os, "open", side_effect=open_file))
62+
stack.enter_context(mock.patch.object(gate.os, "close", side_effect=close_file))
63+
yield state
64+
65+
66+
class EvidenceWriterInodeLifetimeTests(unittest.TestCase):
67+
def assert_writer_pinned(self, state):
68+
self.assertIsNotNone(state["descriptor"])
69+
self.assertFalse(
70+
state["closed"],
71+
"original inode must remain pinned until publication or cleanup finishes",
72+
)
73+
return os.fstat(state["descriptor"])
74+
75+
def test_writer_is_pinned_until_successful_publication(self):
76+
for gate, writer_name, _cleanup_name in WRITERS:
77+
with self.subTest(writer=writer_name), tempfile.TemporaryDirectory() as tmp:
78+
output = Path(tmp).resolve() / "result.json"
79+
raw = b"writer-owned evidence\n"
80+
with observe_writer_descriptor(gate) as state:
81+
getattr(gate, writer_name)(
82+
output,
83+
raw,
84+
post_write_check=lambda: self.assert_writer_pinned(state),
85+
)
86+
self.assertTrue(state["closed"], "successful writer must close its descriptor")
87+
self.assertEqual(output.read_bytes(), raw)
88+
89+
def test_writer_is_pinned_through_failed_publication_cleanup(self):
90+
for gate, writer_name, cleanup_name in WRITERS:
91+
with self.subTest(writer=writer_name), tempfile.TemporaryDirectory() as tmp:
92+
output = Path(tmp).resolve() / "result.json"
93+
original_cleanup = getattr(gate, cleanup_name)
94+
95+
def fail_validation():
96+
raise gate.QualificationError("synthetic validation failure")
97+
98+
with observe_writer_descriptor(gate) as state:
99+
def cleanup(*args):
100+
self.assert_writer_pinned(state)
101+
return original_cleanup(*args)
102+
103+
with mock.patch.object(gate, cleanup_name, side_effect=cleanup) as observed:
104+
with self.assertRaises(gate.QualificationError):
105+
getattr(gate, writer_name)(
106+
output,
107+
b"writer-owned evidence\n",
108+
post_write_check=fail_validation,
109+
)
110+
observed.assert_called_once()
111+
self.assertTrue(state["closed"], "failed writer must close its descriptor")
112+
self.assertFalse(output.exists())
113+
114+
def test_replacements_are_rejected_and_preserved_even_with_identical_bytes(self):
115+
raw = b"writer-owned evidence\n"
116+
for gate, writer_name, _cleanup_name in WRITERS:
117+
for replacement in (raw, b"replacement-owned evidence\n"):
118+
with self.subTest(writer=writer_name, identical=replacement == raw):
119+
with tempfile.TemporaryDirectory() as tmp:
120+
output = Path(tmp).resolve() / "result.json"
121+
122+
def replace_output():
123+
output.unlink()
124+
output.write_bytes(replacement)
125+
output.chmod(0o600)
126+
127+
with self.assertRaises(gate.QualificationError):
128+
getattr(gate, writer_name)(
129+
output,
130+
raw,
131+
post_write_check=replace_output,
132+
)
133+
self.assertEqual(output.read_bytes(), replacement)
134+
135+
136+
if __name__ == "__main__":
137+
unittest.main()

0 commit comments

Comments
 (0)