From 47f14e946922cf98c30e4c8cb2649fa3cf27f5dc Mon Sep 17 00:00:00 2001 From: Radostin Stoyanov Date: Sat, 11 Jul 2026 12:14:15 +0100 Subject: [PATCH] crit: Convert memory page compression offline CRIT can edit image metadata but cannot convert the matching page payloads as one consistent image set. Changing either side alone can leave pagemap, inventory, and payload data incompatible. Add compress and decompress commands for checkpoint directories. Match CRIU's block encoding, raw threshold, alignment, image version, parent compatibility, and validation rules. Keep hugetlb and external-plugin ranges raw because CRIU cannot premap them generically, and add the Python LZ4 binding to supported dependencies. Stage every output before replacing any image. Preserve ownership, permissions, timestamps, xattrs, ACLs, and security metadata; create exclusive hard-link backups; synchronize directory changes; and defer terminating signals until the transaction has a definite result. Roll back the complete image set after a failure and retain the recoverable source if rollback or cleanup also fails. Reject symlinks, non-regular images, unsupported versions, inconsistent compression metadata, unknown modes, and truncated payloads. Recheck source identities before replacement so a changed pathname is not overwritten. Cover page and region images, parent chains, exceptional mappings, metadata preservation, signals, races, and rollback failures. Assisted-by: Codex:GPT-5 Assisted-by: Claude:claude-fable-5 Signed-off-by: Radostin Stoyanov --- Documentation/crit.txt | 38 + Makefile | 1 + contrib/dependencies/apk-packages.sh | 1 + contrib/dependencies/apt-packages.sh | 1 + contrib/dependencies/dnf-packages.sh | 1 + contrib/dependencies/pacman-packages.sh | 1 + crit/crit/__main__.py | 1231 ++++++++++++++++- crit/pyproject.toml | 3 + test/others/crit/.gitignore | 14 + test/others/crit/Makefile | 3 + test/others/crit/compression_image_helpers.py | 451 ++++++ test/others/crit/crit_compression_tests.py | 419 ++++++ test/others/crit/crit_transaction_tests.py | 480 +++++++ test/others/crit/test.sh | 564 +++++++- 14 files changed, 3201 insertions(+), 7 deletions(-) create mode 100644 test/others/crit/compression_image_helpers.py create mode 100644 test/others/crit/crit_compression_tests.py create mode 100644 test/others/crit/crit_transaction_tests.py diff --git a/Documentation/crit.txt b/Documentation/crit.txt index 32636c5b2..7fcf42e80 100644 --- a/Documentation/crit.txt +++ b/Documentation/crit.txt @@ -18,6 +18,10 @@ SYNOPSIS *crit* 'show' [-h] in +*crit* 'compress' [-h] [--in-place] [--acceleration N] dir + +*crit* 'decompress' [-h] [--in-place] dir + DESCRIPTION ----------- *crit* is a feature-rich replacement for existing *criu* show. @@ -43,12 +47,46 @@ Positional Arguments *show*:: convert *criu* image from binary to human-readable JSON +*compress*:: + compress memory page images in a checkpoint directory using CRIU's + per-page LZ4 format. The command rewrites *pages-*.img*, + *pagemap-*.img*, and *inventory.img*. Pages that are zero-filled or + do not meet CRIU's compression threshold are stored without an LZ4 + payload. By default, the original files are preserved with a *.bak* + suffix; use *--in-place* to skip these backup files. The inventory + is updated to record compression metadata and the compressed-image + version. Existing *.bak* files are never overwritten. + +*decompress*:: + decompress memory page images in a checkpoint directory back to + uncompressed page payloads. The command removes compression metadata + from the pagemap and inventory images. When the directory has no parent + reference, the command also restores the normal image version. It retains + the compressed-image version for incremental checkpoints because a parent + may still contain compressed payloads. By default, rewritten files are + preserved with a *.bak* suffix; use *--in-place* to skip these backup + files. Existing *.bak* files are never overwritten. + +Both transformations preserve the ownership, permissions, ACLs, security +labels, and other extended attributes of rewritten images. All output is +staged and synchronized before it is installed. If a write, rename, *SIGHUP*, +*SIGINT*, or *SIGTERM* interrupts the commit, CRIT restores the complete +original image set instead of leaving a partially transformed set. + Optional Arguments ~~~~~~~~~~~~~~~~~~ *-h*, *--help*:: Print some help and exit +*--in-place*:: + Rewrite files without creating *.bak* backup files. This option is + accepted by *compress* and *decompress*. + +*--acceleration* 'N':: + Set the LZ4 acceleration level used by *compress*. Higher values + trade compression ratio for speed. + SEE ALSO -------- criu(8) diff --git a/Makefile b/Makefile index 6680f2d66..5b48f4fe8 100644 --- a/Makefile +++ b/Makefile @@ -463,6 +463,7 @@ ruff: test/others/criu-ns/run.py \ crit/*.py \ crit/crit/*.py \ + test/others/crit/*.py \ scripts/uninstall_module.py \ coredump/ coredump/coredump \ scripts/github-indent-warnings.py \ diff --git a/contrib/dependencies/apk-packages.sh b/contrib/dependencies/apk-packages.sh index c1de79c18..fb303e0bd 100755 --- a/contrib/dependencies/apk-packages.sh +++ b/contrib/dependencies/apk-packages.sh @@ -33,6 +33,7 @@ apk add --no-cache \ protobuf-c-dev \ protobuf-dev \ py3-importlib-metadata \ + py3-lz4 \ py3-pip \ py3-protobuf \ py3-yaml \ diff --git a/contrib/dependencies/apt-packages.sh b/contrib/dependencies/apt-packages.sh index 2c9f2d5a3..442f886c5 100755 --- a/contrib/dependencies/apt-packages.sh +++ b/contrib/dependencies/apt-packages.sh @@ -37,6 +37,7 @@ fi protobuf-c-compiler \ protobuf-compiler \ python3-importlib-metadata \ + python3-lz4 \ python3-pip \ python3-protobuf \ python3-yaml \ diff --git a/contrib/dependencies/dnf-packages.sh b/contrib/dependencies/dnf-packages.sh index 93eff6cd5..cf655cee0 100755 --- a/contrib/dependencies/dnf-packages.sh +++ b/contrib/dependencies/dnf-packages.sh @@ -34,6 +34,7 @@ dnf install -y \ protobuf-devel \ python-devel \ python3-importlib-metadata \ + python3-lz4 \ python3-protobuf \ python3-pyyaml \ python3-setuptools \ diff --git a/contrib/dependencies/pacman-packages.sh b/contrib/dependencies/pacman-packages.sh index f1c3b25a4..b336b2cc4 100755 --- a/contrib/dependencies/pacman-packages.sh +++ b/contrib/dependencies/pacman-packages.sh @@ -26,6 +26,7 @@ pacman -Syu --noconfirm \ protobuf \ protobuf-c \ python-importlib-metadata \ + python-lz4 \ python-pip \ python-protobuf \ python-yaml \ diff --git a/crit/crit/__main__.py b/crit/crit/__main__.py index a94bb2f04..cb19bc7f9 100755 --- a/crit/crit/__main__.py +++ b/crit/crit/__main__.py @@ -1,8 +1,13 @@ #!/usr/bin/env python3 import argparse -import sys +import bisect +import errno import json import os +import signal +import stat +import sys +import tempfile from contextlib import contextmanager import pycriu @@ -367,7 +372,8 @@ def explore_rss(opts): vstr += ' ~' else: vstr += ' %08lx / %-8d' % ( - vma['start'], (vma['end'] - vma['start']) >> 12) + vma['start'], + (vma['end'] - vma['start']) >> 12) if vma['status'] & ((1 << 6) | (1 << 7)): vstr += ' ' + get_file_str(opts, { 'type': 'REG', @@ -394,6 +400,1196 @@ def explore(opts): explorers[opts['what']](opts) +PAGE_SIZE = os.sysconf('SC_PAGE_SIZE') +if PAGE_SIZE <= 0 or PAGE_SIZE & (PAGE_SIZE - 1): + raise RuntimeError("invalid system page size %d" % PAGE_SIZE) + +ZERO_PAGE = b'\0' * PAGE_SIZE +PAGE_COMPRESSION_THRESHOLD = PAGE_SIZE * 7 // 8 +MAX_REGION_SIZE = 4 * 1024 * 1024 +COPY_CHUNK_SIZE = 1024 * 1024 +UINT32_MAX = (1 << 32) - 1 +UINT64_MAX = (1 << 64) - 1 +PE_PARENT = 1 << 0 +PE_PRESENT = 1 << 2 +PE_PAYLOAD_ALIGNED = 1 << 3 +MAP_SHARED = 1 << 0 +MAP_HUGETLB = 0x40000 +VMA_FILE_SHARED = 1 << 7 +VMA_ANON_SHARED = 1 << 8 +VMA_AREA_SYSVIPC = 1 << 10 +VMA_EXT_PLUGIN = 1 << 27 +CRTOOLS_IMAGES_V1_1 = 2 +CRTOOLS_IMAGES_V1_2 = 3 +CR_PARENT_LINK = 'parent' +TERMINATION_SIGNALS = (signal.SIGHUP, signal.SIGINT, signal.SIGTERM) + + +class CritTransformError(Exception): + pass + + +def _pagemap_flags(entry): + has_flags = 'flags' in entry + flags = entry.get('flags', 0) + + if entry.get('in_parent'): + flags |= PE_PARENT + elif not has_flags: + flags = PE_PRESENT + + return flags + + +def _load_image(path, image_metadata): + source_metadata = _original_metadata(image_metadata, path) + with _source_file(path, source_metadata) as image_file: + return pycriu.images.load(image_file) + + +def _numeric_image_id(name, prefix): + if not name.startswith(prefix) or not name.endswith('.img'): + return None + + image_id = name[len(prefix):-len('.img')] + if not image_id.isdigit(): + return None + return int(image_id) + + +def _is_pagemap_image(name): + shmem_id = _numeric_image_id(name, 'pagemap-shmem-') + task_id = _numeric_image_id(name, 'pagemap-') + return shmem_id is not None or task_id is not None + + +def _find_pagemaps(directory, image_metadata): + """Find all pagemap files and their associated pages files.""" + pagemaps = [] + seen_pages_ids = set() + + for name in sorted(os.listdir(directory)): + if not _is_pagemap_image(name): + continue + + path = os.path.join(directory, name) + pagemap = _load_image(path, image_metadata) + + if not pagemap['entries']: + continue + + pages_id = pagemap['entries'][0].get('pages_id') + if pages_id is None: + raise CritTransformError("%s has no pages_id" % name) + if pages_id in seen_pages_ids: + raise CritTransformError("%s reuses pages_id %d" % + (name, pages_id)) + seen_pages_ids.add(pages_id) + + pages_name = 'pages-%d.img' % pages_id + pages_path = os.path.join(directory, pages_name) + if not os.path.exists(pages_path): + raise CritTransformError("%s refers to missing %s" % + (name, pages_name)) + + pagemaps.append((name, pages_name, pagemap)) + + return pagemaps + + +def _merge_ranges(ranges): + merged = [] + for start, end in sorted(ranges): + if merged and start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + return merged + + +def _exceptional_vma_target(mm_name, task_pagemap, vma): + flags = vma.get('flags', 0) + status = vma.get('status', 0) + if not (flags & MAP_HUGETLB or status & VMA_EXT_PLUGIN): + return None + + start = vma.get('start') + end = vma.get('end') + if (not isinstance(start, int) or not isinstance(end, int) or + start < 0 or end <= start or start % PAGE_SIZE or + end % PAGE_SIZE): + raise CritTransformError( + "%s has an invalid exceptional VMA range %r-%r" % + (mm_name, start, end)) + + shared_status = VMA_FILE_SHARED | VMA_ANON_SHARED | VMA_AREA_SYSVIPC + is_shared = flags & MAP_SHARED or status & shared_status + if not is_shared: + return task_pagemap, start, end + + shmid = vma.get('shmid') + pgoff = vma.get('pgoff') + vma_size = end - start + if (not isinstance(shmid, int) or shmid < 0 or + not isinstance(pgoff, int) or pgoff < 0 or + pgoff % PAGE_SIZE or pgoff > UINT64_MAX - vma_size): + raise CritTransformError( + "%s has invalid shared exceptional VMA metadata" % mm_name) + + pagemap_name = 'pagemap-shmem-%d.img' % shmid + return pagemap_name, pgoff, pgoff + vma_size + + +def _exceptional_pagemap_ranges(directory, pagemaps, image_metadata): + """Return ranges which CRIU restore requires to remain raw. + + Task pagemaps use process virtual addresses. Shared-memory pagemaps use + offsets into the shared object, so translate their VMAs through pgoff and + shmid instead of assuming that the pagemap and mm image IDs are alike. + """ + ranges = {pagemap_name: [] for pagemap_name, _, _ in pagemaps} + pagemap_names = set(ranges) + + for name in sorted(os.listdir(directory)): + task_id = _numeric_image_id(name, 'mm-') + if task_id is None: + continue + + mm_path = os.path.join(directory, name) + mm_image = _load_image(mm_path, image_metadata) + if not mm_image.get('entries'): + raise CritTransformError("%s has no entries" % name) + + task_pagemap = 'pagemap-%d.img' % task_id + for vma in mm_image['entries'][0].get('vmas', []): + target = _exceptional_vma_target(name, task_pagemap, vma) + if target is None: + continue + + pagemap_name, range_start, range_end = target + + # File-backed mappings without pages and image subsets without the + # corresponding pagemap do not have payload for CRIT to transform. + if pagemap_name in pagemap_names: + ranges[pagemap_name].append((range_start, range_end)) + + return {name: _merge_ranges(pagemap_ranges) + for name, pagemap_ranges in ranges.items()} + + +def _range_starts(ranges): + return [start for start, _ in ranges] + + +def _address_in_ranges(address, ranges, starts): + index = bisect.bisect_right(starts, address) - 1 + if index >= 0 and address < ranges[index][1]: + return True + + # Native 32-bit x86 encode_pointer() sign-extends addresses through long, + # while mm image VMA bounds remain their unsigned 32-bit values. + if address >> 32 == UINT32_MAX: + address &= UINT32_MAX + index = bisect.bisect_right(starts, address) - 1 + return index >= 0 and address < ranges[index][1] + return False + + +def _get_nr_pages(entry): + return entry.get('nr_pages', entry.get('compat_nr_pages', 0)) + + +def _entry_error(pagemap_name, index, message): + raise CritTransformError("%s entry %d: %s" % + (pagemap_name, index, message)) + + +def _aligned_payload_offset(offset): + return (offset + PAGE_SIZE - 1) & -PAGE_SIZE + + +def _has_compression_metadata(entry): + return (bool(entry.get('compressed_size')) or + 'total_compressed_size' in entry or + 'region_pages' in entry) + + +def _compressed_payload_size(pagemap_name, index, entry, nr_pages, + compressed_sizes): + region_pages = entry.get('region_pages', 0) + if not isinstance(region_pages, int) or region_pages < 0: + _entry_error(pagemap_name, index, + "invalid region_pages %r" % region_pages) + if region_pages * PAGE_SIZE > MAX_REGION_SIZE: + _entry_error(pagemap_name, index, + "region size %d exceeds maximum %d bytes" % + (region_pages * PAGE_SIZE, MAX_REGION_SIZE)) + + block_pages = region_pages or 1 + expected_blocks = (nr_pages + block_pages - 1) // block_pages + if len(compressed_sizes) != expected_blocks: + _entry_error(pagemap_name, index, + "%d compressed blocks, expected %d" % + (len(compressed_sizes), expected_blocks)) + + payload_size = 0 + pages_done = 0 + for block_index, compressed_size in enumerate(compressed_sizes): + pages = min(block_pages, nr_pages - pages_done) + block_bytes = pages * PAGE_SIZE + if not isinstance(compressed_size, int) or compressed_size < 0: + _entry_error(pagemap_name, index, + "block %d has invalid size %r" % + (block_index, compressed_size)) + if compressed_size > block_bytes: + _entry_error( + pagemap_name, index, + "block %d size %d exceeds its uncompressed size %d" % + (block_index, compressed_size, block_bytes)) + payload_size += compressed_size + if payload_size > UINT64_MAX: + _entry_error(pagemap_name, index, + "compressed size sum overflows") + pages_done += pages + + if pages_done != nr_pages: + _entry_error(pagemap_name, index, + "%d pages covered, expected %d" % + (pages_done, nr_pages)) + + recorded_size = entry.get('total_compressed_size') + if recorded_size is not None and recorded_size != payload_size: + _entry_error(pagemap_name, index, + "total_compressed_size %r does not match " + "block sum %d" % (recorded_size, payload_size)) + return payload_size + + +def _validate_pagemap(pagemap_name, pages_path, pagemap, + uncompressed_only=False): + payload_size = 0 + + for index, entry in enumerate(pagemap['entries'][1:], 1): + nr_pages = _get_nr_pages(entry) + flags = _pagemap_flags(entry) + compressed_sizes = entry.get('compressed_size', []) + has_compression_metadata = _has_compression_metadata(entry) + + if not isinstance(nr_pages, int) or nr_pages <= 0: + _entry_error(pagemap_name, index, + "invalid page count %r" % nr_pages) + if nr_pages > UINT64_MAX // PAGE_SIZE: + _entry_error(pagemap_name, index, "page count overflows") + + vaddr = entry.get('vaddr') + if not isinstance(vaddr, int) or vaddr < 0 or vaddr % PAGE_SIZE: + _entry_error(pagemap_name, index, + "address %r is not page-aligned" % vaddr) + if vaddr > UINT64_MAX - nr_pages * PAGE_SIZE: + _entry_error(pagemap_name, index, "address range overflows") + + if flags & PE_PRESENT and flags & PE_PARENT: + _entry_error(pagemap_name, index, + "PE_PRESENT and PE_PARENT are mutually exclusive") + + if flags & PE_PAYLOAD_ALIGNED and not flags & PE_PRESENT: + _entry_error(pagemap_name, index, + "PE_PAYLOAD_ALIGNED is set on a non-present entry") + + if not flags & PE_PRESENT: + if has_compression_metadata: + _entry_error(pagemap_name, index, + "compression metadata is set on a " + "non-present entry") + continue + + if uncompressed_only: + if has_compression_metadata: + _entry_error(pagemap_name, index, + "compression metadata is present in an " + "uncompressed checkpoint") + if flags & PE_PAYLOAD_ALIGNED: + _entry_error(pagemap_name, index, + "aligned compressed payload flag is present in " + "an uncompressed checkpoint") + + if not compressed_sizes: + if has_compression_metadata: + _entry_error(pagemap_name, index, + "incomplete compression metadata") + entry_payload = nr_pages * PAGE_SIZE + else: + entry_payload = _compressed_payload_size(pagemap_name, index, entry, nr_pages, + compressed_sizes) + + if flags & PE_PAYLOAD_ALIGNED: + payload_size = _aligned_payload_offset(payload_size) + payload_size += entry_payload + if payload_size > UINT64_MAX: + raise CritTransformError( + "%s payload size overflows" % pagemap_name) + + actual_size = os.stat(pages_path).st_size + if payload_size != actual_size: + raise CritTransformError( + "%s describes %d payload bytes, but %s contains %d" % + (pagemap_name, payload_size, os.path.basename(pages_path), + actual_size)) + + +def _validate_pagemaps(directory, pagemaps, uncompressed_only=False): + for pagemap_name, pages_name, pagemap in pagemaps: + pages_path = os.path.join(directory, pages_name) + _validate_pagemap(pagemap_name, pages_path, pagemap, + uncompressed_only) + + +def _has_parent_reference(directory, pagemaps): + parent_link = os.path.join(directory, CR_PARENT_LINK) + if os.path.lexists(parent_link): + return True + + for _, _, pagemap in pagemaps: + if any(_pagemap_flags(entry) & PE_PARENT + for entry in pagemap['entries'][1:]): + return True + return False + + +def _capture_file_metadata(path): + metadata = os.stat(path, follow_symlinks=False) + if not stat.S_ISREG(metadata.st_mode): + raise CritTransformError("image is not a regular file: %s" % path) + if not hasattr(os, 'listxattr'): + return metadata, [] + try: + names = os.listxattr(path, follow_symlinks=False) + except OSError as exc: + if exc.errno in (errno.ENOTSUP, errno.ENOSYS): + return metadata, [] + raise + xattrs = [(name, os.getxattr(path, name, follow_symlinks=False)) + for name in names] + return metadata, xattrs + + +def _is_transform_image(name): + if not name.endswith('.img'): + return False + return (name == 'inventory.img' or + name.startswith(('mm-', 'pagemap-', 'pages-'))) + + +def _capture_image_metadata(directory, captured=None): + """Snapshot metadata before image contents can update source atimes.""" + captured = dict(captured or {}) + for name in os.listdir(directory): + if not _is_transform_image(name): + continue + path = os.path.join(directory, name) + if path not in captured and os.path.isfile(path): + captured[path] = _capture_file_metadata(path) + return captured + + +def _original_metadata(captured, path): + try: + return captured[path] + except KeyError as exc: + raise CritTransformError( + "image metadata was not captured before reading %s" % path + ) from exc + + +@contextmanager +def _source_file(path, source_metadata): + """Open an image without changing atime, restoring it on fallback.""" + flags = (os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | + getattr(os, 'O_NOFOLLOW', 0)) + restore_times = False + noatime = getattr(os, 'O_NOATIME', None) + if noatime is None: + fd = os.open(path, flags) + restore_times = True + else: + try: + fd = os.open(path, flags | noatime) + except OSError as exc: + if exc.errno not in (errno.EPERM, errno.EINVAL, + errno.EOPNOTSUPP): + raise + fd = os.open(path, flags) + restore_times = True + + try: + source_stat, _ = source_metadata + opened_stat = os.fstat(fd) + if (opened_stat.st_dev, opened_stat.st_ino) != ( + source_stat.st_dev, source_stat.st_ino): + raise CritTransformError( + "image changed while preparing to read: %s" % path) + with os.fdopen(fd, 'rb') as source: + fd = -1 + try: + yield source + finally: + source_failed = sys.exc_info()[0] is not None + if restore_times: + try: + os.utime(source.fileno(), + ns=(source_stat.st_atime_ns, + source_stat.st_mtime_ns)) + except OSError: + # Preserve a read/decode failure rather than replacing + # it with a secondary metadata-restoration error. + if not source_failed: + raise + finally: + if fd >= 0: + os.close(fd) + + +@contextmanager +def _staged_file(path, source_metadata=None): + directory = os.path.dirname(path) + prefix = '.%s.crit-' % os.path.basename(path) + fd, tmp_path = tempfile.mkstemp(prefix=prefix, dir=directory) + output = None + + try: + if source_metadata is None: + source_metadata = _capture_file_metadata(path) + source_stat, xattrs = source_metadata + # Keep mkstemp's restrictive mode while the replacement is partial. + # Install the original metadata after writing: content writes and + # chown can clear capabilities, set-id bits, or ACL information. + original_owner = (source_stat.st_uid, source_stat.st_gid) + original_mode = stat.S_IMODE(source_stat.st_mode) + original_times = (source_stat.st_atime_ns, + source_stat.st_mtime_ns) + output = os.fdopen(fd, 'wb') + fd = -1 + yield output, tmp_path + output.flush() + os.fchown(output.fileno(), *original_owner) + os.fchmod(output.fileno(), original_mode) + # Replacing the inode would otherwise silently discard POSIX ACLs, + # security labels, and other extended metadata carried as xattrs. + for name, value in xattrs: + os.setxattr(output.fileno(), name, value) + # Content writes and metadata installation update the replacement's + # timestamps. Restore the source values only after those operations. + os.utime(output.fileno(), ns=original_times) + os.fsync(output.fileno()) + try: + output.close() + finally: + output = None + except BaseException: + if output is not None: + try: + output.close() + except OSError: + pass # Preserve the transformation error being handled. + output = None + elif fd >= 0: + os.close(fd) + fd = -1 + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass # The failed operation may already have removed it. + except OSError as cleanup_exc: + # Do not replace the transformation error with a secondary + # failure to remove its incomplete temporary file. + print("Warning: unable to remove temporary file %s: %s" % + (tmp_path, cleanup_exc), file=sys.stderr) + raise + finally: + if output is not None: + output.close() + elif fd >= 0: + os.close(fd) + + +def _stage_image(path, image, source_metadata=None): + with _staged_file(path, source_metadata) as (output, tmp_path): + pycriu.images.dump(image, output) + return tmp_path + + +@contextmanager +def _stage_file_update(staged, path, image_metadata): + source_metadata = _original_metadata(image_metadata, path) + with _staged_file(path, source_metadata) as staged_file: + output, tmp_path = staged_file + with _source_file(path, source_metadata) as source: + yield source, output + staged.append((path, tmp_path)) + + +def _stage_image_update(staged, path, image, image_metadata): + source_metadata = _original_metadata(image_metadata, path) + tmp_path = _stage_image(path, image, source_metadata) + staged.append((path, tmp_path)) + + +def _remove_file(path): + try: + os.unlink(path) + except FileNotFoundError: + pass # Cleanup is idempotent. + + +def _cleanup_staged_files(staged): + errors = [] + for _, tmp_path in staged: + try: + _remove_file(tmp_path) + except OSError as exc: + errors.append((tmp_path, exc)) + return errors + + +def _report_cleanup_errors(errors): + for path, exc in errors: + print("Warning: unable to remove temporary file %s: %s" % + (path, exc), file=sys.stderr) + + +def _make_rollback_link(path): + directory = os.path.dirname(path) + prefix = '.%s.crit-rollback-' % os.path.basename(path) + fd, rollback_path = tempfile.mkstemp(prefix=prefix, dir=directory) + os.close(fd) + os.unlink(rollback_path) + try: + os.link(path, rollback_path) + except BaseException: + try: + _remove_file(rollback_path) + except OSError as cleanup_exc: + print("Warning: unable to remove transaction file %s: %s" % + (rollback_path, cleanup_exc), file=sys.stderr) + raise + return rollback_path + + +def _fsync_directories(paths): + directories = {os.path.dirname(path) or '.' for path in paths} + flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) + + for directory in directories: + fd = os.open(directory, flags) + try: + os.fsync(fd) + finally: + os.close(fd) + + +@contextmanager +def _commit_signal_handlers(): + previous = {} + state = { + 'committed': False, + 'received': set(), + } + + def interrupted(signum, _frame): + # A durable replacement must still be reported as successful when a + # signal arrives while printing statistics or returning from the CLI. + if state['committed']: + return + # The first signal starts synchronous exception cleanup. Repeated + # signals must not interrupt that cleanup and strand staged files. + if state['received']: + return + state['received'].add(signum) + raise CritTransformError( + "commit interrupted by %s" % signal.Signals(signum).name) + + # Turn termination signals into exceptions while images are staged. + # _commit_staged() can then put every original image back before the + # command terminates. + for signum in TERMINATION_SIGNALS: + old_handler = signal.getsignal(signum) + if old_handler == signal.SIG_IGN: + continue + previous[signum] = old_handler + signal.signal(signum, interrupted) + try: + yield state + finally: + for signum, old_handler in previous.items(): + signal.signal(signum, old_handler) + + +@contextmanager +def _deferred_commit_signals(): + previous = {} + received = [] + + def interrupted(signum, _frame): + # Do not raise between a rename and the bookkeeping which makes that + # rename recoverable. The transaction checks this list after every + # image has reached a consistent on-disk state. + if signum not in received: + received.append(signum) + + for signum in TERMINATION_SIGNALS: + old_handler = signal.getsignal(signum) + if old_handler == signal.SIG_IGN: + continue + previous[signum] = old_handler + signal.signal(signum, interrupted) + try: + yield received + finally: + for signum, old_handler in previous.items(): + signal.signal(signum, old_handler) + + +def _verify_image_identity(path, current_path, image_metadata): + if image_metadata is None: + return + + source_stat, _ = _original_metadata(image_metadata, path) + current_stat = os.stat(current_path, follow_symlinks=False) + if (current_stat.st_dev, current_stat.st_ino) != ( + source_stat.st_dev, source_stat.st_ino): + raise CritTransformError( + "image changed while preparing to replace: %s" % path) + + +def _commit_staged(staged, in_place, signal_state=None, + image_metadata=None): + rollback_links = {} + replacement_started = set() + backed_up_paths = set() + image_paths = [path for path, _ in staged] + + with _deferred_commit_signals() as interrupted: + try: + if in_place: + # Keep every original before replacing any image so a later + # rename failure can be rolled back across the complete set. + for path, _ in staged: + rollback_links[path] = _make_rollback_link(path) + _verify_image_identity( + path, rollback_links[path], image_metadata) + else: + # link() fails atomically with EEXIST, closing the race between + # a backup preflight and rename-overwrite. These links are both + # the requested backups and rollback sources until commit. + for path, _ in staged: + backup_path = path + '.bak' + try: + os.link(path, backup_path) + except FileExistsError as exc: + raise CritTransformError( + "refusing to overwrite existing backup: %s" % + backup_path) from exc + backed_up_paths.add(path) + _verify_image_identity(path, backup_path, image_metadata) + + # All rollback sources now refer to the captured originals. + # Verify that every live name still does before changing any + # pathname, then narrow the remaining race with a per-file check. + for path, _ in staged: + _verify_image_identity(path, path, image_metadata) + _fsync_directories(image_paths) + + for path, tmp_path in staged: + _verify_image_identity(path, path, image_metadata) + # Mark the path recoverable before entering the syscall. A + # Python signal or injected exception can run immediately + # after rename(2) changed the directory but before the call + # appears to return to this frame. + replacement_started.add(path) + os.replace(tmp_path, path) + _fsync_directories(image_paths) + + if interrupted: + signal_names = ', '.join(signal.Signals(signum).name + for signum in interrupted) + raise CritTransformError( + "commit interrupted by %s" % signal_names) + if signal_state is not None: + signal_state['committed'] = True + except BaseException as exc: + rollback_errors = [] + recovery_sources = set() + for path, _ in reversed(staged): + if path not in replacement_started: + continue + recovery_source = (path + '.bak' if not in_place else + rollback_links.get(path)) + if recovery_source is None: + continue + try: + os.replace(recovery_source, path) + except OSError as rollback_exc: + rollback_errors.append( + "%s (original retained at %s): %s" % + (path, recovery_source, rollback_exc)) + recovery_sources.add(recovery_source) + + # Backups for paths which were never replaced are no longer part + # of a failed operation. A backup that could not be renamed back is + # deliberately preserved as the last recoverable original copy. + if not in_place: + for path in backed_up_paths: + backup_path = path + '.bak' + if backup_path in recovery_sources: + continue + try: + os.unlink(backup_path) + except FileNotFoundError: + pass # Another cleanup path already removed it. + except OSError as rollback_exc: + rollback_errors.append("%s: %s" % + (backup_path, rollback_exc)) + + try: + _fsync_directories(image_paths) + except OSError as rollback_exc: + rollback_errors.append("directory sync: %s" % rollback_exc) + + for path, tmp_path in staged: + try: + _remove_file(tmp_path) + except OSError as cleanup_exc: + rollback_errors.append("%s: %s" % + (tmp_path, cleanup_exc)) + rollback_path = rollback_links.get(path) + if (rollback_path is not None and + rollback_path not in recovery_sources): + try: + _remove_file(rollback_path) + except OSError as cleanup_exc: + rollback_errors.append("%s: %s" % + (rollback_path, cleanup_exc)) + + if rollback_errors: + raise CritTransformError( + "commit failed (%s); rollback also failed for %s" % + (exc, ', '.join(rollback_errors))) + if not isinstance(exc, Exception): + raise + raise CritTransformError( + "commit failed; original images were restored: %s" % exc) + + if in_place: + # Keep signals deferred until rollback links have been removed and + # that removal is durable. Once the replacement set has committed, + # a signal arriving during this best-effort cleanup must not make + # the command report failure for an image set it already installed. + cleanup_failed = False + for rollback_path in rollback_links.values(): + try: + os.unlink(rollback_path) + except OSError as exc: + cleanup_failed = True + print("Warning: unable to remove transaction file %s: %s" % + (rollback_path, exc), file=sys.stderr) + if not cleanup_failed: + try: + _fsync_directories(image_paths) + except OSError as exc: + print("Warning: unable to sync image directory: %s" % exc, + file=sys.stderr) + + +def _copy_exact(source, output, size, pages_name): + remaining = size + while remaining: + data = source.read(min(remaining, COPY_CHUNK_SIZE)) + if not data: + raise CritTransformError("short read in %s" % pages_name) + output.write(data) + remaining -= len(data) + + +def _read_exact(source, size, pages_name): + chunks = [] + remaining = size + while remaining: + chunk = source.read(remaining) + if not chunk: + raise CritTransformError("short read in %s" % pages_name) + chunks.append(chunk) + remaining -= len(chunk) + return b''.join(chunks) + + +def _write_zeros(output, size): + remaining = size + while remaining: + chunk = min(remaining, len(ZERO_PAGE)) + output.write(ZERO_PAGE[:chunk]) + remaining -= chunk + + +def _align_output_payload(output): + padding = _aligned_payload_offset(output.tell()) - output.tell() + _write_zeros(output, padding) + return padding + + +def _skip_input_padding(source, pages_name): + padding = _aligned_payload_offset(source.tell()) - source.tell() + if padding: + _read_exact(source, padding, pages_name) + return padding + + +def _command_failed(action, exc, staged): + cleanup_errors = _cleanup_staged_files(staged) + print("Error: failed to %s checkpoint: %s" % (action, exc), + file=sys.stderr) + _report_cleanup_errors(cleanup_errors) + return 1 + + +def _load_lz4_block(): + try: + from lz4 import block as lz4_block + except ImportError: + print("Error: lz4 Python package is required.\n" + "Install with: pip install lz4", file=sys.stderr) + return None + return lz4_block + + +def _load_inventory(directory): + inventory_path = os.path.join(directory, 'inventory.img') + image_metadata = { + inventory_path: _capture_file_metadata(inventory_path), + } + inventory = _load_image(inventory_path, image_metadata) + return inventory_path, inventory, image_metadata + + +def _inventory_compression_mode(inventory): + if not inventory.get('entries'): + raise CritTransformError("inventory has no entries") + + inventory_entry = inventory['entries'][0] + image_version = inventory_entry.get('img_version') + if image_version not in (CRTOOLS_IMAGES_V1_1, CRTOOLS_IMAGES_V1_2): + raise CritTransformError( + "inventory has unsupported image version %r" % image_version) + + compression_mode = inventory_entry.get('compress', 0) + if compression_mode not in (0, 1, 2): + raise CritTransformError( + "inventory has invalid compression mode %r" % compression_mode) + if compression_mode and image_version != CRTOOLS_IMAGES_V1_2: + raise CritTransformError( + "inventory image version %r cannot contain compressed pages" % + image_version) + return compression_mode + + +def _encode_page(page, force_raw, lz4_block, acceleration): + if force_raw: + return page, PAGE_SIZE + + compressed = lz4_block.compress( + page, mode='fast', store_size=False, acceleration=acceleration) + if len(compressed) >= PAGE_COMPRESSION_THRESHOLD: + return page, PAGE_SIZE + return compressed, len(compressed) + + +def _remove_compression_metadata(entry): + entry.pop('compressed_size', None) + entry.pop('total_compressed_size', None) + entry.pop('region_pages', None) + + +def _compress_pages_image(pages_in, pages_out, pages_name, pagemap, + raw_ranges, acceleration, lz4_block): + total_pages = 0 + input_size = 0 + output_size = 0 + raw_range_starts = _range_starts(raw_ranges) + + for entry in pagemap['entries'][1:]: + nr_pages = _get_nr_pages(entry) + flags = _pagemap_flags(entry) + + # Only PE_PRESENT entries have payload in pages-*.img. + if not flags & PE_PRESENT: + continue + + compressed_sizes = [] + total_compressed_size = 0 + payload_started = False + + for page_index in range(nr_pages): + page = _read_exact(pages_in, PAGE_SIZE, pages_name) + input_size += PAGE_SIZE + total_pages += 1 + + if page == ZERO_PAGE: + compressed_sizes.append(0) + continue + + page_vaddr = entry['vaddr'] + page_index * PAGE_SIZE + force_raw = _address_in_ranges(page_vaddr, raw_ranges, raw_range_starts) + payload, stored_size = _encode_page(page, force_raw, lz4_block, acceleration) + + if stored_size == PAGE_SIZE and not payload_started: + output_size += _align_output_payload(pages_out) + flags |= PE_PAYLOAD_ALIGNED + entry['flags'] = flags + + payload_started = True + pages_out.write(payload) + compressed_sizes.append(stored_size) + total_compressed_size += stored_size + output_size += stored_size + + if all(size == PAGE_SIZE for size in compressed_sizes): + # The staged payload is already an ordinary contiguous pages + # image, so avoid restore-side metadata work. + _remove_compression_metadata(entry) + else: + entry['compressed_size'] = compressed_sizes + entry['total_compressed_size'] = total_compressed_size + + return total_pages, input_size, output_size + + +def _decode_block(pages_in, pages_out, compressed_size, block_bytes, + pages_name, lz4_block): + if compressed_size == 0: + _write_zeros(pages_out, block_bytes) + return + if compressed_size == block_bytes: + _copy_exact(pages_in, pages_out, compressed_size, pages_name) + return + + data = _read_exact(pages_in, compressed_size, pages_name) + try: + block = lz4_block.decompress(data, uncompressed_size=block_bytes) + except Exception as exc: + raise CritTransformError( + "decompression failed in %s: %s" % (pages_name, exc)) + if len(block) != block_bytes: + raise CritTransformError( + "decompression in %s produced %d bytes, expected %d" % + (pages_name, len(block), block_bytes)) + pages_out.write(block) + + +def _decompress_pages_image(pages_in, pages_out, pages_name, pagemap, + lz4_block): + total_pages = 0 + input_size = 0 + output_size = 0 + + for entry in pagemap['entries'][1:]: + nr_pages = _get_nr_pages(entry) + flags = _pagemap_flags(entry) + + # Only PE_PRESENT entries have payload in pages-*.img. + if not flags & PE_PRESENT: + continue + + if flags & PE_PAYLOAD_ALIGNED: + input_size += _skip_input_padding(pages_in, pages_name) + entry['flags'] = flags & ~PE_PAYLOAD_ALIGNED + + compressed_sizes = entry.get('compressed_size') + if not compressed_sizes: + uncompressed_size = nr_pages * PAGE_SIZE + _copy_exact(pages_in, pages_out, uncompressed_size, pages_name) + input_size += uncompressed_size + output_size += uncompressed_size + total_pages += nr_pages + continue + + # region_pages > 0 means each compressed_size entry covers up to + # region_pages pages as one LZ4 block. + region_pages = entry.get('region_pages', 0) + remaining_pages = nr_pages + + for compressed_size in compressed_sizes: + block_pages = min(region_pages or 1, remaining_pages) + block_bytes = block_pages * PAGE_SIZE + _decode_block(pages_in, pages_out, compressed_size, block_bytes, + pages_name, lz4_block) + input_size += compressed_size + output_size += block_bytes + total_pages += block_pages + remaining_pages -= block_pages + + if remaining_pages: + raise CritTransformError( + "block page count mismatch in %s (%d pages unaccounted)" % + (pages_name, remaining_pages)) + + _remove_compression_metadata(entry) + + return total_pages, input_size, output_size + + +def _stage_compressed_pagemap(staged, directory, pagemap_name, pages_name, + pagemap, image_metadata, raw_ranges, + acceleration, lz4_block): + pagemap_path = os.path.join(directory, pagemap_name) + pages_path = os.path.join(directory, pages_name) + + with _stage_file_update(staged, pages_path, image_metadata) as files: + pages_in, pages_out = files + stats = _compress_pages_image( + pages_in, pages_out, pages_name, pagemap, raw_ranges, + acceleration, lz4_block) + + _stage_image_update(staged, pagemap_path, pagemap, image_metadata) + return stats + + +def _stage_decompressed_pagemap(staged, directory, pagemap_name, pages_name, + pagemap, image_metadata, lz4_block): + pagemap_path = os.path.join(directory, pagemap_name) + pages_path = os.path.join(directory, pages_name) + + with _stage_file_update(staged, pages_path, image_metadata) as files: + pages_in, pages_out = files + stats = _decompress_pages_image( + pages_in, pages_out, pages_name, pagemap, lz4_block) + + _stage_image_update(staged, pagemap_path, pagemap, image_metadata) + return stats + + +def compress_cmd(opts): + lz4_block = _load_lz4_block() + if lz4_block is None: + return 1 + + directory = opts['dir'] + in_place = opts.get('in_place', False) + acceleration = opts.get('acceleration', 1) + staged = [] + stats = [] + + try: + if acceleration < 1 or acceleration > 65537: + raise CritTransformError( + "LZ4 acceleration must be between 1 and 65537") + + inventory_path, inventory, image_metadata = _load_inventory(directory) + compression_mode = _inventory_compression_mode(inventory) + if compression_mode: + print("Checkpoint in %s is already compressed" % directory) + return 0 + + image_metadata = _capture_image_metadata(directory, image_metadata) + pagemaps = _find_pagemaps(directory, image_metadata) + if not pagemaps: + print("No pagemap files found in %s" % directory) + return 0 + + _validate_pagemaps(directory, pagemaps, uncompressed_only=True) + exceptional_ranges = _exceptional_pagemap_ranges(directory, pagemaps, image_metadata) + + print("Compressing checkpoint in %s" % directory) + + for pagemap_name, pages_name, pagemap in pagemaps: + pagemap_stats = _stage_compressed_pagemap( + staged, directory, pagemap_name, pages_name, pagemap, + image_metadata, exceptional_ranges[pagemap_name], + acceleration, lz4_block) + stats.append((pagemap_name,) + pagemap_stats) + + inventory['entries'][0]['compress'] = 1 # COMPRESS_PER_PAGE + inventory['entries'][0]['img_version'] = CRTOOLS_IMAGES_V1_2 + _stage_image_update(staged, inventory_path, inventory, image_metadata) + + commit_signal_state = opts.get('_commit_signal_state') + _commit_staged(staged, in_place, commit_signal_state, + image_metadata) + except Exception as exc: + return _command_failed("compress", exc, staged) + except BaseException: + _report_cleanup_errors(_cleanup_staged_files(staged)) + raise + + for pagemap_name, total_pages, input_size, output_size in stats: + if input_size: + saved = (1 - output_size / input_size) * 100 + print(" %s: %d pages (%dK -> %dK, %.1f%% saved)" % + (pagemap_name, total_pages, input_size // 1024, + output_size // 1024, saved)) + + print("Done") + return 0 + + +def decompress_cmd(opts): + lz4_block = _load_lz4_block() + if lz4_block is None: + return 1 + + directory = opts['dir'] + in_place = opts.get('in_place', False) + staged = [] + stats = [] + + try: + inventory_path, inventory, image_metadata = _load_inventory(directory) + compression_mode = _inventory_compression_mode(inventory) + if not compression_mode: + print("Checkpoint in %s is already decompressed" % directory) + return 0 + + image_metadata = _capture_image_metadata(directory, image_metadata) + pagemaps = _find_pagemaps(directory, image_metadata) + if not pagemaps: + print("No pagemap files found in %s" % directory) + return 0 + + _validate_pagemaps(directory, pagemaps) + has_parent_reference = _has_parent_reference(directory, pagemaps) + + print("Decompressing checkpoint in %s" % directory) + + for pagemap_name, pages_name, pagemap in pagemaps: + pagemap_stats = _stage_decompressed_pagemap( + staged, directory, pagemap_name, pages_name, pagemap, + image_metadata, lz4_block) + stats.append((pagemap_name,) + pagemap_stats) + + inventory['entries'][0].pop('compress', None) + inventory['entries'][0].pop('compress_region_size', None) + if (not has_parent_reference and + inventory['entries'][0].get('img_version') == + CRTOOLS_IMAGES_V1_2): + inventory['entries'][0]['img_version'] = CRTOOLS_IMAGES_V1_1 + _stage_image_update(staged, inventory_path, inventory, image_metadata) + + commit_signal_state = opts.get('_commit_signal_state') + _commit_staged(staged, in_place, commit_signal_state, + image_metadata) + except Exception as exc: + return _command_failed("decompress", exc, staged) + except BaseException: + _report_cleanup_errors(_cleanup_staged_files(staged)) + raise + + for pagemap_name, total_pages, input_size, output_size in stats: + print(" %s: %d pages (%dK -> %dK)" % + (pagemap_name, total_pages, input_size // 1024, + output_size // 1024)) + + print("Done") + return 0 + + def main(): desc = 'CRiu Image Tool' parser = argparse.ArgumentParser( @@ -453,6 +1649,24 @@ def main(): action='store_true') show_parser.set_defaults(func=decode, pretty=True, out=None) + # Compress + compress_parser = subparsers.add_parser( + 'compress', help='Compress memory pages in a checkpoint directory') + compress_parser.add_argument('dir') + compress_parser.add_argument('--in-place', action='store_true', + help='Skip creating backup files') + compress_parser.add_argument('--acceleration', type=int, default=1, + help='LZ4 acceleration (1=default, higher=faster)') + compress_parser.set_defaults(func=compress_cmd) + + # Decompress + decompress_parser = subparsers.add_parser( + 'decompress', help='Decompress memory pages in a checkpoint directory') + decompress_parser.add_argument('dir') + decompress_parser.add_argument('--in-place', action='store_true', + help='Skip creating backup files') + decompress_parser.set_defaults(func=decompress_cmd) + opts = vars(parser.parse_args()) if not opts: @@ -460,7 +1674,18 @@ def main(): sys.stderr.write("crit: error: too few arguments\n") sys.exit(1) - opts["func"](opts) + if opts["func"] in (compress_cmd, decompress_cmd): + # Convert termination into a Python exception for the complete + # transformation, including staging. Context managers can then remove + # the current temporary file and _command_failed() removes earlier + # staged outputs before returning a failure status. + with _commit_signal_handlers() as signal_state: + opts['_commit_signal_state'] = signal_state + ret = opts["func"](opts) + else: + ret = opts["func"](opts) + if ret: + sys.exit(ret) if __name__ == '__main__': diff --git a/crit/pyproject.toml b/crit/pyproject.toml index f0b185eb7..e590bbd37 100644 --- a/crit/pyproject.toml +++ b/crit/pyproject.toml @@ -15,6 +15,9 @@ requires-python = ">=3.6" [project.scripts] crit = "crit.__main__:main" +[project.optional-dependencies] +compression = ["lz4"] + [tool.setuptools] packages = ["crit"] diff --git a/test/others/crit/.gitignore b/test/others/crit/.gitignore index 9614eb6c4..abbaad6f2 100644 --- a/test/others/crit/.gitignore +++ b/test/others/crit/.gitignore @@ -3,3 +3,17 @@ *.txt stats-* *.json +comp/ +uncomp/ +cdc/ +dcd/ +sparse-compress/ +sparse-decompress/ +threshold-compress/ +threshold-parent/ +parent-placeholder/ +truncated-decompress/ +region/ +malformed-*/ +transaction-compress/ +transaction-decompress/ diff --git a/test/others/crit/Makefile b/test/others/crit/Makefile index ca296e333..dea1d4fbd 100644 --- a/test/others/crit/Makefile +++ b/test/others/crit/Makefile @@ -4,3 +4,6 @@ run: clean clean: rm -f *.img *.log *.txt stats-* *.json + rm -rf comp/ uncomp/ cdc/ dcd/ region/ sparse-compress/ sparse-decompress/ + rm -rf threshold-compress/ threshold-parent/ parent-placeholder/ truncated-decompress/ + rm -rf malformed-*/ transaction-compress/ transaction-decompress/ diff --git a/test/others/crit/compression_image_helpers.py b/test/others/crit/compression_image_helpers.py new file mode 100644 index 000000000..539b6635c --- /dev/null +++ b/test/others/crit/compression_image_helpers.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 + +import argparse +import glob +import hashlib +import importlib +import os +import random +import sys + + +PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") +PAGE_COMPRESSION_THRESHOLD = PAGE_SIZE * 7 // 8 +PE_PARENT = 1 << 0 +PE_PRESENT = 1 << 2 +PE_PAYLOAD_ALIGNED = 1 << 3 + + +def pycriu_images(): + return importlib.import_module("pycriu.images") + + +def lz4_block(): + return importlib.import_module("lz4.block") + + +def assert_inventory_version(directory, expected): + images = pycriu_images() + with open(os.path.join(directory, "inventory.img"), "rb") as image_file: + inventory = images.load(image_file) + + actual = inventory["entries"][0]["img_version"] + if actual != expected: + print( + "FAIL: inventory version is %d, expected %d" % (actual, expected) + ) + return 1 + return 0 + + +def make_sparse_pagemap_image(directory, compressed): + images = pycriu_images() + with open(os.path.join(directory, "inventory.img"), "wb") as image_file: + inventory = {"magic": "INVENTORY", "entries": [{"img_version": 2}]} + if compressed: + inventory["entries"][0]["img_version"] = 3 + inventory["entries"][0]["compress"] = 1 + images.dump(inventory, image_file) + + if compressed: + # One raw present page, one non-present hole, one legacy parent entry + # without flags, and one zero present page with no payload. + pages = bytes([0x41]) * PAGE_SIZE + entries = [ + {"pages_id": 1}, + { + "vaddr": 0x100000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + "compressed_size": [PAGE_SIZE], + "total_compressed_size": PAGE_SIZE, + }, + { + "vaddr": 0x101000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": 0, + }, + { + "vaddr": 0x102000, + "compat_nr_pages": 1, + "nr_pages": 1, + "in_parent": True, + }, + { + "vaddr": 0x103000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + "compressed_size": [0], + "total_compressed_size": 0, + }, + ] + else: + # Two present pages are adjacent in pages.img, while the pagemap has a + # non-present hole and a legacy parent reference between them. + pages = bytes([0x41]) * PAGE_SIZE + bytes([0x42]) * PAGE_SIZE + entries = [ + {"pages_id": 1}, + { + "vaddr": 0x100000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + }, + { + "vaddr": 0x101000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": 0, + }, + { + "vaddr": 0x102000, + "compat_nr_pages": 1, + "nr_pages": 1, + "in_parent": True, + }, + { + "vaddr": 0x103000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + }, + ] + + with open(os.path.join(directory, "pages-1.img"), "wb") as image_file: + image_file.write(pages) + + with open(os.path.join(directory, "pagemap-1.img"), "wb") as image_file: + images.dump({"magic": "PAGEMAP", "entries": entries}, image_file) + return 0 + + +def make_threshold_pagemap_image(directory): + images = pycriu_images() + block = lz4_block() + rng = random.Random(0) + random_page = bytes(rng.randrange(256) for _ in range(PAGE_SIZE)) + page = None + + # Find a deterministic page on the raw side of CRIU's 7/8 threshold. A + # fixed-size compressible island is brittle across LZ4 releases because an + # incompressible block may legitimately grow by a few different bytes. + for zero_bytes in range(1, PAGE_SIZE + 1): + trial_page = random_page[:-zero_bytes] + b"\0" * zero_bytes + compressed_size = len( + block.compress(trial_page, store_size=False, acceleration=1) + ) + if PAGE_COMPRESSION_THRESHOLD <= compressed_size < PAGE_SIZE: + page = trial_page + break + + if page is None: + print("FAIL: unable to construct a page at the raw fallback threshold") + return 1 + + with open(os.path.join(directory, "inventory.img"), "wb") as image_file: + images.dump( + {"magic": "INVENTORY", "entries": [{"img_version": 2}]}, + image_file, + ) + + with open(os.path.join(directory, "pages-1.img"), "wb") as image_file: + image_file.write(page) + + with open(os.path.join(directory, "pagemap-1.img"), "wb") as image_file: + images.dump( + { + "magic": "PAGEMAP", + "entries": [ + {"pages_id": 1}, + { + "vaddr": 0x100000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + }, + ], + }, + image_file, + ) + return 0 + + +def make_truncated_uncompressed_entry_image(directory): + images = pycriu_images() + with open(os.path.join(directory, "inventory.img"), "wb") as image_file: + images.dump( + { + "magic": "INVENTORY", + "entries": [{"img_version": 3, "compress": 1}], + }, + image_file, + ) + + with open(os.path.join(directory, "pages-1.img"), "wb") as image_file: + image_file.write(bytes([0x41]) * PAGE_SIZE) + + with open(os.path.join(directory, "pagemap-1.img"), "wb") as image_file: + images.dump( + { + "magic": "PAGEMAP", + "entries": [ + {"pages_id": 1}, + { + "vaddr": 0x100000, + "compat_nr_pages": 2, + "nr_pages": 2, + "flags": PE_PRESENT, + }, + ], + }, + image_file, + ) + return 0 + + +def assert_pagemap_entry_uncompressed(directory): + images = pycriu_images() + with open(os.path.join(directory, "pagemap-1.img"), "rb") as image_file: + pagemap = images.load(image_file) + + entry = pagemap["entries"][1] + for field in ("compressed_size", "total_compressed_size", "region_pages"): + if field in entry: + print("FAIL: raw-only entry retained %s" % field) + return 1 + if not int(entry.get("flags", 0)) & PE_PAYLOAD_ALIGNED: + print("FAIL: raw-only entry lacks PE_PAYLOAD_ALIGNED") + return 1 + return 0 + + +def assert_region_pagemap(directory): + images = pycriu_images() + found_region = False + found_short_region = False + for path in glob.glob(os.path.join(directory, "pagemap-*.img")): + with open(path, "rb") as image_file: + pagemap = images.load(image_file) + for entry in pagemap["entries"][1:]: + region_pages = entry.get("region_pages", 0) + if region_pages > 1: + found_region = True + if entry.get("nr_pages", entry["compat_nr_pages"]) % region_pages: + found_short_region = True + if found_region and found_short_region: + break + if not found_region: + print("FAIL: no region-compressed pagemap entry found") + return 1 + if not found_short_region: + print("FAIL: no short final compression region found") + return 1 + return 0 + + +def write_pair(directory, images, pages_id, entry, payload): + with open( + os.path.join(directory, "pages-%d.img" % pages_id), "wb" + ) as image_file: + image_file.write(payload) + with open( + os.path.join(directory, "pagemap-%d.img" % pages_id), "wb" + ) as image_file: + images.dump( + { + "magic": "PAGEMAP", + "entries": [{"pages_id": pages_id}, entry], + }, + image_file, + ) + + +def make_malformed_compressed_image(directory, kind): + images = pycriu_images() + with open(os.path.join(directory, "inventory.img"), "wb") as image_file: + images.dump( + { + "magic": "INVENTORY", + "entries": [{"img_version": 3, "compress": 1}], + }, + image_file, + ) + + base = { + "vaddr": 0x100000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + } + + if kind == "oversize": + base["compressed_size"] = [PAGE_SIZE + 1] + base["total_compressed_size"] = PAGE_SIZE + 1 + write_pair(directory, images, 1, base, b"A" * (PAGE_SIZE + 1)) + elif kind == "total": + base["compressed_size"] = [PAGE_SIZE] + base["total_compressed_size"] = PAGE_SIZE - 1 + write_pair(directory, images, 1, base, b"A" * PAGE_SIZE) + elif kind == "count": + base["compat_nr_pages"] = 2 + base["nr_pages"] = 2 + base["compressed_size"] = [PAGE_SIZE] + base["total_compressed_size"] = PAGE_SIZE + write_pair(directory, images, 1, base, b"A" * PAGE_SIZE) + elif kind == "flags": + base["flags"] = PE_PRESENT | PE_PARENT + base["compressed_size"] = [PAGE_SIZE] + base["total_compressed_size"] = PAGE_SIZE + write_pair(directory, images, 1, base, b"A" * PAGE_SIZE) + elif kind == "aligned-nonpresent": + base["flags"] = PE_PAYLOAD_ALIGNED + write_pair(directory, images, 1, base, b"") + elif kind == "region-limit": + base["region_pages"] = 4 * 1024 * 1024 // PAGE_SIZE + 1 + base["compressed_size"] = [PAGE_SIZE] + base["total_compressed_size"] = PAGE_SIZE + write_pair(directory, images, 1, base, b"A" * PAGE_SIZE) + elif kind == "region-count": + base["compat_nr_pages"] = 17 + base["nr_pages"] = 17 + base["region_pages"] = 16 + base["compressed_size"] = [PAGE_SIZE] + base["total_compressed_size"] = PAGE_SIZE + write_pair(directory, images, 1, base, b"A" * PAGE_SIZE) + elif kind == "region-final-oversize": + base["compat_nr_pages"] = 17 + base["nr_pages"] = 17 + base["region_pages"] = 16 + base["compressed_size"] = [0, PAGE_SIZE + 1] + base["total_compressed_size"] = PAGE_SIZE + 1 + write_pair(directory, images, 1, base, b"A" * (PAGE_SIZE + 1)) + elif kind == "transaction": + first = dict(base) + first["compressed_size"] = [PAGE_SIZE] + first["total_compressed_size"] = PAGE_SIZE + write_pair(directory, images, 1, first, b"A" * PAGE_SIZE) + + second = dict(base) + second["vaddr"] = 0x200000 + second["compressed_size"] = [8] + second["total_compressed_size"] = 8 + write_pair(directory, images, 2, second, b"not-lz4!") + else: + raise ValueError("unknown malformed-image kind %s" % kind) + return 0 + + +def make_transactional_compress_image(directory): + images = pycriu_images() + with open(os.path.join(directory, "inventory.img"), "wb") as image_file: + images.dump( + {"magic": "INVENTORY", "entries": [{"img_version": 2}]}, + image_file, + ) + + for pages_id, size in ((1, PAGE_SIZE), (2, PAGE_SIZE - 1)): + with open( + os.path.join(directory, "pages-%d.img" % pages_id), "wb" + ) as image_file: + image_file.write(bytes([0x40 + pages_id]) * size) + with open( + os.path.join(directory, "pagemap-%d.img" % pages_id), "wb" + ) as image_file: + images.dump( + { + "magic": "PAGEMAP", + "entries": [ + {"pages_id": pages_id}, + { + "vaddr": pages_id * 0x100000, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + }, + ], + }, + image_file, + ) + return 0 + + +def expected_sparse_checksum(kind): + if kind == "raw-zero": + contents = bytes([0x41]) * PAGE_SIZE + bytes(PAGE_SIZE) + elif kind == "two-raw": + contents = bytes([0x41]) * PAGE_SIZE + bytes([0x42]) * PAGE_SIZE + else: + raise ValueError("unknown checksum kind %s" % kind) + print(hashlib.md5(contents).hexdigest()) + return 0 + + +def parse_args(): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + inventory_parser = subparsers.add_parser("assert-inventory-version") + inventory_parser.add_argument("directory") + inventory_parser.add_argument("expected", type=int) + + sparse_parser = subparsers.add_parser("make-sparse-pagemap") + sparse_parser.add_argument("directory") + sparse_parser.add_argument("compressed", type=int, choices=(0, 1)) + + threshold_parser = subparsers.add_parser("make-threshold-pagemap") + threshold_parser.add_argument("directory") + + truncated_parser = subparsers.add_parser("make-truncated-uncompressed") + truncated_parser.add_argument("directory") + + raw_parser = subparsers.add_parser("assert-entry-uncompressed") + raw_parser.add_argument("directory") + + region_parser = subparsers.add_parser("assert-region-pagemap") + region_parser.add_argument("directory") + + malformed_parser = subparsers.add_parser("make-malformed-compressed") + malformed_parser.add_argument("directory") + malformed_parser.add_argument("kind") + + transaction_parser = subparsers.add_parser("make-transactional-compress") + transaction_parser.add_argument("directory") + + checksum_parser = subparsers.add_parser("expected-sparse-checksum") + checksum_parser.add_argument("kind", choices=("raw-zero", "two-raw")) + + subparsers.add_parser("has-lz4") + return parser.parse_args() + + +def main(): + args = parse_args() + if args.command == "assert-inventory-version": + return assert_inventory_version(args.directory, args.expected) + if args.command == "make-sparse-pagemap": + return make_sparse_pagemap_image(args.directory, bool(args.compressed)) + if args.command == "make-threshold-pagemap": + return make_threshold_pagemap_image(args.directory) + if args.command == "make-truncated-uncompressed": + return make_truncated_uncompressed_entry_image(args.directory) + if args.command == "assert-entry-uncompressed": + return assert_pagemap_entry_uncompressed(args.directory) + if args.command == "assert-region-pagemap": + return assert_region_pagemap(args.directory) + if args.command == "make-malformed-compressed": + return make_malformed_compressed_image(args.directory, args.kind) + if args.command == "make-transactional-compress": + return make_transactional_compress_image(args.directory) + if args.command == "expected-sparse-checksum": + return expected_sparse_checksum(args.kind) + if args.command == "has-lz4": + lz4_block() + return 0 + raise AssertionError("unhandled command %s" % args.command) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/others/crit/crit_compression_tests.py b/test/others/crit/crit_compression_tests.py new file mode 100644 index 000000000..ea5c0dad5 --- /dev/null +++ b/test/others/crit/crit_compression_tests.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 + +import glob +import os +import random +import tempfile + +import lz4.block +import pycriu +from crit import __main__ as crit_main + + +PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") +PE_PRESENT = 1 << 2 +MAP_SHARED = 1 << 0 +MAP_PRIVATE = 1 << 1 +MAP_ANON = 0x20 +MAP_HUGETLB = 0x40000 +VMA_AREA_REGULAR = 1 << 0 +VMA_ANON_SHARED = 1 << 8 +VMA_ANON_PRIVATE = 1 << 9 +VMA_EXT_PLUGIN = 1 << 27 + + +def write_image(directory, name, image): + with open(os.path.join(directory, name), "wb") as image_file: + pycriu.images.dump(image, image_file) + + +def inventory(directory, compress=None, image_version=2): + entry = {"img_version": image_version} + if compress is not None: + entry["compress"] = compress + write_image( + directory, + "inventory.img", + {"magic": "INVENTORY", "entries": [entry]}, + ) + + +def pagemap(directory, image_id, pages_id, entries, shared=False): + prefix = "pagemap-shmem-" if shared else "pagemap-" + write_image( + directory, + "%s%d.img" % (prefix, image_id), + { + "magic": "PAGEMAP", + "entries": [{"pages_id": pages_id}] + entries, + }, + ) + + +def vma(start, page_count, flags, status, shmid=0, pgoff=0): + return { + "start": start, + "end": start + page_count * PAGE_SIZE, + "pgoff": pgoff, + "shmid": shmid, + "prot": 3, + "flags": flags, + "status": status, + "fd": -1, + } + + +def mm(directory, task_id, vmas): + entry = { + "mm_start_code": 0, + "mm_end_code": 0, + "mm_start_data": 0, + "mm_end_data": 0, + "mm_start_stack": 0, + "mm_start_brk": 0, + "mm_brk": 0, + "mm_arg_start": 0, + "mm_arg_end": 0, + "mm_env_start": 0, + "mm_env_end": 0, + "exe_file_id": 0, + "vmas": vmas, + } + write_image( + directory, + "mm-%d.img" % task_id, + {"magic": "MM", "entries": [entry]}, + ) + + +def load(directory, name): + with open(os.path.join(directory, name), "rb") as image_file: + return pycriu.images.load(image_file) + + +def image_bytes(directory): + contents = {} + for path in glob.glob(os.path.join(directory, "*.img")): + with open(path, "rb") as image_file: + contents[path] = image_file.read() + return contents + + +def ordinary_checkpoint(directory, page, task_id=1): + inventory(directory) + with open(os.path.join(directory, "pages-1.img"), "wb") as pages: + pages.write(page) + base = 0x100000 + pagemap( + directory, + task_id, + 1, + [ + { + "vaddr": base, + "compat_nr_pages": 1, + "nr_pages": 1, + "flags": PE_PRESENT, + } + ], + ) + mm( + directory, + task_id, + [ + vma( + base, + 1, + MAP_PRIVATE | MAP_ANON, + VMA_AREA_REGULAR | VMA_ANON_PRIVATE, + ) + ], + ) + + +def test_sign_extended_ranges(): + # Native 32-bit x86 pagemaps sign-extend pointers while mm VMA bounds do + # not. + sign_extended_ranges = [(0x90000000, 0x90000000 + PAGE_SIZE)] + assert crit_main._address_in_ranges( + 0xFFFFFFFF90000000, + sign_extended_ranges, + crit_main._range_starts(sign_extended_ranges), + ) + + +def test_exceptional_mappings_and_timestamps(): + # Private hugetlb and external-plugin ranges use task virtual addresses; + # shared hugetlb ranges use shmid and pgoff in pagemap-shmem images. Verify + # both translations and ensure only the ordinary pages become LZ4 blocks. + with tempfile.TemporaryDirectory(dir=".") as directory: + inventory(directory) + task_base = 0x100000 + task_pages = b"A" * PAGE_SIZE + b"B" * PAGE_SIZE + b"C" * PAGE_SIZE + shared_pages = b"D" * PAGE_SIZE + b"E" * PAGE_SIZE + with open(os.path.join(directory, "pages-1.img"), "wb") as pages: + pages.write(task_pages) + with open(os.path.join(directory, "pages-2.img"), "wb") as pages: + pages.write(shared_pages) + pagemap( + directory, + 100, + 1, + [ + { + "vaddr": task_base, + "compat_nr_pages": 3, + "nr_pages": 3, + "flags": PE_PRESENT, + } + ], + ) + pagemap( + directory, + 77, + 2, + [ + { + "vaddr": 0, + "compat_nr_pages": 2, + "nr_pages": 2, + "flags": PE_PRESENT, + } + ], + shared=True, + ) + mm( + directory, + 100, + [ + vma( + task_base, + 1, + MAP_PRIVATE | MAP_ANON | MAP_HUGETLB, + VMA_AREA_REGULAR | VMA_ANON_PRIVATE, + ), + vma( + task_base + PAGE_SIZE, + 1, + MAP_PRIVATE | MAP_ANON, + VMA_AREA_REGULAR | VMA_ANON_PRIVATE | VMA_EXT_PLUGIN, + ), + vma( + task_base + 2 * PAGE_SIZE, + 1, + MAP_PRIVATE | MAP_ANON, + VMA_AREA_REGULAR | VMA_ANON_PRIVATE, + ), + vma( + 0x200000, + 1, + MAP_SHARED | MAP_ANON | MAP_HUGETLB, + VMA_AREA_REGULAR | VMA_ANON_SHARED, + shmid=77, + ), + vma( + 0x300000, + 1, + MAP_SHARED | MAP_ANON, + VMA_AREA_REGULAR | VMA_ANON_SHARED, + shmid=77, + pgoff=PAGE_SIZE, + ), + ], + ) + + # Make source atime older than mtime so an ordinary read would change + # it. + original_times = {} + for index, path in enumerate( + sorted(glob.glob(os.path.join(directory, "*.img"))) + ): + times = (946684800123456789 + index, 978307200987654321 + index) + os.utime(path, ns=times) + before = os.stat(path) + original_times[path] = (before.st_atime_ns, before.st_mtime_ns) + + assert ( + crit_main.compress_cmd( + {"dir": directory, "in_place": True, "acceleration": 1} + ) + == 0 + ) + for path, times in original_times.items(): + after = os.stat(path) + assert (after.st_atime_ns, after.st_mtime_ns) == times, path + + task_pm = load(directory, "pagemap-100.img")["entries"][1] + shared_pm = load(directory, "pagemap-shmem-77.img")["entries"][1] + assert task_pm["compressed_size"][:2] == [PAGE_SIZE, PAGE_SIZE] + assert 0 < task_pm["compressed_size"][2] < PAGE_SIZE + assert shared_pm["compressed_size"][0] == PAGE_SIZE + assert 0 < shared_pm["compressed_size"][1] < PAGE_SIZE + + decompressed_times = {} + for index, path in enumerate( + sorted(glob.glob(os.path.join(directory, "*.img"))) + ): + times = (1009843200123456789 + index, 1041379200987654321 + index) + os.utime(path, ns=times) + before = os.stat(path) + decompressed_times[path] = (before.st_atime_ns, before.st_mtime_ns) + + assert ( + crit_main.decompress_cmd({"dir": directory, "in_place": True}) == 0 + ) + for path, times in decompressed_times.items(): + after = os.stat(path) + assert (after.st_atime_ns, after.st_mtime_ns) == times, path + with open(os.path.join(directory, "pages-1.img"), "rb") as pages: + assert pages.read() == task_pages + with open(os.path.join(directory, "pages-2.img"), "rb") as pages: + assert pages.read() == shared_pages + + +def test_acceleration(): + # This page is encoded differently by fast-mode acceleration 1 and 100. + # Comparing CRIT output to python-lz4 catches accidentally passing an + # ignored acceleration argument to the library's default compression mode. + rng = random.Random(1238) + tokens = [bytes(rng.randrange(256) for _ in range(4)) for _ in range(128)] + page = b"".join( + tokens[(index * 17 + index // 7) % len(tokens)] + for index in range(PAGE_SIZE // 4) + ) + expected = { + acceleration: lz4.block.compress( + page, + mode="fast", + store_size=False, + acceleration=acceleration, + ) + for acceleration in (1, 100) + } + assert expected[1] != expected[100] + + for acceleration in (1, 100): + with tempfile.TemporaryDirectory(dir=".") as directory: + ordinary_checkpoint(directory, page) + assert ( + crit_main.compress_cmd( + { + "dir": directory, + "in_place": True, + "acceleration": acceleration, + } + ) + == 0 + ) + pagemap_entry = load(directory, "pagemap-1.img")["entries"][1] + encoded = expected[acceleration] + if len(encoded) >= crit_main.PAGE_COMPRESSION_THRESHOLD: + assert "compressed_size" not in pagemap_entry + with open(os.path.join(directory, "pages-1.img"), "rb") as pages: + assert pages.read() == page + else: + assert pagemap_entry["compressed_size"] == [len(encoded)] + with open(os.path.join(directory, "pages-1.img"), "rb") as pages: + assert pages.read() == encoded + + +def test_unknown_compression_mode(): + # Unknown enum values must be rejected before the "already compressed" + # no-op and before any temporary or replacement image is created. + for command in ("compress", "decompress"): + with tempfile.TemporaryDirectory(dir=".") as directory: + ordinary_checkpoint(directory, b"F" * PAGE_SIZE) + inventory_image = load(directory, "inventory.img") + inventory_image["entries"][0]["compress"] = 0xFFFFFFFF + write_image(directory, "inventory.img", inventory_image) + before = image_bytes(directory) + assert run_transform(command, directory) == 1 + assert image_bytes(directory) == before + assert not glob.glob(os.path.join(directory, ".*.crit-*")) + + +def run_transform(command, directory): + if command == "compress": + return crit_main.compress_cmd( + {"dir": directory, "in_place": True, "acceleration": 1} + ) + return crit_main.decompress_cmd({"dir": directory, "in_place": True}) + + +def test_unsupported_image_version(): + for image_version in (1, 4): + for command in ("compress", "decompress"): + with tempfile.TemporaryDirectory(dir=".") as directory: + ordinary_checkpoint(directory, b"V" * PAGE_SIZE) + inventory_image = load(directory, "inventory.img") + inventory_image["entries"][0]["img_version"] = image_version + write_image(directory, "inventory.img", inventory_image) + before = image_bytes(directory) + + assert run_transform(command, directory) == 1 + assert image_bytes(directory) == before + assert not glob.glob(os.path.join(directory, ".*.crit-*")) + + +def test_compression_requires_v1_2_inventory(): + for command in ("compress", "decompress"): + with tempfile.TemporaryDirectory(dir=".") as directory: + ordinary_checkpoint(directory, b"C" * PAGE_SIZE) + inventory_image = load(directory, "inventory.img") + inventory_image["entries"][0]["compress"] = 1 + write_image(directory, "inventory.img", inventory_image) + before = image_bytes(directory) + + assert run_transform(command, directory) == 1 + assert image_bytes(directory) == before + assert not glob.glob(os.path.join(directory, ".*.crit-*")) + + +def test_uncompressed_v1_2_inventory_is_valid(): + with tempfile.TemporaryDirectory(dir=".") as directory: + ordinary_checkpoint(directory, b"U" * PAGE_SIZE) + inventory_image = load(directory, "inventory.img") + inventory_image["entries"][0]["img_version"] = 3 + write_image(directory, "inventory.img", inventory_image) + before = image_bytes(directory) + + assert run_transform("decompress", directory) == 0 + assert image_bytes(directory) == before + + +def test_symlink_rejection(): + # Replacing an image symlink would sever it while transforming its target's + # bytes with the symlink's metadata. Reject non-regular image paths instead. + with tempfile.TemporaryDirectory(dir=".") as directory: + ordinary_checkpoint(directory, b"G" * PAGE_SIZE) + inventory_path = os.path.join(directory, "inventory.img") + inventory_target = os.path.join(directory, "inventory.real") + os.replace(inventory_path, inventory_target) + os.symlink("inventory.real", inventory_path) + with open(inventory_target, "rb") as inventory_file: + before = inventory_file.read() + assert ( + crit_main.compress_cmd( + {"dir": directory, "in_place": True, "acceleration": 1} + ) + == 1 + ) + assert os.path.islink(inventory_path) + with open(inventory_target, "rb") as inventory_file: + assert inventory_file.read() == before + assert not glob.glob(os.path.join(directory, ".*.crit-*")) + + +def main(): + test_sign_extended_ranges() + test_exceptional_mappings_and_timestamps() + test_acceleration() + test_unknown_compression_mode() + test_unsupported_image_version() + test_compression_requires_v1_2_inventory() + test_uncompressed_v1_2_inventory_is_valid() + test_symlink_rejection() + + +if __name__ == "__main__": + main() diff --git a/test/others/crit/crit_transaction_tests.py b/test/others/crit/crit_transaction_tests.py new file mode 100644 index 000000000..b24de7e11 --- /dev/null +++ b/test/others/crit/crit_transaction_tests.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""Crash and race scenarios for CRIT's transactional image replacement. + +crit compress/decompress rewrite pages-*.img, pagemap-*.img, and +inventory.img as one transaction (_commit_staged() and friends in +crit.__main__): replacements are staged next to the originals, the +originals are retained through hard links, and any failure or +termination signal before the whole set is durable must put every +original image back. Each test sabotages one step of that commit and +asserts the directory ends in one of the two legal states: the complete +old image set or the complete new one. + +Faults are injected with intercept(), which wraps a function in the os +module for the duration of a test. crit resolves os functions at call +time through the same module object this test imports, so the wrapper +sees exactly the calls made by the code under test -- and also this +module's own os calls inside the window, which is why it must always +forward to the real function saved before patching. +""" + +import errno +import glob +import io +import os +import shutil +import signal +import stat +import tempfile +import unittest +import unittest.mock as mock +from contextlib import contextmanager, redirect_stderr + +from crit import __main__ as crit_main + +# Transaction files created next to the images: staged replacements +# (".NAME.crit-XXXX") and rollback hard links (".NAME.crit-rollback-XXXX"). +# The name patterns come from _staged_file() and _make_rollback_link(). +TRANSACTION_GLOB = ".*.crit-*" +ROLLBACK_GLOB = ".*.crit-rollback-*" + +TERMINATION_SIGNALS = (signal.SIGHUP, signal.SIGINT, signal.SIGTERM) + + +def write(path, data): + with open(path, "wb") as output: + output.write(data) + + +def read(path): + with open(path, "rb") as source: + return source.read() + + +@contextmanager +def intercept(name, when, before=None, after=None): + """Wrap os. so faults fire on calls matching when(*args). + + For a matching call, before(*args) runs first (raise from it to + suppress the real call), then the real function, then after(*args). + Calls not matching when() pass straight through. + """ + real = getattr(os, name) + + def wrapper(*args, **kwargs): + if not when(*args, **kwargs): + return real(*args, **kwargs) + if before is not None: + before(*args, **kwargs) + result = real(*args, **kwargs) + if after is not None: + after(*args, **kwargs) + return result + + with mock.patch.object(crit_main.os, name, new=wrapper): + yield + + +def raise_(exc): + """intercept() hook: fail the matching call with exc.""" + def effect(*args, **kwargs): + raise exc + return effect + + +def send_signal(signum): + """intercept() hook: deliver signum to this process.""" + def effect(*args, **kwargs): + os.kill(os.getpid(), signum) + return effect + + +class TransactionTestCase(unittest.TestCase): + """Shared fixture: a directory of original images and staged updates.""" + + def make_images(self, count=1): + """Create count originals ("old-N") with staged updates ("new-N"). + + Returns (directory, staged) where staged is the [(path, tmp_path)] + list _commit_staged() consumes. + """ + directory = tempfile.mkdtemp(dir=".") + self.addCleanup(shutil.rmtree, directory, ignore_errors=True) + staged = [] + for index in range(count): + path = os.path.join(directory, "image-%d" % index) + staged_path = path + ".new" + write(path, b"old-%d" % index) + write(staged_path, b"new-%d" % index) + staged.append((path, staged_path)) + return directory, staged + + def assertOriginalsIntact(self, staged): + for index, (path, _) in enumerate(staged): + self.assertEqual(read(path), b"old-%d" % index) + + def assertCommitted(self, staged): + for index, (path, _) in enumerate(staged): + self.assertEqual(read(path), b"new-%d" % index) + + def assertNoTransactionFiles(self, directory): + self.assertEqual(glob.glob(os.path.join(directory, + TRANSACTION_GLOB)), []) + + +class CommitRollbackTests(TransactionTestCase): + """A failed commit must restore the complete original image set.""" + + def test_failed_rename_restores_originals(self): + """A rename failure on a later image rolls back earlier renames.""" + directory, staged = self.make_images(count=2) + second_staged = staged[1][1] + + with intercept("replace", + when=lambda src, dst: src == second_staged, + before=raise_(OSError( + "injected second-image rename failure"))): + with self.assertRaisesRegex(crit_main.CritTransformError, + "original images were restored"): + crit_main._commit_staged(staged, True) + + self.assertOriginalsIntact(staged) + self.assertNoTransactionFiles(directory) + + def test_termination_signal_rolls_back(self): + """A signal between renames rolls back and is named in the error.""" + for signum in TERMINATION_SIGNALS: + if signal.getsignal(signum) == signal.SIG_IGN: + # _commit_staged() leaves ignored signals ignored, so an + # injected kill would not interrupt anything. + continue + with self.subTest(signal=signal.Signals(signum).name): + directory, staged = self.make_images(count=2) + second_staged = staged[1][1] + + with intercept("replace", + when=lambda src, dst, marker=second_staged: + src == marker, + after=send_signal(signum)): + with self.assertRaisesRegex( + crit_main.CritTransformError, + signal.Signals(signum).name): + crit_main._commit_staged(staged, True) + + self.assertOriginalsIntact(staged) + self.assertNoTransactionFiles(directory) + + def test_failed_rollback_preserves_recovery_link(self): + """If rollback also fails, the original bytes survive in a named + recovery link and the error reports the double failure.""" + directory, staged = self.make_images(count=1) + staged_path = staged[0][1] + + with intercept("replace", + when=lambda src, dst: "crit-rollback" in src, + before=raise_(OSError("injected rollback failure"))), \ + intercept("replace", + when=lambda src, dst: src == staged_path, + after=raise_(OSError("injected failure after rename"))): + with self.assertRaisesRegex(crit_main.CritTransformError, + "rollback also failed"): + crit_main._commit_staged(staged, True) + + recovery = glob.glob(os.path.join(directory, ROLLBACK_GLOB)) + self.assertEqual(len(recovery), 1) + self.assertEqual(read(recovery[0]), b"old-0") + + +class ConcurrentReplacementTests(TransactionTestCase): + """A transform must not overwrite an image replaced after staging.""" + + def test_replaced_source_is_preserved(self): + for in_place in (False, True): + with self.subTest(in_place=in_place): + directory, staged = self.make_images(count=1) + path, staged_path = staged[0] + image_metadata = { + path: crit_main._capture_file_metadata(path), + } + replacement = path + ".concurrent" + write(replacement, b"concurrent") + os.replace(replacement, path) + + with self.assertRaisesRegex( + crit_main.CritTransformError, + "image changed while preparing to replace"): + crit_main._commit_staged( + staged, in_place, image_metadata=image_metadata) + + self.assertEqual(read(path), b"concurrent") + self.assertFalse(os.path.exists(staged_path)) + self.assertFalse(os.path.exists(path + ".bak")) + self.assertNoTransactionFiles(directory) + + def test_replacement_while_commit_links_original_is_preserved(self): + for in_place in (False, True): + with self.subTest(in_place=in_place): + directory, staged = self.make_images(count=1) + path, staged_path = staged[0] + image_metadata = { + path: crit_main._capture_file_metadata(path), + } + + def replace_source(_source, _link): + replacement = path + ".concurrent" + write(replacement, b"concurrent") + os.replace(replacement, path) + + with intercept( + "link", when=lambda src, _dst: src == path, + after=replace_source): + with self.assertRaisesRegex( + crit_main.CritTransformError, + "image changed while preparing to replace"): + crit_main._commit_staged( + staged, in_place, + image_metadata=image_metadata) + + self.assertEqual(read(path), b"concurrent") + self.assertFalse(os.path.exists(staged_path)) + self.assertFalse(os.path.exists(path + ".bak")) + self.assertNoTransactionFiles(directory) + + def test_later_replacement_rolls_back_only_earlier_images(self): + for in_place in (False, True): + with self.subTest(in_place=in_place): + directory, staged = self.make_images(count=2) + first_path, first_staged = staged[0] + second_path, second_staged = staged[1] + image_metadata = { + path: crit_main._capture_file_metadata(path) + for path, _ in staged + } + + def replace_second(_source, _destination): + replacement = second_path + ".concurrent" + write(replacement, b"concurrent") + os.replace(replacement, second_path) + + with intercept( + "replace", + when=lambda src, _dst: src == first_staged, + after=replace_second): + with self.assertRaisesRegex( + crit_main.CritTransformError, + "image changed while preparing to replace"): + crit_main._commit_staged( + staged, in_place, + image_metadata=image_metadata) + + self.assertEqual(read(first_path), b"old-0") + self.assertEqual(read(second_path), b"concurrent") + self.assertFalse(os.path.exists(first_staged)) + self.assertFalse(os.path.exists(second_staged)) + self.assertFalse(os.path.exists(first_path + ".bak")) + self.assertFalse(os.path.exists(second_path + ".bak")) + self.assertNoTransactionFiles(directory) + + +@unittest.skipIf(signal.getsignal(signal.SIGTERM) == signal.SIG_IGN, + "SIGTERM is ignored in this environment") +class SignalHandlingTests(TransactionTestCase): + """Termination signals must never leave a half-replaced image set.""" + + def test_repeated_signal_does_not_interrupt_cleanup(self): + """The first signal raises for rollback; a repeat during that + unwinding is swallowed so cleanup can finish.""" + with crit_main._commit_signal_handlers(): + with self.assertRaisesRegex(crit_main.CritTransformError, + "SIGTERM"): + os.kill(os.getpid(), signal.SIGTERM) + # Unwinding has started; a second signal must not raise again. + os.kill(os.getpid(), signal.SIGTERM) + + def test_signal_after_commit_is_ignored(self): + """Once the replacement set is durable, a late signal must not + make a committed command look failed.""" + _, staged = self.make_images(count=1) + + with crit_main._commit_signal_handlers() as signal_state: + crit_main._commit_staged(staged, True, signal_state) + self.assertTrue(signal_state["committed"]) + os.kill(os.getpid(), signal.SIGTERM) + + self.assertCommitted(staged) + + def test_signal_during_post_commit_cleanup(self): + """A signal while removing the now-unneeded rollback links stays + deferred: it is too late to roll back, so the command succeeds + with the new image set installed and no leftover links.""" + directory, staged = self.make_images(count=1) + + with crit_main._commit_signal_handlers() as signal_state: + def during_rollback_cleanup(target): + return (signal_state["committed"] and + "crit-rollback" in target) + + with intercept("unlink", when=during_rollback_cleanup, + after=send_signal(signal.SIGTERM)): + crit_main._commit_staged(staged, True, signal_state) + + self.assertCommitted(staged) + self.assertNoTransactionFiles(directory) + + +class MetadataTests(TransactionTestCase): + """Replacing an image's inode must not lose its metadata.""" + + def test_replacement_preserves_mode_owner_and_times(self): + """The replacement keeps the original's complete Unix ownership + and permission tuple, including when CRIT runs as root on an + unprivileged user's checkpoint.""" + directory, _ = self.make_images(count=0) + path = os.path.join(directory, "owned") + write(path, b"old") + # Owner-only, but distinct from mkstemp's 0600 default so a + # replacement whose mode was never restored cannot pass. + os.chmod(path, 0o400) + os.utime(path, ns=(946684800123456789, 978307200987654321)) + if os.geteuid() == 0: + os.chown(path, 65534, 65534) + # Compare against a stat snapshot rather than the raw utime values + # so filesystems with coarser timestamp granularity still pass. + before = os.stat(path) + + with crit_main._staged_file(path) as (output, staged_path): + output.write(b"new") + crit_main._commit_staged([(path, staged_path)], True) + + after = os.stat(path) + self.assertEqual(stat.S_IMODE(after.st_mode), + stat.S_IMODE(before.st_mode)) + self.assertEqual((after.st_uid, after.st_gid), + (before.st_uid, before.st_gid)) + self.assertEqual((after.st_atime_ns, after.st_mtime_ns), + (before.st_atime_ns, before.st_mtime_ns)) + + def test_replacement_preserves_xattrs(self): + """Replacement keeps ACL/security metadata carried as xattrs.""" + directory, _ = self.make_images(count=0) + path = os.path.join(directory, "labelled") + write(path, b"old") + # user.* is available to unprivileged users on the filesystems + # normally used by the test; skip only when unsupported. + try: + os.setxattr(path, b"user.crit-test", b"preserved") + except OSError as exc: + if exc.errno not in (errno.ENOTSUP, errno.EOPNOTSUPP): + raise + self.skipTest("filesystem does not support user xattrs") + + with crit_main._staged_file(path) as (output, staged_path): + output.write(b"new") + crit_main._commit_staged([(path, staged_path)], True) + + self.assertEqual(os.getxattr(path, b"user.crit-test"), b"preserved") + + def test_metadata_capture_without_xattr_support(self): + """Filesystems without xattr support must still permit a transform + when there is no extended metadata to preserve.""" + directory, _ = self.make_images(count=0) + path = os.path.join(directory, "no-xattrs") + write(path, b"contents") + + unsupported = OSError(errno.ENOTSUP, "xattrs unsupported") + with mock.patch.object(crit_main.os, "listxattr", + side_effect=unsupported): + metadata, xattrs = crit_main._capture_file_metadata(path) + + self.assertTrue(stat.S_ISREG(metadata.st_mode)) + self.assertEqual(xattrs, []) + + def test_noatime_denied_fallback_restores_atime(self): + """O_NOATIME can be denied when the caller may read but does not + own an image; the fallback read restores the captured atime.""" + directory, _ = self.make_images(count=0) + path = os.path.join(directory, "source") + write(path, b"contents") + os.utime(path, ns=(946684800123456789, 978307200987654321)) + source_metadata = crit_main._capture_file_metadata(path) + before = os.stat(path) + noatime = getattr(os, "O_NOATIME", 0) + + with intercept("open", + when=lambda target, flags, *args, **kwargs: + flags & noatime, + before=raise_(OSError(errno.EPERM, + "injected O_NOATIME denial"))): + with crit_main._source_file(path, source_metadata) as source: + self.assertEqual(source.read(), b"contents") + + after = os.stat(path) + self.assertEqual((after.st_atime_ns, after.st_mtime_ns), + (before.st_atime_ns, before.st_mtime_ns)) + + +class CleanupDiagnosticsTests(TransactionTestCase): + """Cleanup failures must not mask the error that triggered cleanup.""" + + def test_cleanup_failure_does_not_mask_primary_error(self): + directory, staged = self.make_images(count=1) + + stderr = io.StringIO() + with redirect_stderr(stderr), mock.patch.object( + crit_main, "_remove_file", + side_effect=OSError(errno.EIO, "injected cleanup failure")): + result = crit_main._command_failed( + "compress", + ValueError("primary transformation failure"), + staged) + + self.assertEqual(result, 1) + diagnostic = stderr.getvalue() + self.assertIn("primary transformation failure", diagnostic) + self.assertIn("injected cleanup failure", diagnostic) + + +class BackupCollisionTests(TransactionTestCase): + """A prior .bak file is user data and must never be overwritten.""" + + def test_existing_backup_is_preserved(self): + """The complete operation is refused before the first rename.""" + directory, staged = self.make_images(count=1) + path, staged_path = staged[0] + backup = path + ".bak" + write(backup, b"existing backup") + + with self.assertRaisesRegex(crit_main.CritTransformError, + "existing backup"): + crit_main._commit_staged(staged, False) + + self.assertOriginalsIntact(staged) + self.assertEqual(read(backup), b"existing backup") + self.assertFalse(os.path.exists(staged_path)) + self.assertNoTransactionFiles(directory) + + def test_concurrent_backup_creation_wins(self): + """A backup created by another process after this transaction + starts must win atomically (link() failing with EEXIST).""" + directory, staged = self.make_images(count=1) + path, staged_path = staged[0] + backup = path + ".bak" + + def racer(source, destination): + write(destination, b"concurrent backup") + + with intercept("link", + when=lambda src, dst: dst.endswith(".bak"), + before=racer): + with self.assertRaisesRegex(crit_main.CritTransformError, + "existing backup"): + crit_main._commit_staged(staged, False) + + self.assertOriginalsIntact(staged) + self.assertEqual(read(backup), b"concurrent backup") + self.assertFalse(os.path.exists(staged_path)) + self.assertNoTransactionFiles(directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/others/crit/test.sh b/test/others/crit/test.sh index 2698bbd3c..6ffb47a9d 100755 --- a/test/others/crit/test.sh +++ b/test/others/crit/test.sh @@ -1,19 +1,34 @@ #!/bin/bash # shellcheck disable=SC2002 -set -x +set -ex # shellcheck source=test/others/env.sh source ../env.sh images_list=() +RESTORED_PID="" + +function cleanup_restored_process { + if [ -z "$RESTORED_PID" ]; then + return + fi + if kill -0 "$RESTORED_PID" 2>/dev/null; then + kill -KILL "$RESTORED_PID" 2>/dev/null || true + fi + RESTORED_PID="" +} + +trap cleanup_restored_process EXIT function gen_imgs { PID=$(../loop) - if ! $CRIU dump -v4 -o dump.log -D ./ -t "$PID"; then + if ! $CRIU dump --no-default-config -v4 -o dump.log -D ./ -t "$PID"; then echo "Failed to checkpoint process $PID" - cat dump.log - kill -9 "$PID" + if [ -f dump.log ]; then + cat dump.log + fi + kill -9 "$PID" 2>/dev/null || true exit 1 fi @@ -101,8 +116,549 @@ function run_test2 { ${CRIT} x ./ rss || exit 1 } +ZDTM_DIR="${BASE_DIR}/test/zdtm/static" +CRIT_TEST_DIR="${BASE_DIR}/test/others/crit" +COMPRESSION_IMAGE_HELPER="${CRIT_TEST_DIR}/compression_image_helpers.py" +CRIT_TRANSACTION_TESTS="${CRIT_TEST_DIR}/crit_transaction_tests.py" +CRIT_COMPRESSION_TESTS="${CRIT_TEST_DIR}/crit_compression_tests.py" + +function dump_test_process { + # Dump compress_pages02 (covers 8 mapping types: anonymous, + # zero-filled, shared anonymous, file-backed private/shared, + # memfd, read-only, PROT_NONE guard) into the given directory. + # Prints the PID on success. + local dir=$1; shift + local pid + + if ! make -C "$ZDTM_DIR" compress_pages02 > /dev/null 2>&1; then + echo "FAIL: failed to build compress_pages02" + exit 1 + fi + + # Clean stale marker files and run from the test directory + # so zdtm pidfile rename works (same filesystem). + rm -f "$dir"/test.out* "$dir"/test.pid "$dir"/test.file + + if ! (cd "$dir" && "$ZDTM_DIR/compress_pages02" \ + --pidfile=test.pid \ + --outfile=test.out \ + --filename=test.file); then + echo "FAIL: failed to start compress_pages02" + cat "$dir/test.out" 2>/dev/null + exit 1 + fi + + # Wait for pidfile + local i=0 + while [ $i -lt 50 ]; do + if [ -f "$dir/test.pid" ]; then + break + fi + sleep 0.1 + i=$((i + 1)) + done + + pid=$(cat "$dir/test.pid" 2>/dev/null) + if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then + echo "FAIL: test process didn't start" + cat "$dir/test.out" 2>/dev/null + exit 1 + fi + + if ! $CRIU dump --no-default-config -v4 -o dump.log -D "$dir" \ + -t "$pid" --shell-job "$@"; then + echo "FAIL: dump into $dir" + if [ -f "$dir/dump.log" ]; then + cat "$dir/dump.log" + fi + kill -9 "$pid" 2>/dev/null || true + exit 1 + fi + echo "$pid" +} + +function restore_and_verify { + # Restore from directory, send SIGTERM so compress_pages02 + # verifies its own memory, then check the test output for PASS. + local dir=$1 + local i + local pid + local verified=0 + + if ! $CRIU restore --no-default-config -v4 -o restore.log -D "$dir" \ + --shell-job -d; then + echo "FAIL: restore from $dir" + cat "$dir/restore.log" + exit 1 + fi + + # Get the root PID from the pstree image + pid=$($CRIT decode -i "$dir/pstree.img" 2>/dev/null | + grep -o '"pid": [0-9]*' | head -1 | grep -o '[0-9]*') + + if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then + echo "FAIL: restored process not running from $dir (pid=$pid)" + exit 1 + fi + RESTORED_PID="$pid" + + # Send SIGTERM so compress_pages02 verifies all memory + # regions (zero, pattern, shared, file, memfd, readonly, + # guard) then writes PASS/FAIL to its output file. + if ! kill -TERM "$pid" 2>/dev/null; then + echo "FAIL: unable to signal restored process $pid from $dir" + exit 1 + fi + for ((i = 0; i < 100; i++)); do + if grep -q PASS "$dir/test.out" 2>/dev/null; then + verified=1 + break + fi + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + if [ "$verified" -ne 1 ]; then + echo "FAIL: memory verification failed after restore from $dir" + cat "$dir/test.out" 2>/dev/null + exit 1 + fi + + # The workload normally exits immediately after publishing PASS. Bound the + # wait so a broken test cannot leak into subsequent CRIT cases. + for ((i = 0; i < 50; i++)); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + RESTORED_PID="" +} + +function pages_checksum { + # Print md5sum of all pages-*.img files in a directory. + # This captures the raw page content for comparison. + cat "$1"/pages-*.img | md5sum | awk '{print $1}' +} + +function assert_inventory_version { + local dir=$1 + local expected=$2 + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + assert-inventory-version "$dir" "$expected" +} + +function make_sparse_pagemap_image { + local dir=$1 + local compressed=$2 + + rm -rf "$dir" + mkdir -p "$dir" + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + make-sparse-pagemap "$dir" "$compressed" +} + +function make_threshold_pagemap_image { + local dir=$1 + + rm -rf "$dir" + mkdir -p "$dir" + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + make-threshold-pagemap "$dir" +} + +function make_truncated_uncompressed_entry_image { + local dir=$1 + + rm -rf "$dir" + mkdir -p "$dir" + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + make-truncated-uncompressed "$dir" +} + +function assert_sparse_pagemap_payload { + local dir=$1 + local expected_size=$2 + local expected_md5=$3 + local actual_size + local actual_md5 + + actual_size=$(stat -c %s "$dir/pages-1.img") + if [ "$actual_size" != "$expected_size" ]; then + echo "FAIL: $dir/pages-1.img size is $actual_size, expected $expected_size" + exit 1 + fi + + actual_md5=$(md5sum "$dir/pages-1.img" | awk '{print $1}') + if [ "$actual_md5" != "$expected_md5" ]; then + echo "FAIL: $dir/pages-1.img checksum is $actual_md5, expected $expected_md5" + exit 1 + fi +} + +function assert_pagemap_entry_uncompressed { + local dir=$1 + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + assert-entry-uncompressed "$dir" +} + +function assert_region_pagemap { + local dir=$1 + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + assert-region-pagemap "$dir" +} + +function make_malformed_compressed_image { + local dir=$1 + local kind=$2 + + rm -rf "$dir" + mkdir -p "$dir" + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + make-malformed-compressed "$dir" "$kind" +} + +function make_transactional_compress_image { + local dir=$1 + + rm -rf "$dir" + mkdir -p "$dir" + + PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + make-transactional-compress "$dir" +} + +function image_set_checksum { + (cd "$1" && sha256sum ./*.img) | sha256sum | awk '{print $1}' +} + +function assert_no_transaction_files { + local dir=$1 + local file + + file=$(find "$dir" -maxdepth 1 -name '.*.crit-*' -print -quit) + if [ -n "$file" ]; then + echo "FAIL: transaction file was not removed: $file" + exit 1 + fi +} + +function test_commit_transactions { + PYTHONPATH="${BASE_DIR}/lib:${BASE_DIR}/crit" \ + python3 "$CRIT_TRANSACTION_TESTS" -v +} + +function test_compress_regressions { + PYTHONPATH="${BASE_DIR}/lib:${BASE_DIR}/crit" \ + python3 "$CRIT_COMPRESSION_TESTS" +} + +function run_test_compress { + local page_size + page_size=$(getconf PAGESIZE) + + echo "=== compress/decompress tests ===" + + # ------------------------------------------------------- + # Test 1: dump -c -> decompress -> restore + # ------------------------------------------------------- + echo " -- Test 1: compressed dump -> decompress -> restore" + dump_test_process comp/ -c > /dev/null + + $CRIT decompress comp/ || exit 1 + assert_inventory_version comp/ 2 + + # Verify backup files exist + ls comp/pages-*.img.bak > /dev/null 2>&1 || { echo "FAIL: no pages backup"; exit 1; } + ls comp/pagemap-*.img.bak > /dev/null 2>&1 || { echo "FAIL: no pagemap backup"; exit 1; } + ls comp/inventory.img.bak > /dev/null 2>&1 || { echo "FAIL: no inventory backup"; exit 1; } + + restore_and_verify comp/ + echo " PASS" + + # ------------------------------------------------------- + # Test 2: dump uncompressed -> compress -> restore + # ------------------------------------------------------- + echo " -- Test 2: uncompressed dump -> compress -> restore" + dump_test_process uncomp/ > /dev/null + + $CRIT compress uncomp/ --in-place || exit 1 + assert_inventory_version uncomp/ 3 + + # Verify no backup files with --in-place + if ls uncomp/*.bak > /dev/null 2>&1; then + echo "FAIL: backup files created with --in-place" + exit 1 + fi + + restore_and_verify uncomp/ + echo " PASS" + + # ------------------------------------------------------- + # Test 3: compress already compressed, decompress already decompressed + # ------------------------------------------------------- + echo " -- Test 3: compress already compressed, decompress already decompressed" + $CRIT compress uncomp/ --in-place 2>&1 | grep -q "already compressed" || { + echo "FAIL: compress should report already compressed" + exit 1 + } + $CRIT decompress comp/ 2>&1 | grep -q "already decompressed" || { + echo "FAIL: decompress should report already decompressed" + exit 1 + } + echo " PASS" + + # ------------------------------------------------------- + # Test 4: compress -> decompress -> compress produces same pages + # ------------------------------------------------------- + echo " -- Test 4: compress -> decompress -> compress stability" + rm -rf cdc/ + mkdir -p cdc/ + dump_test_process cdc/ > /dev/null + + $CRIT compress cdc/ --in-place || exit 1 + local sum1 + sum1=$(pages_checksum cdc/) + + $CRIT decompress cdc/ --in-place || exit 1 + assert_inventory_version cdc/ 2 + $CRIT compress cdc/ --in-place || exit 1 + assert_inventory_version cdc/ 3 + local sum2 + sum2=$(pages_checksum cdc/) + + if [ "$sum1" != "$sum2" ]; then + echo "FAIL: compress->decompress->compress changed pages data" + echo " first: $sum1" + echo " second: $sum2" + exit 1 + fi + + restore_and_verify cdc/ + echo " PASS" + + # ------------------------------------------------------- + # Test 5: decompress -> compress -> decompress produces same pages + # ------------------------------------------------------- + echo " -- Test 5: decompress -> compress -> decompress stability" + rm -rf dcd/ + mkdir -p dcd/ + dump_test_process dcd/ -c > /dev/null + + $CRIT decompress dcd/ --in-place || exit 1 + assert_inventory_version dcd/ 2 + local sum3 + sum3=$(pages_checksum dcd/) + + $CRIT compress dcd/ --in-place || exit 1 + assert_inventory_version dcd/ 3 + $CRIT decompress dcd/ --in-place || exit 1 + assert_inventory_version dcd/ 2 + local sum4 + sum4=$(pages_checksum dcd/) + + if [ "$sum3" != "$sum4" ]; then + echo "FAIL: decompress->compress->decompress changed pages data" + echo " first: $sum3" + echo " second: $sum4" + exit 1 + fi + + restore_and_verify dcd/ + echo " PASS" + + # ------------------------------------------------------- + # Test 6: CRIT must not consume pages payload for sparse + # pagemap entries without PE_PRESENT, including legacy + # in_parent entries with no flags field. + # ------------------------------------------------------- + echo " -- Test 6: sparse pagemap entries have no pages payload" + local raw_md5 + local zero_md5 + raw_md5=$(PYTHONPATH="${BASE_DIR}/lib" \ + python3 "$COMPRESSION_IMAGE_HELPER" \ + expected-sparse-checksum raw-zero) + zero_md5=$(PYTHONPATH="${BASE_DIR}/lib" \ + python3 "$COMPRESSION_IMAGE_HELPER" \ + expected-sparse-checksum two-raw) + + make_sparse_pagemap_image sparse-compress/ 0 + $CRIT compress sparse-compress/ --in-place || exit 1 + assert_inventory_version sparse-compress/ 3 + $CRIT decompress sparse-compress/ --in-place || exit 1 + # Parent entries may still resolve to compressed parent images, so CRIT + # must retain the V1_2 reader gate after transforming the local payload. + assert_inventory_version sparse-compress/ 3 + assert_sparse_pagemap_payload sparse-compress/ "$((page_size * 2))" "$zero_md5" + + make_sparse_pagemap_image sparse-decompress/ 1 + $CRIT decompress sparse-decompress/ --in-place || exit 1 + assert_inventory_version sparse-decompress/ 3 + assert_sparse_pagemap_payload sparse-decompress/ "$((page_size * 2))" "$raw_md5" + echo " PASS" + + # ------------------------------------------------------- + # Test 7: CRIT uses the same 7/8 raw fallback threshold as CRIU and + # omits compression metadata when the complete entry stays raw. + # ------------------------------------------------------- + echo " -- Test 7: raw-only entries use ordinary pagemap representation" + make_threshold_pagemap_image threshold-compress/ + local threshold_md5 + threshold_md5=$(md5sum threshold-compress/pages-1.img | awk '{print $1}') + + $CRIT compress threshold-compress/ --in-place || exit 1 + assert_inventory_version threshold-compress/ 3 + assert_pagemap_entry_uncompressed threshold-compress/ + assert_sparse_pagemap_payload threshold-compress/ "$page_size" "$threshold_md5" + + # A parent link means a parent image may still require compressed-image + # readers even when every page in this directory is present and raw. + cp -a threshold-compress/ threshold-parent/ + mkdir -p parent-placeholder/ + cp threshold-compress/inventory.img parent-placeholder/inventory.img + ln -s ../parent-placeholder threshold-parent/parent + $CRIT decompress threshold-parent/ --in-place || exit 1 + assert_inventory_version threshold-parent/ 3 + + $CRIT decompress threshold-compress/ --in-place || exit 1 + assert_inventory_version threshold-compress/ 2 + assert_sparse_pagemap_payload threshold-compress/ "$page_size" "$threshold_md5" + echo " PASS" + + # ------------------------------------------------------- + # Test 8: CRIT must fail explicitly on a truncated + # uncompressed payload copied through by decompress. + # ------------------------------------------------------- + echo " -- Test 8: truncated uncompressed payload is rejected" + make_truncated_uncompressed_entry_image truncated-decompress/ + if $CRIT decompress truncated-decompress/ --in-place \ + > truncated-decompress.log 2>&1; then + echo "FAIL: decompress accepted truncated pages payload" + cat truncated-decompress.log + exit 1 + fi + grep -q "describes.*payload bytes" truncated-decompress.log || { + echo "FAIL: decompress did not report the payload-size mismatch" + cat truncated-decompress.log + exit 1 + } + echo " PASS" + + # ------------------------------------------------------- + # Test 9: region-compressed images are transformed and + # restored, including a final short region where present. + # ------------------------------------------------------- + echo " -- Test 9: region-compressed dump -> decompress -> restore" + rm -rf region/ + mkdir -p region/ + dump_test_process region/ "--compress-region=$((page_size * 16))" > /dev/null + assert_region_pagemap region/ + $CRIT decompress region/ --in-place || exit 1 + assert_inventory_version region/ 2 + restore_and_verify region/ + echo " PASS" + + # ------------------------------------------------------- + # Test 10: reject malformed compressed metadata before + # attempting any unbounded read or allocation. + # ------------------------------------------------------- + echo " -- Test 10: malformed compressed metadata is rejected" + local kind + for kind in oversize total count flags aligned-nonpresent region-limit region-count \ + region-final-oversize; do + local dir="malformed-$kind" + local before + local after + make_malformed_compressed_image "$dir/" "$kind" + before=$(image_set_checksum "$dir/") + if $CRIT decompress "$dir/" --in-place \ + > "$dir.log" 2>&1; then + echo "FAIL: decompress accepted malformed metadata: $kind" + cat "$dir.log" + exit 1 + fi + after=$(image_set_checksum "$dir/") + if [ "$before" != "$after" ]; then + echo "FAIL: malformed $kind image was modified" + exit 1 + fi + assert_no_transaction_files "$dir/" + done + echo " PASS" + + # ------------------------------------------------------- + # Test 11: a failure in a later pages image leaves every + # original image untouched for both transformations. + # ------------------------------------------------------- + echo " -- Test 11: transformations are transactional" + local before + local after + + make_malformed_compressed_image transaction-decompress/ transaction + before=$(image_set_checksum transaction-decompress/) + if $CRIT decompress transaction-decompress/ --in-place \ + > transaction-decompress.log 2>&1; then + echo "FAIL: decompress accepted corrupt LZ4 payload" + cat transaction-decompress.log + exit 1 + fi + after=$(image_set_checksum transaction-decompress/) + if [ "$before" != "$after" ]; then + echo "FAIL: failed decompression modified the image set" + exit 1 + fi + assert_no_transaction_files transaction-decompress/ + + make_transactional_compress_image transaction-compress/ + before=$(image_set_checksum transaction-compress/) + if $CRIT compress transaction-compress/ --in-place \ + > transaction-compress.log 2>&1; then + echo "FAIL: compress accepted a truncated pages image" + cat transaction-compress.log + exit 1 + fi + after=$(image_set_checksum transaction-compress/) + if [ "$before" != "$after" ]; then + echo "FAIL: failed compression modified the image set" + exit 1 + fi + assert_no_transaction_files transaction-compress/ + test_commit_transactions + echo " PASS" + + # ------------------------------------------------------- + # Test 12: offline compression observes restore invariants, + # honors acceleration, preserves timestamps, and validates enums. + # ------------------------------------------------------- + echo " -- Test 12: CRIT compression regressions" + test_compress_regressions + echo " PASS" + + echo "=== compress/decompress: ALL PASS ===" +} + ${CRIT} --version gen_imgs run_test1 run_test2 + +# Skip compress/decompress tests if lz4 or CRIU compression is unavailable +if PYTHONPATH="${BASE_DIR}/lib" python3 "$COMPRESSION_IMAGE_HELPER" \ + has-lz4 2>/dev/null && \ + $CRIU check --no-default-config --feature compress 2>/dev/null; then + mkdir -p comp/ uncomp/ + run_test_compress +else + echo "=== Skipping compress/decompress tests (lz4 or CRIU compression not available) ===" +fi