From 573879a61f5061d31570a0729e2964f5b9fa9f12 Mon Sep 17 00:00:00 2001 From: Wenwen Fu Date: Mon, 10 Aug 2026 11:20:32 +0800 Subject: [PATCH] gen_udev_rules: derive raw rules from partition layouts Generate udev rules from the union of all in-tree partition layouts by default, while allowing repeatable -i options for selected layouts. Keep a small reviewed list of filesystem partition names and emit exact PARTNAME matches for the remaining labels. Names outside the supplied layouts are not emitted, so unknown downstream partitions retain normal blkid probing. This keeps the generated rules machine-independent and avoids a runtime catch-all rule. Signed-off-by: Wenwen Fu --- README.md | 7 + pyproject.toml | 1 + qcom_ptool/cli.py | 1 + .../55-qcom-raw-partitions-noblkid.rules.in | 35 +++++ qcom_ptool/data/__init__.py | 2 + .../data/filesystem-partition-names.list | 11 ++ qcom_ptool/gen_udev_rules.py | 126 ++++++++++++++++++ tests/unit/test_gen_udev_rules.py | 115 ++++++++++++++++ 8 files changed, 298 insertions(+) create mode 100644 qcom_ptool/data/55-qcom-raw-partitions-noblkid.rules.in create mode 100644 qcom_ptool/data/__init__.py create mode 100644 qcom_ptool/data/filesystem-partition-names.list create mode 100644 qcom_ptool/gen_udev_rules.py create mode 100644 tests/unit/test_gen_udev_rules.py diff --git a/README.md b/README.md index 2976791..c41689b 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,17 @@ Once installed, the tool is invoked as: ```sh qcom-ptool gen_partition -i platforms///partitions.conf -o partitions.xml qcom-ptool gen_contents -p partitions.xml -t contents.xml.in -o contents.xml +qcom-ptool gen_udev_rules -o 55-qcom-raw-partitions-noblkid.rules qcom-ptool ptool -x partitions.xml qcom-ptool msp -r rawprogram0.xml -d /dev/sdX -p patch0.xml ``` +By default, the generator scans all `platforms/*/*/partitions.conf` files. +Repeatable `-i` options can select specific layouts. It skips known filesystem +partition names and emits exact `PARTNAME` rules for the others. Unknown names +retain normal blkid probing. The rules use +`UDEV_DISABLE_PERSISTENT_STORAGE_BLKID_FLAG` on systemd v252 and newer. + Run `qcom-ptool -h` to see the options accepted by each subcommand. diff --git a/pyproject.toml b/pyproject.toml index de20867..2c2d823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ include = ["qcom_ptool*"] [tool.setuptools.package-data] "qcom_ptool.schema" = ["*.json"] +"qcom_ptool.data" = ["*.in", "*.list"] [tool.ruff] target-version = "py38" diff --git a/qcom_ptool/cli.py b/qcom_ptool/cli.py index bb011d4..3774141 100644 --- a/qcom_ptool/cli.py +++ b/qcom_ptool/cli.py @@ -10,6 +10,7 @@ SUBCOMMANDS = { "gen_partition": "qcom_ptool.gen_partition", "gen_contents": "qcom_ptool.gen_contents", + "gen_udev_rules": "qcom_ptool.gen_udev_rules", "ptool": "qcom_ptool.ptool", "msp": "qcom_ptool.msp", } diff --git a/qcom_ptool/data/55-qcom-raw-partitions-noblkid.rules.in b/qcom_ptool/data/55-qcom-raw-partitions-noblkid.rules.in new file mode 100644 index 0000000..b5e092b --- /dev/null +++ b/qcom_ptool/data/55-qcom-raw-partitions-noblkid.rules.in @@ -0,0 +1,35 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +# Skip filesystem probing for known Qualcomm raw GPT partitions. +# +# Match by GPT partition name instead of kernel disk name. Disk enumeration is +# not stable across all boards, and a disk can contain both raw firmware and +# mountable filesystem partitions. +# +# 60-persistent-storage.rules creates partition metadata links after the blkid +# import. Since this rule skips that import for raw partitions, emit those +# links here from kernel-provided PARTNAME/PARTUUID properties. +ACTION=="remove", GOTO="qcom_raw_noblkid_end" +SUBSYSTEM!="block", GOTO="qcom_raw_noblkid_end" +ENV{DEVTYPE}!="partition", GOTO="qcom_raw_noblkid_end" +ENV{PARTNAME}=="", GOTO="qcom_raw_links" + +# Raw partition name patterns +@QCOM_RAW_PARTITION_RULES@ +GOTO="qcom_raw_noblkid_end" + +LABEL="qcom_raw_noblkid" +# Supported since systemd v252. Older versions probe as usual. +ENV{UDEV_DISABLE_PERSISTENT_STORAGE_BLKID_FLAG}="1" + +LABEL="qcom_raw_links" +# persistent partition links +ENV{ID_PATH}!="?*", IMPORT{parent}="ID_PATH" +ENV{PARTUUID}=="?*", SYMLINK+="disk/by-partuuid/$env{PARTUUID}" +ENV{PARTNAME}=="?*", OPTIONS+="string_escape=replace", SYMLINK+="disk/by-partlabel/$env{PARTNAME}" +ENV{ID_PATH}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-partnum/%n" +ENV{ID_PATH}=="?*", ENV{PARTUUID}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-partuuid/$env{PARTUUID}" +ENV{ID_PATH}=="?*", ENV{PARTNAME}=="?*", OPTIONS+="string_escape=replace", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-partlabel/$env{PARTNAME}" + +LABEL="qcom_raw_noblkid_end" diff --git a/qcom_ptool/data/__init__.py b/qcom_ptool/data/__init__.py new file mode 100644 index 0000000..21749a9 --- /dev/null +++ b/qcom_ptool/data/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause diff --git a/qcom_ptool/data/filesystem-partition-names.list b/qcom_ptool/data/filesystem-partition-names.list new file mode 100644 index 0000000..a15577a --- /dev/null +++ b/qcom_ptool/data/filesystem-partition-names.list @@ -0,0 +1,11 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +# Partition names known to contain filesystems. Raw rules are generated for +# every other partition name found in the supplied in-tree layouts. +efi +logfs +persist +rootfs +userdata +vm-data diff --git a/qcom_ptool/gen_udev_rules.py b/qcom_ptool/gen_udev_rules.py new file mode 100644 index 0000000..b2377db --- /dev/null +++ b/qcom_ptool/gen_udev_rules.py @@ -0,0 +1,126 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +"""Generate udev rules for Qualcomm raw partitions.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from qcom_ptool.loaders import load as load_spec + +DATA_DIR = Path(__file__).with_name("data") +FILESYSTEM_NAMES_FILE = DATA_DIR / "filesystem-partition-names.list" +TEMPLATE_FILE = DATA_DIR / "55-qcom-raw-partitions-noblkid.rules.in" +RULES_PLACEHOLDER = "@QCOM_RAW_PARTITION_RULES@" +NAME_RE = re.compile(r"[A-Za-z0-9_.+-]+") +DEFAULT_LAYOUT_GLOB = "platforms/*/*/partitions.conf" + + +def load_filesystem_names() -> set[str]: + """Load names of partitions that may contain filesystems.""" + names: set[str] = set() + for line_number, line in enumerate( + FILESYSTEM_NAMES_FILE.read_text(encoding="utf-8").splitlines(), start=1 + ): + name = line.partition("#")[0].strip() + if not name: + continue + if NAME_RE.fullmatch(name) is None: + raise ValueError( + f"{FILESYSTEM_NAMES_FILE}:{line_number}: invalid name: {name}" + ) + if name in names: + raise ValueError( + f"{FILESYSTEM_NAMES_FILE}:{line_number}: duplicate name: {name}" + ) + names.add(name) + + if not names: + raise ValueError(f"filesystem name list is empty: {FILESYSTEM_NAMES_FILE}") + return names + + +def load_partition_names(inputs: list[Path]) -> set[str]: + """Load and merge partition names from all supplied layouts.""" + names: set[str] = set() + for path in inputs: + spec = load_spec(str(path)) + for partitions in spec["partitions"].values(): + for partition in partitions: + name = partition["label"] + if name and name != "last_parti": + if NAME_RE.fullmatch(name) is None: + raise ValueError(f"{path}: invalid partition name: {name}") + names.add(name) + return names + + +def load_raw_partition_names(inputs: list[Path]) -> list[str]: + """Return known raw partition names from the supplied layouts.""" + return sorted(load_partition_names(inputs) - load_filesystem_names()) + + +def discover_inputs(inputs: list[Path] | None) -> list[Path]: + """Use explicit layouts or discover all layouts in the current repo.""" + if inputs: + return inputs + + discovered = sorted(Path.cwd().glob(DEFAULT_LAYOUT_GLOB)) + if not discovered: + raise ValueError( + "no partition layouts found; run from the qcom-ptool repository " + "or provide one or more -i/--input paths" + ) + return discovered + + +def render_rules(names: list[str]) -> str: + """Render the udev rules template for the supplied partition names.""" + rules = "\n".join( + f'ENV{{PARTNAME}}=="{name}", GOTO="qcom_raw_noblkid"' + for name in names + ) + template = TEMPLATE_FILE.read_text(encoding="utf-8") + if template.count(RULES_PLACEHOLDER) != 1: + raise ValueError("rules template must contain exactly one placeholder") + return template.replace(RULES_PLACEHOLDER, rules) + + +def generate_rules(inputs: list[Path] | None = None) -> str: + """Render rules for raw partitions in all supplied layouts.""" + return render_rules(load_raw_partition_names(discover_inputs(inputs))) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-i", + "--input", + action="append", + type=Path, + help="partition layout to scan; may be repeated (defaults to platforms/*/*/partitions.conf)", + ) + parser.add_argument("-o", "--output", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + content = generate_rules(args.input) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(content, encoding="utf-8") + except (OSError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + print(f"generated: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_gen_udev_rules.py b/tests/unit/test_gen_udev_rules.py new file mode 100644 index 0000000..a7a71e1 --- /dev/null +++ b/tests/unit/test_gen_udev_rules.py @@ -0,0 +1,115 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from qcom_ptool import gen_udev_rules + + +REPO_ROOT = Path(__file__).resolve().parents[2] +LAYOUT = REPO_ROOT / "platforms" / "qcs6490-rb3gen2" / "ufs" / "partitions.conf" + + +def test_generate_rules_uses_layout_names() -> None: + rules = gen_udev_rules.generate_rules([LAYOUT]) + + assert 'ENV{PARTNAME}=="xbl_a"' in rules + assert 'ENV{PARTNAME}=="cdt"' in rules + assert "rootfs" not in rules + assert 'ENV{PARTNAME}=="not-in-layout"' not in rules + + +def test_generate_rules_merges_layouts_and_deduplicates( + tmp_path: Path, +) -> None: + first = tmp_path / "first.conf" + second = tmp_path / "second.conf" + first.write_text( + "--disk --type=ufs --size=1\n" + "--partition --name=raw_a --size=1KB --type-guid=1\n" + "--partition --name=rootfs --size=1KB --type-guid=2\n", + encoding="utf-8", + ) + second.write_text( + "--disk --type=ufs --size=1\n" + "--partition --name=raw_a --size=1KB --type-guid=1\n" + "--partition --name=raw_b --size=1KB --type-guid=2\n", + encoding="utf-8", + ) + rules = gen_udev_rules.generate_rules([first, second]) + + assert rules.count('ENV{PARTNAME}=="raw_a"') == 1 + assert 'ENV{PARTNAME}=="raw_b"' in rules + assert 'ENV{PARTNAME}=="rootfs"' not in rules + + +def test_generate_rules_preserves_persistent_links() -> None: + rules = gen_udev_rules.generate_rules([LAYOUT]) + + assert 'ENV{PARTNAME}=="", GOTO="qcom_raw_links"' in rules + assert 'ENV{UDEV_DISABLE_PERSISTENT_STORAGE_BLKID_FLAG}="1"' in rules + assert "UDEV_DISABLE_PERSISTENT_STORAGE_RULES_FLAG" not in rules + assert 'SYMLINK+="disk/by-partuuid/$env{PARTUUID}"' in rules + assert 'SYMLINK+="disk/by-partlabel/$env{PARTNAME}"' in rules + + +def test_load_filesystem_names_rejects_unsafe_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + policy = tmp_path / "names.list" + policy.write_text('rootfs\nrootfs", RUN+="/bin/true\n', encoding="utf-8") + monkeypatch.setattr(gen_udev_rules, "FILESYSTEM_NAMES_FILE", policy) + + with pytest.raises(ValueError, match="invalid name") as error: + gen_udev_rules.load_filesystem_names() + + assert f"{policy}:2:" in str(error.value) + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ("xbl_a\nxbl_a\n", "duplicate name"), + ("# comments only\n", "filesystem name list is empty"), + ], +) +def test_load_filesystem_names_rejects_invalid_policy( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + content: str, + message: str, +) -> None: + policy = tmp_path / "names.list" + policy.write_text(content, encoding="utf-8") + monkeypatch.setattr(gen_udev_rules, "FILESYSTEM_NAMES_FILE", policy) + + with pytest.raises(ValueError, match=message): + gen_udev_rules.load_filesystem_names() + + +def test_main_writes_rules(tmp_path: Path) -> None: + output = tmp_path / "rules.d" / "55-qcom.rules" + + assert gen_udev_rules.main(["-i", str(LAYOUT), "-o", str(output)]) == 0 + assert 'ENV{PARTNAME}=="cdt"' in output.read_text(encoding="utf-8") + + +def test_main_discovers_repo_layouts(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(REPO_ROOT) + output = tmp_path / "rules" + + assert gen_udev_rules.main(["-o", str(output)]) == 0 + assert 'ENV{PARTNAME}=="xbl_a"' in output.read_text(encoding="utf-8") + + +def test_generate_rules_rejects_missing_default_layouts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="no partition layouts found"): + gen_udev_rules.generate_rules()