mirror of
https://github.com/DragonOS-Community/DragonOS.git
synced 2026-09-08 23:57:59 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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;\
|
||||
|
||||
@@ -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" }
|
||||
Reference in New Issue
Block a user