From 42c6fe0dfc465e8c64bc448e556da0a5a1ebd728 Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Tue, 20 Oct 2026 23:07:24 +0200 Subject: [PATCH 1/2] Retain bounded static ELF dependency correspondence for BAR image --- containers/barc-stock-headless/README.md | 10 ++ containers/barc-stock-headless/build-image.py | 150 ++++++++++++++++++ containers/barc-stock-headless/test_source.py | 134 ++++++++++++++++ 3 files changed, 294 insertions(+) diff --git a/containers/barc-stock-headless/README.md b/containers/barc-stock-headless/README.md index 9034b0c..3c44b6f 100644 --- a/containers/barc-stock-headless/README.md +++ b/containers/barc-stock-headless/README.md @@ -31,6 +31,16 @@ The builder fetches the existing HighBar vcpkg baseline256acc640… and verifies The builder records `/opt/barc/toolchain/ubuntu-packages.txt`, `vcpkg-status.txt`, generated manifest, tool-byte SHA256SUMS and MANIFEST.SHA256. Subsequent stages verify those produced bytes. The maintained platform runner separately retains the actual resulting immutable image ID and logs. These records are qualification OUTPUTS; they neither require a previously qualified image nor establish a successful build in advance. Official parent metadata is verified, but raw layer/config verification and the full resulting toolchain/build/ELF closure remain first-build obligations. +## Static ELF correspondence + +Before the integration stage launches the downloader, it writes `/opt/barc/STATIC-ELF.json` from the actual selected engine and produced plugin. Both root hashes must match the pinned engine digest and the existing plugin digest manifest. The collector reads ELF bytes directly; it never invokes a loader, `ldd`, the engine or the plugin. It follows `DT_NEEDED` and the selected x86-64 interpreter recursively, records file hashes, byte sizes, metadata, dependency edges and resolved aliases, then checks the complete inventory again. A previous output is never overwritten. A failed collection produces no success manifest. + +This is a deliberately limited image-local **candidate correspondence**, not the dynamic loader's search algorithm. Candidate directories are each object's directory, its explicit `$ORIGIN`-relative RPATH or RUNPATH, and the fixed image directories `/lib/x86_64-linux-gnu`, `/usr/lib/x86_64-linux-gnu`, `/lib64` and `/usr/lib64`. Aliases to the same canonical file deduplicate; distinct candidates refuse even when their bytes match. Missing dependencies, escaping or changing aliases, SONAME mismatches, malformed ELF, audit/filter dependencies, NODEFLIB, combined RPATH/RUNPATH and unsupported path expansions refuse. Only little-endian ELF64 x86-64 executables/shared objects and `/lib64/ld-linux-x86-64.so.2` are supported. A real build exposing another arrangement requires a reviewed source correction in the same Delivery. + +Bounds are256 files/512MiB aggregate,256MiB per file,256 program headers,4096 dynamic entries,1MiB string metadata,128 dependencies and16 search directories per object. The admitted outer image operation still owns the original deadline, process custody and resource ceiling. This source does not establish loader precedence, inherited RPATH, cache/hardware-capability selection, symbol/version compatibility, dynamic `dlopen` populations or loaded ABI. `loadedAbiAccepted` remains false. Actual image creation/export/load/readback, static correspondence against that image, scoped retirement and native gameplay keep their separate acceptance requirements and the existing old-writer/NVMe resource hold. + +`python3 containers/barc-stock-headless/test_source.py` exercises the production collector with authored ELF bytes, including recursive closure, digest drift, missing/ambiguous dependencies, confinement, cycles, alias changes and bounded populations. One bounded `readelf` invocation independently checks an authored fixture; no fixture is compiled or executed. These controls establish source behavior, not a qualified image. + ## Build and runtime boundaries `python3 containers/barc-stock-headless/build-image.py --mode preflight` performs inert data-only pin/shape checks before any image pull. After existing machine admission, use the repository root as the container context and this Containerfile. No toolchain build arguments are needed. Its default final stage acquires the full selected engine/game/map runtime. `--target development` is available for source/plugin work. Image creation does not run spring-headless or invent a native acceptance attempt. diff --git a/containers/barc-stock-headless/build-image.py b/containers/barc-stock-headless/build-image.py index 5e05841..b49785e 100644 --- a/containers/barc-stock-headless/build-image.py +++ b/containers/barc-stock-headless/build-image.py @@ -47,6 +47,150 @@ def sha(path, maximum): result.update(block) return result.hexdigest() +def elf_metadata(path): + """Read bounded ELF64 metadata as data; never invoke a loader or the file.""" + require(path.is_file() and not path.is_symlink(), 'ELF input must be a regular unlinked file') + size = path.stat().st_size + require(64 <= size <= 256 * 1024 * 1024, 'ELF file byte budget exceeded') + with path.open('rb') as stream: + def read(offset, count): + require(0 <= offset <= size and 0 <= count <= 1024 * 1024 and offset + count <= size, + 'truncated or excessive ELF metadata') + stream.seek(offset); value = stream.read(count) + require(len(value) == count, 'ELF changed during metadata read') + return value + header = struct.unpack('<16sHHIQQQIHHHHHH', read(0, 64)) + require(header[0][:7] == b'\x7fELF\x02\x01\x01' and header[0][7] in (0, 3) and header[1] in (2, 3) + and header[2:4] == (62, 1) and header[8] == 64, + 'only executable/shared ELF64 little-endian x86-64 is supported') + require(header[5] >= 64 and header[9] == 56 and 0 < header[10] <= 256, 'unsupported ELF program-header population') + segments = [struct.unpack(' Date: Tue, 20 Oct 2026 23:10:18 +0200 Subject: [PATCH 2/2] Bind bounded ELF reads and preserve repeated alias identities --- containers/barc-stock-headless/README.md | 2 +- containers/barc-stock-headless/build-image.py | 60 ++++++++++++++----- containers/barc-stock-headless/test_source.py | 44 ++++++++++++-- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/containers/barc-stock-headless/README.md b/containers/barc-stock-headless/README.md index 3c44b6f..d37fe08 100644 --- a/containers/barc-stock-headless/README.md +++ b/containers/barc-stock-headless/README.md @@ -37,7 +37,7 @@ Before the integration stage launches the downloader, it writes `/opt/barc/STATI This is a deliberately limited image-local **candidate correspondence**, not the dynamic loader's search algorithm. Candidate directories are each object's directory, its explicit `$ORIGIN`-relative RPATH or RUNPATH, and the fixed image directories `/lib/x86_64-linux-gnu`, `/usr/lib/x86_64-linux-gnu`, `/lib64` and `/usr/lib64`. Aliases to the same canonical file deduplicate; distinct candidates refuse even when their bytes match. Missing dependencies, escaping or changing aliases, SONAME mismatches, malformed ELF, audit/filter dependencies, NODEFLIB, combined RPATH/RUNPATH and unsupported path expansions refuse. Only little-endian ELF64 x86-64 executables/shared objects and `/lib64/ld-linux-x86-64.so.2` are supported. A real build exposing another arrangement requires a reviewed source correction in the same Delivery. -Bounds are256 files/512MiB aggregate,256MiB per file,256 program headers,4096 dynamic entries,1MiB string metadata,128 dependencies and16 search directories per object. The admitted outer image operation still owns the original deadline, process custody and resource ceiling. This source does not establish loader precedence, inherited RPATH, cache/hardware-capability selection, symbol/version compatibility, dynamic `dlopen` populations or loaded ABI. `loadedAbiAccepted` remains false. Actual image creation/export/load/readback, static correspondence against that image, scoped retirement and native gameplay keep their separate acceptance requirements and the existing old-writer/NVMe resource hold. +Bounds are256 files/512MiB aggregate,256MiB per file,256 program headers,4096 dynamic entries,1MiB string metadata/2MiB cumulative metadata reads,128 dependencies and16 search directories per object. Hashing charges actual accepted bytes and reads at most the initially admitted descriptor size. Nonblocking/no-follow opens, regular-file descriptor checks and before/after descriptor/path identity checks refuse growing, replaced or nonregular inputs. Repeated alias lookups must retain the original resolution. The admitted outer image operation still owns the original deadline, process custody and resource ceiling. This source does not establish loader precedence, inherited RPATH, cache/hardware-capability selection, symbol/version compatibility, dynamic `dlopen` populations or loaded ABI. `loadedAbiAccepted` remains false. Actual image creation/export/load/readback, static correspondence against that image, scoped retirement and native gameplay keep their separate acceptance requirements and the existing old-writer/NVMe resource hold. `python3 containers/barc-stock-headless/test_source.py` exercises the production collector with authored ELF bytes, including recursive closure, digest drift, missing/ambiguous dependencies, confinement, cycles, alias changes and bounded populations. One bounded `readelf` invocation independently checks an authored fixture; no fixture is compiled or executed. These controls establish source behavior, not a qualified image. diff --git a/containers/barc-stock-headless/build-image.py b/containers/barc-stock-headless/build-image.py index b49785e..dc66c58 100644 --- a/containers/barc-stock-headless/build-image.py +++ b/containers/barc-stock-headless/build-image.py @@ -10,6 +10,8 @@ import re import shutil import subprocess +import contextlib +import stat ROOT = pathlib.Path(__file__).resolve().parent SHA = re.compile(r'[0-9a-f]{64}') @@ -38,25 +40,49 @@ def check_inputs(pins): require(pins['game']['name'] == 'Beyond All Reason test-29926-0571aa8', 'moving/unselected game name refused') require(pins['game']['sdp'] == '7fd33d9ceb79c052065b8d3c2f6f2687.sdp', 'game package identity differs') -def sha(path, maximum): - require(path.is_file() and not path.is_symlink(), 'missing/linked input: ' + str(path)) - require(path.stat().st_size <= maximum, 'input exceeds byte budget: ' + str(path)) - result = hashlib.sha256() - with path.open('rb') as stream: - while block := stream.read(1024 * 1024): +def file_identity(info): + return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns) + +@contextlib.contextmanager +def regular_input(path, maximum): + # NONBLOCK prevents a raced FIFO from blocking open before the fstat check. + require(not path.is_symlink(), 'linked input refused') + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, 'rb') as stream: + before = os.fstat(stream.fileno()) + require(stat.S_ISREG(before.st_mode) and before.st_size <= maximum, 'input is nonregular or exceeds byte budget') + require(file_identity(path.lstat()) == file_identity(before), 'input replaced before read') + yield stream, before.st_size + require(file_identity(os.fstat(stream.fileno())) == file_identity(before) + and file_identity(path.lstat()) == file_identity(before), 'input grew or was replaced during read') + +def file_digest(path, maximum): + result = hashlib.sha256(); count = 0 + with regular_input(path, maximum) as (stream, size): + while count < size: + block = stream.read(min(1024 * 1024, size - count)) + require(bool(block), 'input truncated during read') + count += len(block) + require(count <= maximum, 'input read exceeds byte budget') result.update(block) - return result.hexdigest() + require(count == size, 'input size changed during read') + return result.hexdigest(), count + +def sha(path, maximum): + return file_digest(path, maximum)[0] def elf_metadata(path): """Read bounded ELF64 metadata as data; never invoke a loader or the file.""" - require(path.is_file() and not path.is_symlink(), 'ELF input must be a regular unlinked file') - size = path.stat().st_size - require(64 <= size <= 256 * 1024 * 1024, 'ELF file byte budget exceeded') - with path.open('rb') as stream: + with regular_input(path, 256 * 1024 * 1024) as (stream, size): + require(size >= 64, 'truncated ELF file') + metadata_bytes = 0 def read(offset, count): + nonlocal metadata_bytes require(0 <= offset <= size and 0 <= count <= 1024 * 1024 and offset + count <= size, 'truncated or excessive ELF metadata') + require(metadata_bytes + count <= 2 * 1024 * 1024, 'ELF cumulative metadata read budget exceeded') stream.seek(offset); value = stream.read(count) + metadata_bytes += len(value) require(len(value) == count, 'ELF changed during metadata read') return value header = struct.unpack('<16sHHIQQQIHHHHHH', read(0, 64)) @@ -125,7 +151,10 @@ def confined(path): resolved = path.resolve(strict=True) require(resolved.is_relative_to(image_root) and resolved.is_file(), 'ELF candidate escapes image or is nonregular') require(path.is_relative_to(image_root), 'ELF input path escapes image') - aliases['/' + path.relative_to(image_root).as_posix()] = '/' + resolved.relative_to(image_root).as_posix() + selected = '/' + path.relative_to(image_root).as_posix() + destination = '/' + resolved.relative_to(image_root).as_posix() + require(selected not in aliases or aliases[selected] == destination, 'ELF dependency alias changed between lookups') + aliases[selected] = destination return resolved roots = {'engine': confined(engine), 'plugin': confined(plugin)} pending = list(roots.values()); records = {}; total = 0 @@ -135,9 +164,10 @@ def confined(path): key = '/' + path.relative_to(image_root).as_posix() if key in records: continue require(len(records) < 256, 'ELF dependency closure population exceeded') - total += path.stat().st_size + before, accepted_bytes = file_digest(path, min(256 * 1024 * 1024, 512 * 1024 * 1024 - total)) + total += accepted_bytes require(total <= 512 * 1024 * 1024, 'ELF dependency closure byte budget exceeded') - before = sha(path, 256 * 1024 * 1024); metadata = elf_metadata(path) + metadata = elf_metadata(path) directories = [path.parent, *defaults] if metadata['searchPath'] is not None: entries = metadata['searchPath'].split(':') @@ -164,7 +194,7 @@ def confined(path): edges.append({'interpreter': metadata['interpreter'], 'path': '/' + candidate.relative_to(image_root).as_posix()}) pending.append(candidate) require(sha(path, 256 * 1024 * 1024) == before, 'ELF changed during correspondence') - records[key] = {'sha256': before, 'bytes': path.stat().st_size, 'metadata': metadata, 'dependencies': edges} + records[key] = {'sha256': before, 'bytes': accepted_bytes, 'metadata': metadata, 'dependencies': edges} # A final sweep binds dependencies and both roots to one unchanged inventory. for key, record in records.items(): require(sha(image_root / key.lstrip('/'), 256 * 1024 * 1024) == record['sha256'], 'ELF closure changed during collection') diff --git a/containers/barc-stock-headless/test_source.py b/containers/barc-stock-headless/test_source.py index c880261..c635b52 100644 --- a/containers/barc-stock-headless/test_source.py +++ b/containers/barc-stock-headless/test_source.py @@ -5,6 +5,7 @@ import importlib.util import json import pathlib +import os import struct import subprocess import tempfile @@ -107,13 +108,48 @@ def test_closure_population_is_bounded(self): def test_alias_retarget_during_collection_refuses(self): alias = self.image/'lib64/liba.so'; alias.symlink_to(self.liba) replacement = elf_fixture(self.image/'spare/liba.so', soname='liba.so', needed=['libb.so']) - original = module.sha; calls = [] + original = module.file_digest; calls = [] def change(path, maximum): result = original(path, maximum); calls.append(path) if path == self.libb and calls.count(path) == 2: alias.unlink(); alias.symlink_to(replacement) return result - with mock.patch.object(module, 'sha', side_effect=change), self.assertRaisesRegex(ValueError, 'alias changed'): self.collect() + with mock.patch.object(module, 'file_digest', side_effect=change), self.assertRaisesRegex(ValueError, 'alias changed'): self.collect() + def test_alias_retarget_between_repeated_lookups_refuses(self): + target = self.image/'spare/liba.so' + target.parent.mkdir(); self.liba.rename(target); self.liba.symlink_to(target) + replacement = elf_fixture(self.image/'other/liba.so', soname='liba.so', needed=['libb.so']) + original = module.elf_metadata + def change(path): + if path == self.plugin: + self.liba.unlink(); self.liba.symlink_to(replacement) + return original(path) + with mock.patch.object(module, 'elf_metadata', side_effect=change), self.assertRaisesRegex(ValueError, 'between lookups'): self.collect() + def test_growing_hash_input_is_bounded_during_actual_read(self): + path = self.image/'growing'; path.write_bytes(b'abcd'); original = module.os.fdopen; requested = [] + class Reader: + def __init__(self, stream): self.stream = stream + def __enter__(self): return self + def __exit__(self, *args): self.stream.close() + def fileno(self): return self.stream.fileno() + def read(self, count): + requested.append(count) + with path.open('ab') as writer: writer.write(b'x'*100) + return self.stream.read(count) + with mock.patch.object(module.os, 'fdopen', side_effect=lambda *args: Reader(original(*args))): + with self.assertRaisesRegex(ValueError, 'grew or was replaced'): module.file_digest(path, 8) + self.assertEqual(requested, [4]) + def test_replaced_file_after_open_is_refused(self): + path = self.image/'replaced'; path.write_bytes(b'old'); original = module.os.fdopen + def replace(*args): + stream = original(*args); path.unlink(); path.write_bytes(b'new'); return stream + with mock.patch.object(module.os, 'fdopen', side_effect=replace), self.assertRaisesRegex(ValueError, 'replaced before read'): + module.file_digest(path, 8) + def test_nonregular_and_linked_hash_inputs_refuse_without_blocking(self): + path = self.image/'fifo'; os.mkfifo(path) + with self.assertRaisesRegex(ValueError, 'nonregular'): module.file_digest(path, 8) + path.unlink(); path.symlink_to(self.libb) + with self.assertRaises((ValueError, OSError)): module.file_digest(path, 8192) def test_cycles_terminate_without_omitting_edges(self): elf_fixture(self.libb, soname='libb.so', needed=['liba.so']) result = self.collect() @@ -128,13 +164,13 @@ def test_origin_directory_and_unsupported_search_semantics(self): elf_fixture(self.liba, soname='liba.so', needed=['libb.so'], search=value) with self.assertRaises(ValueError): self.collect() def test_changed_closure_refuses_final_sweep(self): - original = module.sha; calls = [] + original = module.file_digest; calls = [] def change(path, maximum): result = original(path, maximum); calls.append(path) if path == self.libb and calls.count(path) == 2: data = bytearray(self.engine.read_bytes()); data[-1] ^= 1; self.engine.write_bytes(data) return result - with mock.patch.object(module, 'sha', side_effect=change), self.assertRaisesRegex(ValueError, 'closure changed'): self.collect() + with mock.patch.object(module, 'file_digest', side_effect=change), self.assertRaisesRegex(ValueError, 'closure changed'): self.collect() def test_malformed_metadata_and_unsupported_dynamic_features(self): original = self.libb.read_bytes() mutations = [(4, b'\x01'), (5, b'\x02'), (18, b'\xb7\x00'), (54, b'\x00\x00'),