138 lines
6.4 KiB
Bash
Executable File
138 lines
6.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Validate the exported tar, then corrupt disposable copies to test rejection.
|
|
source "$(dirname -- "${BASH_SOURCE[0]}")/../../tools/lib.sh"
|
|
cd "$FDS_ROOT"
|
|
[[ -f out/rootfs-aarch64.tar ]] || die 'Run make rootfs PROFILE=cli first'
|
|
scratch=$(mktemp -d "$FDS_ROOT/out/m1-checks.XXXXXX")
|
|
trap 'rm -rf -- "$scratch"' EXIT
|
|
build=$(dirname -- "$(realpath out/rootfs-aarch64.tar)")
|
|
(
|
|
cd "$build"
|
|
sha256sum -c rootfs.sha256 packages.sha256
|
|
)
|
|
python3 - "$build" <<'PY'
|
|
import json
|
|
import pathlib
|
|
import struct
|
|
import sys
|
|
import tarfile
|
|
build = pathlib.Path(sys.argv[1])
|
|
metadata = json.loads((build / 'archive-metadata.json').read_text())
|
|
with tarfile.open(build / 'rootfs-aarch64.tar') as archive:
|
|
members = {member.name: member for member in archive}
|
|
assert members['.'].isdir() and (members['.'].uid, members['.'].gid, members['.'].mode) == (0, 0, 0o755), 'Incorrect root directory metadata'
|
|
assert members.keys() == metadata.keys(), 'Archive member mismatch'
|
|
for name, member in members.items():
|
|
assert not name.startswith('/') and '..' not in pathlib.PurePosixPath(name).parts
|
|
assert not (member.ischr() or member.isblk()), name
|
|
if member.isfifo():
|
|
assert name.startswith('etc/s6-linux-init/current/run-image/'), name
|
|
expected = metadata[name]
|
|
assert (member.uid, member.gid, member.mode) == (expected['uid'], expected['gid'], int(expected['mode'], 8)), name
|
|
for name in ('usr/bin/wall', 'usr/bin/write'):
|
|
if name in members:
|
|
assert (members[name].uid, members[name].gid, members[name].mode) == (0, 5, 0o2755), name
|
|
assert (members['usr/lib/utempter/utempter'].uid, members['usr/lib/utempter/utempter'].gid, members['usr/lib/utempter/utempter'].mode) == (0, 14, 0o2711), 'Incorrect utempter group restoration'
|
|
assert members['etc/shadow'].mode == 0o600
|
|
assert members['tmp'].mode == 0o1777
|
|
assert members['root'].mode == 0o700
|
|
assert (members['usr/bin/xbps-uchroot'].uid, members['usr/bin/xbps-uchroot'].gid, members['usr/bin/xbps-uchroot'].mode) == (0, 101, 0o4750)
|
|
assert members['bin'].linkname == 'usr/bin'
|
|
capability = members['usr/bin/iputils-ping'].pax_headers['SCHILY.xattr.security.capability'].encode('utf-8', 'surrogateescape')
|
|
assert len(capability) == 20 and struct.unpack_from('<I', capability)[0] >> 24 == 2
|
|
assert struct.unpack_from('<I', capability, 4)[0] & (1 << 13), 'Missing CAP_NET_RAW'
|
|
print('PASS: archive paths, ownership, modes and portable ping capability')
|
|
PY
|
|
mkdir "$scratch/capability-restore"
|
|
# Exercise GNU tar's actual xattr restoration inside a private user namespace.
|
|
# The resulting v3 xattr is namespaced to this host user, not host root.
|
|
bwrap --unshare-user --uid 0 --gid 0 --cap-add CAP_SETFCAP \
|
|
--ro-bind / / --bind "$scratch/capability-restore" "$scratch/capability-restore" \
|
|
--chdir "$scratch/capability-restore" \
|
|
tar --extract --file "$FDS_ROOT/out/rootfs-aarch64.tar" --numeric-owner \
|
|
--same-permissions --xattrs --xattrs-include=security.capability usr/bin/iputils-ping
|
|
python3 - "$scratch/capability-restore/usr/bin/iputils-ping" <<'PY'
|
|
import os
|
|
import struct
|
|
import sys
|
|
cap = os.getxattr(sys.argv[1], 'security.capability')
|
|
assert len(cap) == 24 and struct.unpack_from('<I', cap)[0] >> 24 == 3
|
|
assert struct.unpack_from('<I', cap, 20)[0] == os.getuid()
|
|
assert struct.unpack_from('<I', cap, 4)[0] & (1 << 13)
|
|
print('PASS: GNU tar restores ping capability in a private user namespace')
|
|
PY
|
|
mkdir "$scratch/root"
|
|
# Extract as the current user, preserving target modes for the audit. Capabilities
|
|
# are verified above; this test does not grant capabilities to host-owned files.
|
|
tar --extract --file out/rootfs-aarch64.tar --directory "$scratch/root" \
|
|
--no-same-owner --same-permissions
|
|
python3 tools/rootfs-audit "$scratch/root"
|
|
tools/rootfs-runtime-check "$scratch/root"
|
|
python3 - "$scratch/root" <<'PY'
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import pathlib
|
|
import plistlib
|
|
import shutil
|
|
import sys
|
|
sys.path.insert(0, 'tools')
|
|
loader = importlib.machinery.SourceFileLoader('audit_module', 'tools/rootfs-audit')
|
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
module = importlib.util.module_from_spec(spec)
|
|
loader.exec_module(module)
|
|
root = pathlib.Path(sys.argv[1])
|
|
database = root / 'var/db/xbps/pkgdb-0.38.plist'
|
|
original = database.read_bytes()
|
|
db = plistlib.loads(original)
|
|
|
|
def reject(label, needle):
|
|
try:
|
|
module.audit(root)
|
|
except AssertionError as error:
|
|
assert needle in str(error), (label, error)
|
|
print('PASS: rejects ' + label)
|
|
else:
|
|
raise AssertionError('Incorrectly accepted: ' + label)
|
|
|
|
db['busybox'] = {'architecture': 'aarch64', 'state': 'installed'}
|
|
database.write_bytes(plistlib.dumps(db))
|
|
reject('forbidden package', 'Forbidden package')
|
|
database.write_bytes(original)
|
|
db = plistlib.loads(original)
|
|
db['glibc']['state'] = 'unpacked'
|
|
database.write_bytes(plistlib.dumps(db))
|
|
reject('unfinished package configuration', 'Unconfigured package')
|
|
database.write_bytes(original)
|
|
db = plistlib.loads(original)
|
|
db['glibc']['architecture'] = 'aarch64-musl'
|
|
database.write_bytes(plistlib.dumps(db))
|
|
reject('wrong package ABI', 'Wrong package ABI')
|
|
database.write_bytes(original)
|
|
binary = root / 'usr/bin/ls'
|
|
backup = binary.read_bytes()
|
|
binary.write_bytes(pathlib.Path('/usr/bin/ls').read_bytes())
|
|
reject('host ELF contamination', 'Non-aarch64 ELF')
|
|
binary.write_bytes(backup)
|
|
marker = root / 'etc/s6-rc/source/boot/contents.d/dasungd'
|
|
marker.unlink()
|
|
reject('missing base Dasung service', 'Dasung missing')
|
|
marker.touch()
|
|
init = root / 'etc/s6-linux-init/current/bin/init'
|
|
original_init = init.read_bytes()
|
|
init.write_text('#!/bin/bash\nexec /usr/bin/s6-svscan /run/service\n')
|
|
reject('shell PID 1 launcher', 'Init is not the native s6 launcher')
|
|
init.write_bytes(original_init)
|
|
boot_member = root / 'etc/s6-rc/source/boot/contents.d/getty'
|
|
boot_member.unlink()
|
|
reject('missing console boot member', 'Missing boot service: getty')
|
|
boot_member.touch()
|
|
PY
|
|
if PROFILE=windowmaker tools/build-rootfs >"$scratch/rejection.log" 2>&1; then
|
|
die 'Unimplemented profile was accepted'
|
|
fi
|
|
grep -q 'Supported profiles: cli, development' "$scratch/rejection.log"
|
|
printf 'PASS: rejects unimplemented image profile\n'
|
|
printf 'NOTE: run make init-test separately for native PID 1 and full ARM boot checks\n'
|
|
printf 'SKIP: physical Pi/monitor tests and boot timings require hardware\n'
|
|
printf 'PASS: exported-rootfs acceptance and failure-path checks\n'
|