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