diff --git a/AGENTS.md b/AGENTS.md index 527abf7..66e028d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,11 @@ and index/platform/config tuple. Its hosted acquisition and filesystem qualification must succeed before wrapper metadata/inventory pins are adopted; release publication alone does not establish those inventories. + Docker archive views may mutate root directory size/mtime while switching + roots: validate those fields under the exact `/` schema, retain exact stable + root fields and bounded UID/mode ownership headers before and after protected + reads. Keep full stat equality for every nonroot path; see + `tools/RELEASED_SDK_INVENTORY.md` for the root fence contract. - Validate Dockerfiles with `docker build --check`. Build and smoke-test each affected image, including `classic-validation` before `classic-final` and the Windows `classic-check` target plus native bundle; note that a cold diff --git a/tools/RELEASED_SDK_INVENTORY.md b/tools/RELEASED_SDK_INVENTORY.md index a028712..574b4f2 100644 --- a/tools/RELEASED_SDK_INVENTORY.md +++ b/tools/RELEASED_SDK_INVENTORY.md @@ -26,6 +26,51 @@ file, 8 GiB aggregate payload and a shared 900-second deadline; single-file reads use the same deadline and aggregate accounting. The existing collector's operation alarm and socket timeout remain required outer bounds. +On a failed whole-path-stat comparison, `inventory_failure.path_stat_drift` +records the fixed comparison site (`directory-header`, `producer-header`, +`regular-header` or `recheck`), a fixed subject class (`root-directory`, +`directory` or `file`), changed known field names (`name`, `size`, `mode`, +`mtime`, `linkTarget`), and booleans for field-set and unknown-field changes. +It never retains stat values, arbitrary field names, paths or exception text. +Every nonroot path retains full stat equality, including timestamps, sizes and +unknown fields, before inventories or scanner qualification can succeed. + +Moby Docker 29.4.2 opens a new filesystem view for each +[`HEAD` or `GET` archive operation](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/archive_unix.go). +Its [`filesystem view`](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/containerfs_linux.go) +calls [`SwitchRoot`](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/internal/mounttree/switchroot_linux.go), +which can create and remove a temporary directory directly beneath the root. +The resulting root `mtime` is specific to that filesystem view. Root `size` is +the raw `lstat.Size()` of the directory: Go documents nonregular-file size as +[system-dependent](https://go.dev/src/io/fs/fs.go), and Linux directories store +[directory entries in filesystem data blocks](https://docs.kernel.org/filesystems/ext4/directory.html). +Creating/removing a directory entry does not guarantee restoring that storage +size. This differs from the root tar header's required zero payload size. + +Only the exact `/` path uses a separate metadata fence. Both root stats must +have exactly `name`, `size`, `mode`, `mtime` and `linkTarget`; unknown or absent +fields fail even when both snapshots agree. Name must be `/`, link target must +be empty, and mode must be a true integer containing only the Go directory bit +and safe permissions. These fields remain exactly equal across views. Size must +be a true integer in `0..2^63-1`; mtime must be a valid calendar timestamp in +RFC3339Nano form (years 1–9999, at most nine fractional digits, explicit UTC or +numeric offset). These two view-local fields are validated but not compared for +equality at `/`. This exception never applies to any other path. + +Protected reads retain the exact created-container identity, selected image, +mount, read-only and network isolation fences before and after reading bytes. +Root ownership is checked through a bounded GET header both before and after +the protected read: exact `/` name, directory type, matching mode, UID 0 and +zero tar payload size. Each stream closes after the header. The existing GID +policy is unchanged. All provider/tool bytes and nonroot ancestor stats remain +fenced, and the separate scanner must still prove complete inventory equality. + +The Moby source proves that root timestamp equality is an invalid cross-view +invariant. It does not establish the actual comparison site or changed field +in a hosted failure whose stats were not retained. Source-derived fixtures and +diagnostics do not qualify the released SDK; actual hosted collection and +filesystem equality remain required. + Each producer record is `relative_path -> {sha256, size, executable}`, including zero-byte files. Canonical sorted compact JSON uses the wrapper's `linux_portable.inventory_digest` format. Common licenses use exactly the diff --git a/tools/collect-released-sdk-metadata.py b/tools/collect-released-sdk-metadata.py index aaebd00..9acf228 100644 --- a/tools/collect-released-sdk-metadata.py +++ b/tools/collect-released-sdk-metadata.py @@ -938,7 +938,7 @@ def collect_final_inventory(container, data, file_records, llvm_tools, policy, * progress['stage'] = 'metadata' protocol = SimpleNamespace(BINARY_LIMIT=BINARY_LIMIT, stat_matches=stat_matches, regular_mode=regular_mode, path_stat=path_stat, single_file_archive=single_file_archive, pax_times=pax_times) - reader = INVENTORY.InstalledInventory(container, protocol) + reader = INVENTORY.InstalledInventory(container, protocol, progress=progress) raw = {path.removeprefix(INVENTORY.ROOT + '/'): value for path, value in data.items() if path.startswith(INVENTORY.ROOT + '/')} records = {name: file_records[INVENTORY.ROOT + '/' + name] for name in raw} diff --git a/tools/released_sdk_inventory.py b/tools/released_sdk_inventory.py index d81ff7e..524092c 100644 --- a/tools/released_sdk_inventory.py +++ b/tools/released_sdk_inventory.py @@ -2,11 +2,12 @@ # SPDX-License-Identifier: MIT """Internal fixed-boundary inventory reader; no CLI or acquisition authority. -The caller must supply the collector's authenticated, created-state StoppedImage -and its archive protocol. This foundation is deliberately not wired to a release -target: final producer/consumer/index/platform/config pins are still required. +The fixed final-target collector supplies its authenticated, created-state +StoppedImage and archive protocol. This helper grants no acquisition or release +trust; target pins and hosted filesystem qualification belong to the caller. """ from abc import ABC, abstractmethod +import datetime import hashlib import json from pathlib import PurePosixPath @@ -528,9 +529,58 @@ def read(self, size): class InstalledInventory(ProviderReader): """Use only with an authenticated StoppedImage; does not grant release trust.""" - def __init__(self, image, protocol): + def __init__(self, image, protocol, *, progress=None): self.image, self.protocol = image, protocol self.budget = Budget() + self.progress = progress + + def _same_stat(self, actual, expected, *, site, path): + """Fence stable root fields and full nonroot stats; retain no values.""" + require(type(actual) is dict and type(expected) is dict, 'inventory-path-drift') + matches = actual == expected + if path == '/': + # Moby's separate filesystem views create/remove a pivot directory + # beneath /. Its mtime and directory storage size are view-local. + # Only these two validated fields are excluded from root equality. + matches = (self._valid_root_stat(actual) and self._valid_root_stat(expected) + and {key: value for key, value in actual.items() if key not in ('size', 'mtime')} == + {key: value for key, value in expected.items() if key not in ('size', 'mtime')}) + if not matches and self.progress is not None: + fields = ('name', 'size', 'mode', 'mtime', 'linkTarget') + mode = expected.get('mode') + subject = ('root-directory' if path == '/' else + 'directory' if type(mode) is int and mode & GO_DIRECTORY else 'file') + self.progress['path_stat_drift'] = { + 'site': site, + 'subject': subject, + 'changed_fields': [key for key in fields + if (key in actual) != (key in expected) + or actual.get(key) != expected.get(key)], + 'field_set_changed': actual.keys() != expected.keys(), + 'unknown_fields_changed': + {key: value for key, value in actual.items() if key not in fields} != + {key: value for key, value in expected.items() if key not in fields}, + } + require(matches, 'inventory-path-drift') + + @staticmethod + def _valid_root_stat(value): + if type(value) is not dict or set(value) != {'name', 'size', 'mode', 'mtime', 'linkTarget'}: + return False + mode, size, mtime = value['mode'], value['size'], value['mtime'] + if not (value['name'] == '/' and value['linkTarget'] == '' + and type(mode) is int and mode & ~0o777 == GO_DIRECTORY + and mode & 0o022 == 0 and mode & 0o555 == 0o555 + and type(size) is int and 0 <= size <= 2**63 - 1 + and type(mtime) is str and len(mtime) <= 35 + and re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]' + r'(?:\.[0-9]{1,9})?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])', mtime)): + return False + try: + datetime.datetime.fromisoformat(mtime) + except ValueError: + return False + return True def _fence(self): self.budget.check() @@ -545,7 +595,8 @@ def _directory_owner(self, path, expected): """ conn, response = self._get(path) try: - require(self.protocol.path_stat(response) == expected, 'inventory-path-drift') + self._same_stat(self.protocol.path_stat(response), expected, + site='directory-header', path=path) framing(response) info = next_member(response, self.budget, self.protocol) names = {'/'} if path == '/' else {PurePosixPath(path).name} @@ -564,7 +615,7 @@ def _parents(self, path, *, protected=False): self.budget.check() value = self.image.stat(str(parent)) if str(parent) == '/': - require(value.get('name') == '/', 'inventory-directory-entry') + require(self._valid_root_stat(value), 'inventory-directory-entry') self.protocol.stat_matches({**value, 'name': ''}, '/', directory=True) else: self.protocol.stat_matches(value, str(parent), directory=True) @@ -576,7 +627,12 @@ def _parents(self, path, *, protected=False): def _recheck(self, observations): for path, before in observations.items(): self.budget.check() - require(self.image.stat(path) == before, 'inventory-path-drift') + after = self.image.stat(path) + self._same_stat(after, before, site='recheck', path=path) + if path == '/': + # HEAD cannot prove ownership. Recheck the bounded UID/mode + # header after protected bytes as well as before them. + self._directory_owner(path, after) self._fence() def _get(self, path): @@ -592,7 +648,8 @@ def producer(self): observations[ROOT] = expected conn, response = self._get(ROOT) try: - require(self.protocol.path_stat(response) == expected, 'inventory-path-drift') + self._same_stat(self.protocol.path_stat(response), expected, + site='producer-header', path=ROOT) result = tree_archive(response, self.protocol, expected, self.budget) finally: conn.close() @@ -627,7 +684,8 @@ def _regular(self, path, *, executable=False, provider=False): self.budget.member(expected['size']) conn, response = self._get(path) try: - require(self.protocol.path_stat(response) == expected, 'inventory-path-drift') + self._same_stat(self.protocol.path_stat(response), expected, + site='regular-header', path=path) identity, _ = self.protocol.single_file_archive( BudgetResponse(response, self.budget), path, expected, retain=False, executable=executable, root_owned=protected) self.budget.check() diff --git a/tools/tests/test_released_sdk_diagnostics.py b/tools/tests/test_released_sdk_diagnostics.py index e676fec..3ef5a00 100644 --- a/tools/tests/test_released_sdk_diagnostics.py +++ b/tools/tests/test_released_sdk_diagnostics.py @@ -59,7 +59,7 @@ def __hash__(self): with self.subTest(kind=type(error).__name__): self.assertEqual(I.failure_code(error), 'inventory-operation-failed') - def collect_failure(self, stage, error): + def collect_failure(self, stage, error, details=None): fixture = FIXTURES.OfflineTests() data, binary = fixture.portable_fixture() shader_path = I.ROOT + '/shader-generation.json' @@ -90,7 +90,7 @@ def collect_failure(self, stage, error): for operation in mocked[index + 1:]: operation.assert_not_called() self.assertEqual(result['status'], 'stopped') - self.assertEqual(result['inventory_failure'], {'stage': stage}) + self.assertEqual(result['inventory_failure'], {'stage': stage, **(details or {})}) self.assertTrue(result['owned_container_removed']) self.assertFalse(result['image_code_executed']) self.assertFalse(result['final_portable_cohort']) @@ -101,6 +101,19 @@ def collect_failure(self, stage, error): self.assertNotIn(secret, json.dumps(result)) return result + def test_actual_stat_comparison_reaches_receipt_without_values_or_execution(self): + def drift(_records, _runtime, _contract, *, reader): + expected = {'name': '/', 'size': 4096, 'mode': I.GO_DIRECTORY | 0o755, + 'mtime': 'before', 'linkTarget': ''} + actual = {**expected, 'mtime': SECRET, SECRET: SECRET} + with patch.object(reader.image, 'stat', return_value=actual): + reader._recheck({'/': expected}) + receipt = self.collect_failure('source-archives-and-providers', drift, { + 'path_stat_drift': {'site': 'recheck', 'subject': 'root-directory', + 'changed_fields': ['mtime'], 'field_set_changed': True, + 'unknown_fields_changed': True}}) + self.assertEqual(receipt['reason'], 'inventory-path-drift') + def test_each_inventory_stage_retains_fixed_code_and_stops_before_scanner(self): for stage, code in ( ('producer-tree', 'inventory-extension-or-link'), diff --git a/tools/tests/test_released_sdk_filesystem.py b/tools/tests/test_released_sdk_filesystem.py index 455761e..45c8078 100644 --- a/tools/tests/test_released_sdk_filesystem.py +++ b/tools/tests/test_released_sdk_filesystem.py @@ -431,7 +431,7 @@ def reader_check(producer, runtime, contract, *, reader): return source_check(producer, runtime, contract, reader=reader) expected_reader = reader reader_class = C.INVENTORY.InstalledInventory - def new_reader(cls, *args): + def new_reader(cls, *args, **kwargs): return expected_reader with patch.object(reader_class, '__new__', new_reader), \ patch.object(C.INVENTORY, 'crosscheck_archives', side_effect=reader_check) as crosscheck: diff --git a/tools/tests/test_released_sdk_inventory.py b/tools/tests/test_released_sdk_inventory.py index 7b2487a..0006268 100644 --- a/tools/tests/test_released_sdk_inventory.py +++ b/tools/tests/test_released_sdk_inventory.py @@ -43,7 +43,7 @@ def getheader(self, name, default=None): def directory(path): return {'name': Path(path).name if path != '/' else '/', 'mode': M.GO_DIRECTORY | 0o755, - 'size': 4096, 'mtime': 'fixed', 'linkTarget': ''} + 'size': 4096, 'mtime': '2026-10-13T01:02:03.123456789Z', 'linkTarget': ''} def regular(path, raw): @@ -861,6 +861,260 @@ def drift(method, route, headers): with self.assertRaisesRegex(M.InventoryError, 'inventory-path-drift'): M.InstalledInventory(image, P).build_tools(self.build_receipt()) + def test_malformed_root_mtime_retains_fixed_diagnostic_and_rejects(self): + image = self.installed_image() + request = image.docker.request.side_effect + def drift(method, route, headers): + conn, response = request(method, route, headers) + path = urllib.parse.parse_qs(urllib.parse.urlsplit(route).query)['path'][0] + self.assertEqual(path, '/') + response.headers['X-Docker-Container-Path-Stat'] = base64.b64encode( + json.dumps({**directory('/'), 'mtime': 'later-view'}).encode()).decode() + return conn, response + image.docker.request.side_effect = drift + progress = {'stage': 'source-archives-and-providers'} + reader = M.InstalledInventory(image, P) + reader.progress = progress + with self.assertRaisesRegex(M.InventoryError, 'inventory-path-drift'): + reader.read_provider('/usr/local/lib/libcurl.so.1.0.0') + self.assertEqual(progress, { + 'stage': 'source-archives-and-providers', + 'path_stat_drift': {'site': 'directory-header', 'subject': 'root-directory', + 'changed_fields': ['mtime'], 'field_set_changed': False, + 'unknown_fields_changed': False}}) + image.docker.request.assert_called_once() + image.connections[0].close.assert_called_once() + + def changing_root_image(self, payloads=None, *, fields=('size', 'mtime')): + """Source-derived Moby view model; not captured hosted observations.""" + image = self.installed_image(payloads) + stat, request = image.stat.side_effect, image.docker.request.side_effect + image.root_views = [] + def root_view(method, value): + image.root_views.append(method) + number = len(image.root_views) + changed = {'size': 4096 + number * 512, + 'mtime': f'2026-10-13T01:02:03.{number:09d}Z'} + return {**value, **{key: changed[key] for key in fields}} + def head(path): + value = stat(path) + return root_view('HEAD', value) if path == '/' else value + def get(method, route, headers): + conn, response = request(method, route, headers) + path = urllib.parse.parse_qs(urllib.parse.urlsplit(route).query)['path'][0] + if path == '/': + response.headers['X-Docker-Container-Path-Stat'] = base64.b64encode( + json.dumps(root_view('GET', directory('/'))).encode()).decode() + # Both the pre-read and post-read root GET must consume only + # the single ownership header and close the recursive stream. + read = response.read + def bounded(size): + self.assertEqual((response.tell(), size), (0, 512)) + return read(size) + response.read = bounded + return conn, response + image.stat.side_effect, image.docker.request.side_effect = head, get + return image + + def test_root_view_size_and_mtime_churn_preserves_protected_provider_and_tool_reads(self): + for fields in (('size',), ('mtime',), ('size', 'mtime')): + for tools in (False, True): + payloads = {path: b'tool' for path in M.BUILD_TOOLS.values()} if tools else None + image = self.changing_root_image(payloads, fields=fields) + progress = {} + reader = M.InstalledInventory(image, P, progress=progress) + with self.subTest(fields=fields, tools=tools): + if tools: + result = reader.build_tools(self.build_receipt()) + self.assertEqual(result, self.build_receipt()['build_tools']['tools']) + else: + result = reader.read_provider('/usr/local/lib/libcurl.so.1.0.0') + self.assertEqual(result, {'sha256': hashlib.sha256(b'libcurl').hexdigest(), + 'size': 7, 'executable': True}) + count = 3 if tools else 1 + self.assertEqual(image.root_views, ['HEAD', 'GET', 'HEAD', 'GET'] * count) + self.assertEqual(image.fence.call_count, 2 * count) + self.assertEqual(progress, {}) + for conn in image.connections: + conn.close.assert_called_once() + + def test_root_view_churn_never_masks_stable_field_or_schema_drift(self): + changes = ({'name': 'elsewhere'}, {'mode': M.GO_DIRECTORY | 0o555}, + {'mode': M.GO_DIRECTORY | 0o777}, {'mode': M.GO_SYMLINK | 0o755}, + {'mode': 0o755}, {'mode': True}, {'linkTarget': '/elsewhere'}, + {'extra': 'PRIVATE_VALUE'}, {'size': True}, {'size': -1}, + {'size': 2**63}, {'size': 4096.0}, {'mtime': None}, {'mtime': 1}, + {'mtime': '2026-02-30T01:02:03Z'}, {'mtime': '2026-10-13T24:00:00Z'}, + {'mtime': '2026-10-13T01:02:03'}, {'mtime': '2026-10-13T01:02:03+24:00'}, + {'mtime': '2026-10-13T01:02:03.1234567890Z'}) + for change in changes: + for phase in (1, 2): + image = self.changing_root_image() + request = image.docker.request.side_effect + calls = 0 + def get(method, route, headers): + nonlocal calls + conn, response = request(method, route, headers) + if urllib.parse.parse_qs(urllib.parse.urlsplit(route).query)['path'] == ['/']: + calls += 1 + if calls == phase: + value = {**P.path_stat(response), **change} + response.headers['X-Docker-Container-Path-Stat'] = base64.b64encode( + json.dumps(value).encode()).decode() + return conn, response + image.docker.request.side_effect = get + with self.subTest(change=change, phase=phase), self.assertRaisesRegex( + M.InventoryError, 'inventory-path-drift'): + M.InstalledInventory(image, P).read_provider('/usr/local/lib/libcurl.so.1.0.0') + for conn in image.connections: + conn.close.assert_called_once() + # Unknown or absent root fields also fail if both snapshots agree. + for value in ({**directory('/'), 'extra': 'same'}, + *[{key: item for key, item in directory('/').items() if key != missing} + for missing in directory('/')]): + with self.subTest(value=value), self.assertRaisesRegex(M.InventoryError, 'inventory-path-drift'): + M.InstalledInventory(self.fake_image(), P)._same_stat( + value, value, site='directory-header', path='/') + for value in (None, [], '/', 0): + for actual, expected in ((value, directory('/')), (directory('/'), value)): + with self.subTest(value=value), self.assertRaisesRegex(M.InventoryError, 'inventory-path-drift'): + M.InstalledInventory(self.fake_image(), P)._same_stat( + actual, expected, site='directory-header', path='/') + for mtime in ('2026-10-13T01:02:03Z', '2026-10-13T01:02:03.1+05:30', + '2026-10-13T01:02:03.123456789-06:00'): + self.assertTrue(M.InstalledInventory._valid_root_stat({**directory('/'), 'mtime': mtime})) + + def test_root_view_churn_keeps_postread_ownership_and_container_fences(self): + for extra in ({'uid': 1000}, {'mode': 0o555}, {'type': tarfile.SYMTYPE, 'linkname': 'other'}, + {'name': 'other'}, {'size': 1}): + image = self.changing_root_image() + request = image.docker.request.side_effect + calls = 0 + def get(method, route, headers): + nonlocal calls + conn, response = request(method, route, headers) + if urllib.parse.parse_qs(urllib.parse.urlsplit(route).query)['path'] == ['/']: + calls += 1 + if calls == 2: + response = Response(tar([('/', b'x' if extra.get('size') else b'', + tarfile.DIRTYPE, extra)]), P.path_stat(response)) + return conn, response + image.docker.request.side_effect = get + with self.subTest(extra=extra), self.assertRaises(M.InventoryError): + M.InstalledInventory(image, P).read_provider('/usr/local/lib/libcurl.so.1.0.0') + self.assertEqual(calls, 2) + for conn in image.connections: + conn.close.assert_called_once() + fixtures = load('root_fence_collector_fixture', ROOT / 'tools/tests/test_collect_released_sdk_metadata.py') + for changed in ({'Id': 'd' * 64}, {'State': {'Status': 'running', 'Running': True, 'Pid': 1}}, + {'Mounts': [{'Source': '/foreign'}]}, + {'HostConfig': {'ReadonlyRootfs': False, 'NetworkMode': 'none'}}, + {'HostConfig': {'ReadonlyRootfs': True, 'NetworkMode': 'host'}}, + {'Image': 'sha256:' + 'd' * 64}, {'ImageManifestDescriptor': {}}): + image = self.changing_root_image() + owned, pristine = fixtures.OfflineTests().stopped() + owned.docker.get.side_effect = [pristine, {**pristine, **changed}] + image.fence.side_effect = owned.fence + with self.subTest(changed=changed), self.assertRaises(fixtures.M.Stop): + M.InstalledInventory(image, P).read_provider('/usr/local/lib/libcurl.so.1.0.0') + self.assertEqual(image.root_views, ['HEAD', 'GET', 'HEAD', 'GET']) + self.assertEqual(owned.docker.get.call_count, 2) + for conn in image.connections: + conn.close.assert_called_once() + + def test_root_view_churn_preserves_full_nonroot_stats(self): + for path in ('/usr', '/usr/local', '/usr/local/lib', '/usr/local/lib/libcurl.so.1.0.0'): + for field in ('size', 'mtime'): + for site in ('header', 'recheck'): + image = self.changing_root_image() + stat, request = image.stat.side_effect, image.docker.request.side_effect + calls = 0 + def changed(value): + return {**value, field: value[field] + 1 if field == 'size' + else '2026-10-13T01:02:04.123456789Z'} + def head(requested): + nonlocal calls + value = stat(requested) + if requested == path: + calls += 1 + if site == 'recheck' and calls == 2: + value = changed(value) + return value + def get(method, route, headers): + conn, response = request(method, route, headers) + if site == 'header' and urllib.parse.parse_qs( + urllib.parse.urlsplit(route).query)['path'] == [path]: + response.headers['X-Docker-Container-Path-Stat'] = base64.b64encode( + json.dumps(changed(P.path_stat(response))).encode()).decode() + return conn, response + image.stat.side_effect, image.docker.request.side_effect = head, get + with self.subTest(path=path, field=field, site=site), self.assertRaisesRegex( + M.InventoryError, 'inventory-path-drift'): + M.InstalledInventory(image, P).read_provider('/usr/local/lib/libcurl.so.1.0.0') + for conn in image.connections: + conn.close.assert_called_once() + + def test_stat_diagnostics_preserve_all_field_and_unknown_field_rejections(self): + expected = directory('/') + secret = 'PRIVATE_TOKEN https://private.invalid/?signature=PRIVATE_SIGNATURE' + for key in (*expected, secret): + for actual in ({**expected, key: secret}, + {name: value for name, value in expected.items() if name != key}): + if actual == expected: + continue + progress = {} + reader = M.InstalledInventory(self.fake_image(), P, progress=progress) + with self.subTest(key=key, removed=key not in actual), self.assertRaisesRegex( + M.InventoryError, 'inventory-path-drift'): + reader._same_stat(actual, expected, site='directory-header', path='/') + self.assertEqual(progress['path_stat_drift'], { + 'site': 'directory-header', 'subject': 'root-directory', + 'changed_fields': [key] if key in expected else [], + 'field_set_changed': actual.keys() != expected.keys(), + 'unknown_fields_changed': key not in expected}) + self.assertNotIn(secret, json.dumps(progress)) + # Diagnostics are optional, rejection is not. + with self.assertRaisesRegex(M.InventoryError, 'inventory-path-drift'): + M.InstalledInventory(self.fake_image(), P)._same_stat( + actual, expected, site='directory-header', path='/') + progress = {} + reader = M.InstalledInventory(self.fake_image(), P, progress=progress) + reader._same_stat(expected, expected, site='directory-header', path='/') + self.assertEqual(progress, {}) + + def test_stat_diagnostic_sites_cover_producer_regular_and_rechecks(self): + for site, path in (('producer-header', M.ROOT), + ('regular-header', '/usr/local/lib/libcurl.so.1.0.0'), + ('recheck', '/'), ('recheck', '/usr/local'), + ('recheck', '/usr/local/lib/libcurl.so.1.0.0')): + image = self.installed_image() + progress = {} + reader = M.InstalledInventory(image, P, progress=progress) + if site == 'recheck': + expected = image.stat(path) + image.stat.side_effect = lambda _: {**expected, 'mtime': 'changed'} + operation = lambda: reader._recheck({path: expected}) + else: + request = image.docker.request.side_effect + def drift(method, route, headers): + conn, response = request(method, route, headers) + requested = urllib.parse.parse_qs(urllib.parse.urlsplit(route).query)['path'][0] + if requested == path: + response.headers['X-Docker-Container-Path-Stat'] = base64.b64encode( + json.dumps({**image.stat(path), 'mtime': 'changed'}).encode()).decode() + return conn, response + image.docker.request.side_effect = drift + operation = reader.producer if site == 'producer-header' else lambda: reader.read_provider(path) + with self.subTest(site=site, path=path), self.assertRaisesRegex( + M.InventoryError, 'inventory-path-drift'): + operation() + subject = 'root-directory' if path == '/' else 'file' if path.endswith('.so.1.0.0') else 'directory' + self.assertEqual(progress['path_stat_drift'], { + 'site': site, 'subject': subject, 'changed_fields': ['mtime'], + 'field_set_changed': False, 'unknown_fields_changed': False}) + for conn in image.connections: + conn.close.assert_called_once() + def test_root_directory_requires_exact_moby_archive_name(self): data = {path: b'tool' for path in M.BUILD_TOOLS.values()} for name in ('.', './', '//', '///', 'usr'):