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 <rstoyanov@fedoraproject.org>
This commit is contained in:
Radostin Stoyanov 2026-07-11 12:14:15 +01:00
parent 94447379a9
commit 47f14e9469
14 changed files with 3201 additions and 7 deletions

View file

@ -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)

View file

@ -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 \

View file

@ -33,6 +33,7 @@ apk add --no-cache \
protobuf-c-dev \
protobuf-dev \
py3-importlib-metadata \
py3-lz4 \
py3-pip \
py3-protobuf \
py3-yaml \

View file

@ -37,6 +37,7 @@ fi
protobuf-c-compiler \
protobuf-compiler \
python3-importlib-metadata \
python3-lz4 \
python3-pip \
python3-protobuf \
python3-yaml \

View file

@ -34,6 +34,7 @@ dnf install -y \
protobuf-devel \
python-devel \
python3-importlib-metadata \
python3-lz4 \
python3-protobuf \
python3-pyyaml \
python3-setuptools \

View file

@ -26,6 +26,7 @@ pacman -Syu --noconfirm \
protobuf \
protobuf-c \
python-importlib-metadata \
python-lz4 \
python-pip \
python-protobuf \
python-yaml \

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,9 @@ requires-python = ">=3.6"
[project.scripts]
crit = "crit.__main__:main"
[project.optional-dependencies]
compression = ["lz4"]
[tool.setuptools]
packages = ["crit"]

View file

@ -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/

View file

@ -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/

View file

@ -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())

View file

@ -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()

View file

@ -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.<name> 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()

View file

@ -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