feat(rootfs): deterministic sysconfig payload for disk images (#2260)

- Introduce tools/build_sysconfig_payload.py, which renders a deterministic bin/sysconfig.tar from user/sysconfig-metadata.toml with numeric owner/group, explicit modes and a fixed mtime, so the payload no longer leaks host UID/GID/umask into the image.
- Replace the copy_sysconfig step (cp -r of the sysconfig tree into bin/sysroot) with prepare_sysconfig_payload, and bump the sysroot layout version so stale files left by the old copy step are never imported again as app files.
- Import the payload last in tools/write_disk_image.sh, extract with --numeric-owner/--preserve-permissions, then audit every member's type/uid/gid/mode against the tar headers; delete the image and rebuild when the payload schema version changes.
- Add a CI job that runs the payload generator's unit tests.

Signed-off-by: longjin <longjin@DragonOS.org>
This commit is contained in:
LoGin
2026-09-07 15:57:22 +08:00
committed by GitHub
parent d53fdc91a7
commit 3ce437ee2d
7 changed files with 775 additions and 12 deletions
+8
View File
@@ -24,6 +24,14 @@ jobs:
kernel:
- 'kernel/**'
sysconfig-payload-test:
name: Sysconfig payload generator test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run sysconfig payload generator unit tests
run: python3 tools/test_build_sysconfig_payload.py
format-check:
name: Format check ${{ matrix.arch }}
runs-on: ubuntu-latest
+8 -2
View File
@@ -169,8 +169,14 @@ rootfs: check_nix
@echo "To change building image type, change the 'rootfsType' to 'vfat' in flake.nix"
nix run .#rootfs-x86_64
# Build the deterministic sysconfig payload (bin/sysconfig.tar);
# it must be up to date before writing the disk image.
.PHONY: prepare_sysconfig_payload
prepare_sysconfig_payload:
$(MAKE) -C ./user prepare_sysconfig_payload
# 写入磁盘镜像
write_diskimage: prepare_rootfs_manifest
write_diskimage: prepare_rootfs_manifest prepare_sysconfig_payload
ifeq ($(IN_NIX_ENV),1)
@echo "⚠️ 警告:Nix 环境中使用 'make write_diskimage' 已被弃用"
@echo " 请使用: nix run .#rootfs-$(ARCH)"
@@ -183,7 +189,7 @@ else
endif
# 写入磁盘镜像(uefi)
write_diskimage-uefi: prepare_rootfs_manifest
write_diskimage-uefi: prepare_rootfs_manifest prepare_sysconfig_payload
bash -c "export ARCH=$(ARCH); export ROOTFS_MANIFEST=$(ROOTFS_MANIFEST); cd tools && $(GRUB_PREPARE_CMD) && sudo DADK=$(DADK) $(GRUB_SKIP_ENV) ARCH=$(ARCH) ROOTFS_MANIFEST=$(ROOTFS_MANIFEST) bash $(ROOT_PATH)/tools/write_disk_image.sh --bios=uefi && cd .."
# 不编译,直接启动QEMU
qemu: check_arch
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""Build a deterministic sysconfig payload (bin/sysconfig.tar).
Guarantees:
- tar members carry numeric owner/group (default 0:0) and explicit modes;
- fixed mtime, ordering and tar header fields, so the output is insensitive
to the host UID/GID/umask;
- fail closed on symlinks, special nodes, or executable files that are not
explicitly declared in the metadata spec;
- the source tree is never modified; runs as a regular user.
"""
from __future__ import annotations
import argparse
import io
import os
import re
import stat
import sys
import tarfile
import tempfile
from pathlib import Path
FIXED_MTIME = 0
SPEC_VERSION = 1
# Maximum allowed mode: reject setuid/setgid/sticky bits.
MAX_MODE = 0o777
REPO_ROOT = Path(__file__).resolve().parent.parent
class PayloadError(Exception):
"""Raised on invalid spec or source tree; main exits 1 on this."""
# ---------------------------------------------------------------------------
# Spec parsing
# ---------------------------------------------------------------------------
def _parse_mode(value, where: str) -> int:
if not isinstance(value, str) or not re.fullmatch(r"[0-7]{3,4}", value):
raise PayloadError(f"{where}: mode must be a 3-4 digit octal string, got {value!r}")
mode = int(value, 8)
if mode & ~MAX_MODE:
raise PayloadError(f"{where}: setuid/setgid/sticky bits are not allowed: {value!r}")
return mode
def _validate_rel_path(path: str, where: str) -> None:
if not path or path.startswith("/") or "\\" in path:
raise PayloadError(f"{where}: invalid path {path!r}")
if any(part in ("", ".", "..") for part in path.split("/")):
raise PayloadError(f"{where}: invalid path {path!r}")
def _parse_value_fallback(value: str, lineno: int):
value = value.strip()
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
return value[1:-1]
if re.fullmatch(r"[0-9]+", value):
return int(value)
if value.startswith("{") and value.endswith("}"):
result = {}
inner = value[1:-1].strip()
if inner:
for part in inner.split(","):
m = re.fullmatch(r"\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*", part)
if not m:
raise PayloadError(
f"spec line {lineno}: cannot parse inline table entry {part!r}"
)
result[m.group(1)] = _parse_value_fallback(m.group(2), lineno)
return result
raise PayloadError(f"spec line {lineno}: unsupported value {value!r}")
def _parse_spec_fallback(text: str) -> dict:
"""Strict fallback parser used when tomllib (Python 3.11+) is unavailable.
Only supports the restricted syntax used by the spec file: a top-level
version key, a [defaults] section, and [files] entries of the form
"path" = { mode = "0755" }. Anything else is an error, never silently
ignored.
"""
data: dict = {"defaults": {}, "files": {}}
section = None
for lineno, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip()
if section not in ("defaults", "files"):
raise PayloadError(f"spec line {lineno}: unsupported section [{section}]")
continue
m = re.fullmatch(r'([A-Za-z0-9_-]+|"[^"\n]+")\s*=\s*(.+)', line)
if not m:
raise PayloadError(f"spec line {lineno}: cannot parse {raw!r}")
key, value = m.group(1), m.group(2)
parsed = _parse_value_fallback(value, lineno)
if section is None:
if key != "version" or not isinstance(parsed, int):
raise PayloadError(
f"spec line {lineno}: only 'version = <int>' is allowed at top level"
)
data["version"] = parsed
elif section == "defaults":
if key.startswith('"'):
raise PayloadError(
f"spec line {lineno}: [defaults] keys must not be quoted"
)
data["defaults"][key] = parsed
else: # files
if not key.startswith('"') or not isinstance(parsed, dict):
raise PayloadError(
f'spec line {lineno}: [files] entries must look like '
f'"path" = {{ mode = "0755" }}'
)
data["files"][key[1:-1]] = parsed
return data
def _load_spec_raw(spec_path: Path) -> dict:
text = spec_path.read_text(encoding="utf-8")
try:
import tomllib
except ImportError:
return _parse_spec_fallback(text)
try:
return tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
raise PayloadError(f"invalid TOML in spec file: {exc}") from exc
class Spec:
def __init__(self, uid: int, gid: int, dir_mode: int, file_mode: int,
overrides: dict[str, int]):
self.uid = uid
self.gid = gid
self.dir_mode = dir_mode
self.file_mode = file_mode
self.overrides = overrides
def load_spec(spec_path: Path) -> Spec:
raw = _load_spec_raw(spec_path)
version = raw.get("version")
if version != SPEC_VERSION:
raise PayloadError(f"spec version must be {SPEC_VERSION}, got {version!r}")
defaults = raw.get("defaults") or {}
try:
uid = defaults["uid"]
gid = defaults["gid"]
dir_mode = _parse_mode(defaults["directory-mode"], "defaults.directory-mode")
file_mode = _parse_mode(defaults["regular-file-mode"], "defaults.regular-file-mode")
except KeyError as exc:
raise PayloadError(f"spec [defaults] is missing key: {exc}") from exc
if not isinstance(uid, int) or not isinstance(gid, int) or uid < 0 or gid < 0:
raise PayloadError("spec [defaults] uid/gid must be non-negative integers")
overrides: dict[str, int] = {}
for path, entry in (raw.get("files") or {}).items():
where = f'files."{path}"'
_validate_rel_path(path, where)
if not isinstance(entry, dict) or set(entry) != {"mode"}:
raise PayloadError(f"{where}: only the 'mode' key is supported")
overrides[path] = _parse_mode(entry["mode"], where)
return Spec(uid, gid, dir_mode, file_mode, overrides)
# ---------------------------------------------------------------------------
# Source tree scanning
# ---------------------------------------------------------------------------
def scan_tree(src: Path) -> dict[str, tuple[str, Path, int]]:
"""Return {relative_path: (kind, source_path, st_mode)}.
kind is "dir" or "file". Uses lstat semantics and never follows symlinks;
symlinks and special nodes are rejected immediately.
"""
entries: dict[str, tuple[str, Path, int]] = {}
for root, dir_names, file_names in os.walk(src, followlinks=False):
root_path = Path(root)
for name in dir_names + file_names:
full = root_path / name
rel = full.relative_to(src).as_posix()
_validate_rel_path(rel, "source tree")
st = os.lstat(full)
if stat.S_ISLNK(st.st_mode):
raise PayloadError(f"source tree contains a symlink (not supported): {rel}")
if stat.S_ISDIR(st.st_mode):
entries[rel] = ("dir", full, st.st_mode)
elif stat.S_ISREG(st.st_mode):
entries[rel] = ("file", full, st.st_mode)
else:
raise PayloadError(
f"source tree contains a special node (fifo/socket/device): {rel}"
)
return entries
def resolve_mode(rel: str, kind: str, st_mode: int, spec: Spec) -> int:
if rel in spec.overrides:
return spec.overrides[rel]
if kind == "dir":
return spec.dir_mode
if st_mode & 0o111:
raise PayloadError(
f"executable file is not declared in the metadata spec [files]: {rel}\n"
f'declare its mode in user/sysconfig-metadata.toml (e.g. "0755")'
)
return spec.file_mode
# ---------------------------------------------------------------------------
# Tar generation
# ---------------------------------------------------------------------------
def build_tar_bytes(src: Path, spec: Spec) -> bytes:
entries = scan_tree(src)
for declared in spec.overrides:
if declared not in entries:
raise PayloadError(
f"spec declares a path that does not exist in the source tree: {declared}"
)
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w", format=tarfile.GNU_FORMAT) as tf:
for rel in sorted(entries, key=lambda p: p.encode("utf-8")):
kind, full, st_mode = entries[rel]
info = tarfile.TarInfo(rel)
info.mtime = FIXED_MTIME
info.uid = spec.uid
info.gid = spec.gid
info.uname = ""
info.gname = ""
info.mode = resolve_mode(rel, kind, st_mode, spec)
if kind == "dir":
info.type = tarfile.DIRTYPE
tf.addfile(info)
else:
data = full.read_bytes()
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def write_if_changed(out: Path, data: bytes) -> bool:
"""Skip replacement when content is unchanged, so downstream make
dependencies are not needlessly rebuilt."""
if out.exists() and out.read_bytes() == data:
return False
out.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=out.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, out)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src", type=Path, default=REPO_ROOT / "user/sysconfig",
help="sysconfig source directory")
parser.add_argument("--spec", type=Path,
default=REPO_ROOT / "user/sysconfig-metadata.toml",
help="metadata spec file")
parser.add_argument("--out", type=Path, default=REPO_ROOT / "bin/sysconfig.tar",
help="output tar path")
args = parser.parse_args(argv)
try:
spec = load_spec(args.spec)
data = build_tar_bytes(args.src, spec)
except PayloadError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
except OSError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
changed = write_if_changed(args.out, data)
state = "updated" if changed else "unchanged"
print(f"sysconfig payload: {args.out} ({state}, {len(data)} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Unit tests for tools/build_sysconfig_payload.py.
Run with: python3 tools/test_build_sysconfig_payload.py
Coverage:
- byte-identical output under umask 0002/0022/0077;
- tar members carry numeric 0:0, explicit modes, fixed mtime, byte ordering;
- fail closed on undeclared executables, symlinks, special nodes and
invalid spec paths;
- the fallback parser agrees with tomllib on the real spec file;
- end-to-end build of the real user/sysconfig tree.
"""
from __future__ import annotations
import importlib.util
import io
import os
import stat
import tarfile
import tempfile
import unittest
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOLS_DIR.parent
_spec = importlib.util.spec_from_file_location(
"build_sysconfig_payload", TOOLS_DIR / "build_sysconfig_payload.py"
)
bsp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(bsp)
SPEC_TEXT = """\
version = 1
[defaults]
uid = 0
gid = 0
directory-mode = "0755"
regular-file-mode = "0644"
[files]
"etc/init.d/rcS" = { mode = "0755" }
"etc/shadow" = { mode = "0600" }
"""
def make_tree(root: Path) -> None:
"""Create a minimal sysconfig tree.
File modes are left to the current umask (simulating different host
environments); rcS is created with a 0o777 default mode so it keeps an
executable bit under any common umask.
"""
(root / "etc/init.d").mkdir(parents=True)
(root / "etc/dragonos/network").mkdir(parents=True)
rcS = root / "etc/init.d/rcS"
fd = os.open(rcS, os.O_WRONLY | os.O_CREAT, 0o777)
os.write(fd, b"#!/bin/sh\n")
os.close(fd)
(root / "etc/shadow").write_text("root::0:::::::\n")
(root / "etc/dragonos/network/default.conf").write_text("[network]\n")
def read_members(data: bytes) -> list[tuple]:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:") as tf:
return [
(m.name, m.uid, m.gid, m.mode, m.mtime, m.uname, m.gname, m.isdir())
for m in tf.getmembers()
]
class PayloadBuildTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.tmp = Path(self._tmp.name)
self.src = self.tmp / "sysconfig"
self.src.mkdir()
self.spec_path = self.tmp / "spec.toml"
self.spec_path.write_text(SPEC_TEXT)
def build(self) -> bytes:
spec = bsp.load_spec(self.spec_path)
return bsp.build_tar_bytes(self.src, spec)
def test_umask_independence(self):
outputs = []
for mask in (0o002, 0o022, 0o077):
tree = self.tmp / f"tree-{mask:03o}"
tree.mkdir()
old = os.umask(mask)
try:
make_tree(tree)
finally:
os.umask(old)
outputs.append(bsp.build_tar_bytes(tree, bsp.load_spec(self.spec_path)))
self.assertEqual(outputs[0], outputs[1])
self.assertEqual(outputs[1], outputs[2])
def test_member_metadata(self):
make_tree(self.src)
members = dict((m[0], m) for m in read_members(self.build()))
for name, m in members.items():
self.assertEqual((m[1], m[2]), (0, 0), name) # uid/gid
self.assertEqual(m[4], 0, name) # mtime
self.assertEqual((m[5], m[6]), ("", ""), name) # uname/gname
self.assertEqual(members["etc/init.d/rcS"][3], 0o755)
self.assertEqual(members["etc/shadow"][3], 0o600)
self.assertEqual(members["etc/dragonos/network/default.conf"][3], 0o644)
self.assertEqual(members["etc"][3], 0o755)
self.assertTrue(members["etc"][7]) # is a directory
names = [m[0] for m in read_members(self.build())]
self.assertEqual(names, sorted(names, key=lambda p: p.encode()))
def test_undeclared_executable_fails(self):
make_tree(self.src)
extra = self.src / "etc/init.d/extra.sh"
extra.write_text("#!/bin/sh\n")
extra.chmod(0o755)
with self.assertRaises(bsp.PayloadError):
self.build()
def test_symlink_fails(self):
make_tree(self.src)
os.symlink("shadow", self.src / "etc/shadow.link")
with self.assertRaises(bsp.PayloadError):
self.build()
def test_special_node_fails(self):
make_tree(self.src)
os.mkfifo(self.src / "etc/pipe")
with self.assertRaises(bsp.PayloadError):
self.build()
def test_spec_declares_missing_path_fails(self):
make_tree(self.src)
self.spec_path.write_text(
SPEC_TEXT + '"etc/nonexistent" = { mode = "0644" }\n'
)
with self.assertRaises(bsp.PayloadError):
self.build()
def test_invalid_override_paths_fail(self):
make_tree(self.src)
for bad in ("../x", "/abs", "a//b", "a/./b", "a/../b"):
self.spec_path.write_text(
SPEC_TEXT + f'"{bad}" = {{ mode = "0644" }}\n'
)
with self.assertRaises(bsp.PayloadError, msg=bad):
self.build()
def test_special_mode_bits_rejected(self):
make_tree(self.src)
self.spec_path.write_text(
SPEC_TEXT + '"etc/shadow" = { mode = "4755" }\n'
)
with self.assertRaises(bsp.PayloadError):
self.build()
def test_fallback_parser_matches_tomllib_on_real_spec(self):
try:
import tomllib
except ImportError:
self.skipTest("requires tomllib (Python 3.11+)")
real_spec = (REPO_ROOT / "user/sysconfig-metadata.toml").read_text()
expected = tomllib.loads(real_spec)
actual = bsp._parse_spec_fallback(real_spec)
self.assertEqual(expected["version"], actual["version"])
self.assertEqual(expected["defaults"], actual["defaults"])
self.assertEqual(expected["files"], actual["files"])
def test_real_sysconfig_end_to_end(self):
spec = bsp.load_spec(REPO_ROOT / "user/sysconfig-metadata.toml")
data = bsp.build_tar_bytes(REPO_ROOT / "user/sysconfig", spec)
members = dict((m[0], m) for m in read_members(data))
self.assertEqual(members["etc/init.d/rcS"][3], 0o755)
self.assertEqual(members["usr/sbin/dragon-network"][3], 0o755)
self.assertEqual(members["usr/sbin/dragon-network-boot"][3], 0o755)
self.assertEqual(members["usr/lib/dragon-network/udhcpc.script"][3], 0o755)
self.assertEqual(members["etc/shadow"][3], 0o600)
self.assertEqual(members["etc/gshadow"][3], 0o600)
self.assertEqual(members["etc/resolv.conf"][3], 0o644)
self.assertFalse(members["etc/resolv.conf"][7]) # regular file
for name, m in members.items():
self.assertEqual((m[1], m[2]), (0, 0), name)
def test_write_if_changed_is_idempotent(self):
make_tree(self.src)
out = self.tmp / "out/sysconfig.tar"
data = self.build()
self.assertTrue(bsp.write_if_changed(out, data))
mtime = out.stat().st_mtime_ns
self.assertFalse(bsp.write_if_changed(out, data))
self.assertEqual(out.stat().st_mtime_ns, mtime)
if __name__ == "__main__":
unittest.main(verbosity=2)
+198 -3
View File
@@ -40,6 +40,20 @@ DADK_MANIFEST_ARGS=(-f "${DADK_MANIFEST}")
echo "Using DADK manifest: ${DADK_MANIFEST}"
trap cleanup EXIT
# sysconfig payload, generated by tools/build_sysconfig_payload.py with
# deterministic guest metadata (numeric owner/group and explicit modes).
SYSCONFIG_TAR="${root_folder}/bin/sysconfig.tar"
# Payload schema version. Bump it when the payload layout or import
# semantics change; a mismatch forces a rebuild of stale disk images.
SYSCONFIG_SCHEMA_VERSION="1"
SYSCONFIG_SCHEMA_STATE="${root_folder}/bin/.sysconfig-schema-version"
if [ ! -f "${SYSCONFIG_TAR}" ]; then
echo "Error: missing sysconfig payload: ${SYSCONFIG_TAR}" >&2
echo "Run 'make prepare_sysconfig_payload' at the repo root first." >&2
exit 1
fi
mount_folder=$($DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs show-mountpoint || exit 1)
boot_folder="${mount_folder}/boot"
GRUB_INSTALL_PATH="${boot_folder}/grub"
@@ -95,6 +109,18 @@ if [ "${SKIP_GRUB}" != "1" ] && ([ ${ARCH} == "i386" ] || [ ${ARCH} == "x86_64"
fi
fi
# Force a rebuild of stale images when the sysconfig schema changes, so
# --skip-if-exists cannot reuse an image polluted by host metadata or
# containing payload members that have since been removed.
image_exists=$($DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs check-disk-image-exists 2>/dev/null || true)
if [ "${image_exists}" = "1" ]; then
schema_current="$(cat "${SYSCONFIG_SCHEMA_STATE}" 2>/dev/null || true)"
if [ "${schema_current}" != "${SYSCONFIG_SCHEMA_VERSION}" ]; then
echo "sysconfig schema version changed (current='${schema_current:-none}', expected='${SYSCONFIG_SCHEMA_VERSION}'), removing the old disk image and rebuilding..."
$DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs delete || exit 1
fi
fi
# 判断是否存在硬盘镜像文件,如果不存在,就创建一个
echo "创建硬盘镜像文件..."
$DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs create --skip-if-exists || exit 1
@@ -138,10 +164,10 @@ is_vfat_target() {
[ "$FS_TYPE" = "vfat" ] || [ "$FS_TYPE" = "fat32" ]
}
copy_sysroot_to_vfat() {
copy_tree_to_vfat() {
# vfat 是大小写不敏感文件系统,且不支持符号链接。
# 逐条目复制并做大小写折叠去重,避免 PAM.7.gz / pam.7.gz 这类冲突。
local src_root="${root_folder}/bin/sysroot"
local src_root="$1"
local rel src_path dst_path key
local -A casefold_kept=()
@@ -203,7 +229,7 @@ copy_one_sysroot_entry() {
}
if is_vfat_target; then
copy_sysroot_to_vfat
copy_tree_to_vfat "${root_folder}/bin/sysroot"
else
shopt -s dotglob nullglob
for item in "${root_folder}"/bin/sysroot/*; do
@@ -212,6 +238,171 @@ else
shopt -u dotglob nullglob
fi
# ================= sysconfig payload import and audit =================
# sysconfig must be imported last, after the DADK app layer, and applied
# with the exact metadata recorded in the payload.
validate_sysconfig_tar() {
# The generator already guarantees valid content; this is a defensive
# re-check before importing as root.
python3 - "${SYSCONFIG_TAR}" <<'PYEOF'
import sys
import tarfile
errors = []
with tarfile.open(sys.argv[1], "r:") as tf:
for m in tf:
parts = m.name.split("/")
if m.name.startswith("/") or any(p in ("", ".", "..") for p in parts):
errors.append(f"invalid path: {m.name!r}")
if not (m.isdir() or m.isreg()):
errors.append(f"invalid node type: {m.name!r}")
if errors:
print("\n".join(errors), file=sys.stderr)
sys.exit(1)
PYEOF
}
import_sysconfig_ext4() {
validate_sysconfig_tar || return 1
# Unlink existing non-directory targets first (including symlinks such
# as /etc/resolv.conf in the Ubuntu base image), so tar recreates fresh
# inodes with the numeric owner/group/mode from the tar header instead
# of inheriting ownership, permissions or link semantics from old
# inodes. Directories are kept in place; tar only applies their
# metadata. Note: tar --unlink-first cannot be used here because it
# would also try to unlink non-empty directories.
python3 - "${SYSCONFIG_TAR}" "${mount_folder}" <<'PYEOF' || return 1
import os
import stat as statmod
import sys
import tarfile
tar_path, root = sys.argv[1], sys.argv[2]
with tarfile.open(tar_path, "r:") as tf:
for m in tf:
if m.isdir():
continue
target = os.path.join(root, m.name)
try:
st = os.lstat(target)
except FileNotFoundError:
continue
if statmod.S_ISDIR(st.st_mode):
print(f"target is an existing directory, refusing to "
f"overwrite it with a regular file: {m.name!r}",
file=sys.stderr)
sys.exit(1)
os.unlink(target)
PYEOF
tar --extract --file="${SYSCONFIG_TAR}" -C "${mount_folder}" \
--numeric-owner --preserve-permissions
}
import_sysconfig_vfat() {
# FAT does not store Unix metadata; only project content and
# executability onto it.
validate_sysconfig_tar || return 1
local tmpdir
tmpdir=$(mktemp -d)
tar -xf "${SYSCONFIG_TAR}" -C "${tmpdir}" || { rm -rf "${tmpdir}"; return 1; }
copy_tree_to_vfat "${tmpdir}"
rm -rf "${tmpdir}"
}
audit_sysconfig_ext4() {
# Re-read every payload member from the mounted final inodes and verify
# its type, uid, gid and mode. Expectations come straight from the tar
# header, so there is no second manifest to keep in sync.
local rc=0 name expect actual expect_list
expect_list=$(python3 - "${SYSCONFIG_TAR}" <<'PYEOF'
import stat as st
import sys
import tarfile
with tarfile.open(sys.argv[1], "r:") as tf:
for m in tf:
flag = st.S_IFDIR if m.isdir() else st.S_IFREG
print(f"{m.name}\t{m.uid} {m.gid} {m.mode:o} {flag | m.mode:x}")
PYEOF
) || { echo " audit failed: cannot read payload manifest" >&2; return 1; }
while IFS=$'\t' read -r name expect; do
[ -n "${name}" ] || continue
actual=$(stat -c '%u %g %a %f' "${mount_folder}/${name}" 2>/dev/null || echo "<missing>")
if [ "${actual}" != "${expect}" ]; then
echo " audit failed: /${name}: expected '${expect}', got '${actual}'" >&2
rc=1
fi
done <<< "${expect_list}"
# The root directory is not part of the payload, but it must stay
# root-owned and not writable by group/other.
actual=$(stat -c '%u %g %a %f' "${mount_folder}/" 2>/dev/null || echo "<missing>")
if [ "${actual}" != "0 0 755 41ed" ]; then
echo " audit failed: /: expected '0 0 755 41ed', got '${actual}'" >&2
rc=1
fi
# /etc/resolv.conf must be a regular file, not a symlink
# (fail-closed requirement of dragon-network).
if [ -L "${mount_folder}/etc/resolv.conf" ]; then
echo " audit failed: /etc/resolv.conf is still a symlink" >&2
rc=1
fi
return $rc
}
audit_sysconfig_vfat() {
# FAT: do not assert Unix metadata, only verify that every payload
# member is present.
local rc=0 name member_list
member_list=$(tar -tf "${SYSCONFIG_TAR}") || {
echo " audit failed: cannot read payload manifest" >&2
return 1
}
while IFS= read -r name; do
[ -n "${name}" ] || continue
if [ ! -e "${mount_folder}/${name}" ]; then
echo " audit failed: /${name} is missing" >&2
rc=1
fi
done <<< "${member_list}"
return $rc
}
# Ubuntu sentinel outside the payload: confirms the import did not touch
# ownership of the base image. /home/ubuntu in the ubuntu:24.04 base image
# is owned by uid/gid 1000, which makes it an ideal non-root sentinel.
SENTINEL_PATH="home/ubuntu"
sentinel_before=""
if ! is_vfat_target && [ -e "${mount_folder}/${SENTINEL_PATH}" ]; then
sentinel_before=$(stat -c '%u %g %a %f' "${mount_folder}/${SENTINEL_PATH}")
fi
sysconfig_ok=0
if is_vfat_target; then
if import_sysconfig_vfat && audit_sysconfig_vfat; then
sysconfig_ok=1
fi
else
if import_sysconfig_ext4 && audit_sysconfig_ext4; then
if [ -z "${sentinel_before}" ] || \
[ "$(stat -c '%u %g %a %f' "${mount_folder}/${SENTINEL_PATH}")" = "${sentinel_before}" ]; then
sysconfig_ok=1
else
echo " audit failed: owner/mode of /${SENTINEL_PATH} was modified by the payload import" >&2
fi
fi
fi
if [ "${sysconfig_ok}" != "1" ]; then
echo "Error: sysconfig payload import/audit failed, deleting the partially updated disk image" >&2
$DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs umount || true
ROOTFS_MOUNTED=0
$DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs delete || true
rm -f "${SYSCONFIG_SCHEMA_STATE}"
exit 1
fi
echo "sysconfig payload imported and audited successfully"
ensure_boot_dir() {
# Keep existing /boot when it's a directory or a symlink to a directory.
if [ -d "${mount_folder}/boot" ]; then
@@ -287,3 +478,7 @@ sync
$DADK "${DADK_MANIFEST_ARGS[@]}" -w $root_folder rootfs umount || exit 1
ROOTFS_MOUNTED=0
# Record the schema version only after full success, so the next build can
# decide whether a forced image rebuild is needed.
echo "${SYSCONFIG_SCHEMA_VERSION}" > "${SYSCONFIG_SCHEMA_STATE}"
+18 -7
View File
@@ -10,6 +10,12 @@ DADK_CACHE_DIR = $(ROOT_PATH)/bin/dadk_cache
# 统一使用由 tools/rootfs_manifest_resolve.sh 生成的 manifest
DADK_MANIFEST_FILE ?= $(ROOT_PATH)/dadk-manifest.generated.toml
SYSROOT_INSTALL_MARKER = $(ROOT_PATH)/bin/sysroot/.dadk_install_marker
# Sysroot layout version. Layout 1 is the legacy layout where sysconfig was
# copied into bin/sysroot via cp -r, leaking host metadata into the image.
# Bumping this version triggers a one-time removal and DADK reinstall of
# bin/sysroot (keeping bin/dadk_cache), so stale files left by the old
# copy_sysconfig step are not imported into the image again as app files.
SYSROOT_LAYOUT_VERSION = 2
ROOTFS_MANIFEST ?= default
ECHO:
@@ -44,7 +50,7 @@ endif
dadk_run: install_dadk
@test -f "$(DADK_MANIFEST_FILE)" || (echo "Error: missing $(DADK_MANIFEST_FILE), run 'make prepare_rootfs_manifest' at repo root first." && exit 1)
mkdir -p $(DADK_CACHE_DIR)
@marker_expected="manifest=$(ROOTFS_MANIFEST)"; \
@marker_expected="manifest=$(ROOTFS_MANIFEST);layout=$(SYSROOT_LAYOUT_VERSION)"; \
if [ -f "$(ROOT_PATH)/config/rootfs.generated.toml" ]; then \
rootfs_hash="$$(sha256sum "$(ROOT_PATH)/config/rootfs.generated.toml" | awk '{print $$1}')"; \
marker_expected="$$marker_expected;rootfs=$$rootfs_hash"; \
@@ -69,7 +75,7 @@ dadk_run: install_dadk
fi
$(DADK) -f $(DADK_MANIFEST_FILE) user build -w $(ROOT_PATH)
$(DADK) -f $(DADK_MANIFEST_FILE) user install -w $(ROOT_PATH)
@marker_expected="manifest=$(ROOTFS_MANIFEST)"; \
@marker_expected="manifest=$(ROOTFS_MANIFEST);layout=$(SYSROOT_LAYOUT_VERSION)"; \
if [ -f "$(ROOT_PATH)/config/rootfs.generated.toml" ]; then \
rootfs_hash="$$(sha256sum "$(ROOT_PATH)/config/rootfs.generated.toml" | awk '{print $$1}')"; \
marker_expected="$$marker_expected;rootfs=$$rootfs_hash"; \
@@ -86,20 +92,25 @@ dadk_clean: install_dadk
all:
mkdir -p $(ROOT_PATH)/bin/sysroot
$(MAKE) dadk_run
$(MAKE) copy_sysconfig
$(MAKE) prepare_sysconfig_payload
@echo 用户态程序编译完成
.PHONY: copy_sysconfig
copy_sysconfig:
cp -r sysconfig/* $(ROOT_PATH)/bin/sysroot/
# Build the deterministic sysconfig payload (bin/sysconfig.tar).
# sysconfig no longer goes into bin/sysroot; tools/write_disk_image.sh
# imports it last when writing the disk image. The output is insensitive
# to the host UID/GID/umask.
.PHONY: prepare_sysconfig_payload
prepare_sysconfig_payload:
python3 $(ROOT_PATH)/tools/build_sysconfig_payload.py
.PHONY: clean
clean:
$(MAKE) dadk_clean
rm -f $(ROOT_PATH)/bin/sysconfig.tar
@list='$(user_sub_dirs)'; for subdir in $$list; do \
echo "Clean in dir: $$subdir";\
cd $$subdir && $(MAKE) clean;\
+33
View File
@@ -0,0 +1,33 @@
# Guest metadata spec for the DragonOS sysconfig payload.
#
# tools/build_sysconfig_payload.py generates a deterministic
# bin/sysconfig.tar from this file, without reading the host UID/GID/umask.
#
# Rules:
# - owner/group are always numeric guest IDs;
# - mode must be an octal string (e.g. "0755"); setuid/setgid/sticky bits
# are not allowed;
# - directories and regular files default to directory-mode and
# regular-file-mode respectively;
# - executable files (any execute bit in the source) must be explicitly
# declared in [files] below, otherwise the generator fails closed;
# - only directories and regular files are supported; symlinks and special
# nodes are rejected.
version = 1
[defaults]
uid = 0
gid = 0
directory-mode = "0755"
regular-file-mode = "0644"
[files]
# Startup scripts and commands
"etc/init.d/rcS" = { mode = "0755" }
"usr/lib/dragon-network/udhcpc.script" = { mode = "0755" }
"usr/sbin/dragon-network" = { mode = "0755" }
"usr/sbin/dragon-network-boot" = { mode = "0755" }
# Account secret files
"etc/shadow" = { mode = "0600" }
"etc/gshadow" = { mode = "0600" }