diff --git a/README.md b/README.md index 2976791..4d0672b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ 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 -i platforms///partitions.conf -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 ``` 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..5c3339b --- /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 reviewed 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" + +# Generated from the selected qcom-ptool partition layouts. New labels are not +# excluded from blkid probing until their patterns have been reviewed. +@QCOM_RAW_PARTITION_RULES@ +GOTO="qcom_raw_noblkid_end" + +LABEL="qcom_raw_noblkid" +ENV{UDEV_DISABLE_PERSISTENT_STORAGE_BLKID_FLAG}="1" + +LABEL="qcom_raw_links" +# 60-persistent-storage.rules normally imports ID_PATH after this rule runs. +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/approved-raw-partition-patterns.list b/qcom_ptool/data/approved-raw-partition-patterns.list new file mode 100644 index 0000000..8c45161 --- /dev/null +++ b/qcom_ptool/data/approved-raw-partition-patterns.list @@ -0,0 +1,67 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +# Partition-name patterns reviewed as safe to exclude from blkid probing. +# Keep one udev glob per line. A new partition label is not implicitly safe. +abl_[ab] +ALIGN_TO_128K_* +aop_[ab] +aop_config_[ab] +apdp +apdp_[ab] +catecontentfv +cdt +cmnlib64_[ab] +cmnlib_[ab] +cpucp_[ab] +ddr +ddr_[ab] +devcfg_[ab] +devinfo +diag_log +dip +dtb_[ab] +emac +featenabler_[ab] +fsc +fsg +gearvm_[ab] +gvm_log +hyp_[ab] +imagefv_[ab] +keymaster_[ab] +limits +limits-cdsp +logdump +modemst1 +modemst2 +multiimgoem +multiimgoem_[ab] +multiimgqti +multiimgqti_[ab] +pvm_log +qmcs +quantumfv +quantumsdk +questdatafv +qupfw_[ab] +qweslicstore_[ab] +recoveryinfo +secdata +shrm_[ab] +softsku +splash +spunvm +storsec +storsec_[ab] +SYSFW_VERSION +toolsfv +tz_[ab] +TZAPPS +uefi_[ab] +uefisecapp_[ab] +vbmeta_[ab] +xbl_[ab] +xbl_config_[ab] +xbl_logs +xbl_ramdump_[ab] diff --git a/qcom_ptool/gen_udev_rules.py b/qcom_ptool/gen_udev_rules.py new file mode 100644 index 0000000..c053a07 --- /dev/null +++ b/qcom_ptool/gen_udev_rules.py @@ -0,0 +1,124 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause + +"""Generate udev rules for reviewed raw partitions in selected layouts.""" + +from __future__ import annotations + +import argparse +import fnmatch +import getopt +import re +import sys +from pathlib import Path + +from qcom_ptool.loaders import load as load_spec + +DATA_DIR = Path(__file__).with_name("data") +POLICY_FILE = DATA_DIR / "approved-raw-partition-patterns.list" +TEMPLATE_FILE = DATA_DIR / "55-qcom-raw-partitions-noblkid.rules.in" +RULES_PLACEHOLDER = "@QCOM_RAW_PARTITION_RULES@" +LABEL_RE = re.compile(r"[A-Za-z0-9_.+-]+") +PATTERN_RE = re.compile(r"[A-Za-z0-9_.+!*?\[\]-]+") + + +def load_patterns() -> list[str]: + """Return non-comment entries from the packaged policy file.""" + patterns: list[str] = [] + seen: set[str] = set() + for line_number, line in enumerate( + POLICY_FILE.read_text(encoding="utf-8").splitlines(), start=1 + ): + pattern = line.partition("#")[0].strip() + if not pattern: + continue + if PATTERN_RE.fullmatch(pattern) is None: + raise ValueError(f"{POLICY_FILE}:{line_number}: invalid pattern: {pattern}") + if pattern in seen: + raise ValueError(f"{POLICY_FILE}:{line_number}: duplicate pattern: {pattern}") + patterns.append(pattern) + seen.add(pattern) + + if not patterns: + raise ValueError(f"approved pattern list is empty: {POLICY_FILE}") + return patterns + + +def collect_labels(input_paths: list[Path]) -> set[str]: + """Return partition labels defined by the selected layouts.""" + labels: set[str] = set() + for path in input_paths: + spec = load_spec(str(path)) + labels.update( + entry["label"] + for entries in spec["partitions"].values() + for entry in entries + ) + return labels + + +def generate_rules(input_paths: list[Path]) -> str: + """Render udev rules for approved labels present in the layouts.""" + patterns = load_patterns() + approved_labels = { + label + for label in collect_labels(input_paths) + if any(fnmatch.fnmatchcase(label, pattern) for pattern in patterns) + } + invalid_labels = sorted( + label for label in approved_labels if LABEL_RE.fullmatch(label) is None + ) + if invalid_labels: + raise ValueError(f"invalid approved partition label: {invalid_labels[0]!r}") + + labels = sorted(approved_labels) + if not labels: + return "" + + rules = "\n".join( + f'ENV{{PARTNAME}}=="{label}", GOTO="qcom_raw_noblkid"' + for label in labels + ) + 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 parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-i", + "--input", + action="append", + required=True, + type=Path, + dest="inputs", + help="partition layout to include; repeat for additional storage", + ) + 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.inputs) + if not content: + print( + "skipped: selected layouts contain no approved raw partitions; " + "output left unchanged" + ) + return 0 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(content, encoding="utf-8") + except (OSError, ValueError, getopt.GetoptError) 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..261b823 --- /dev/null +++ b/tests/unit/test_gen_udev_rules.py @@ -0,0 +1,123 @@ +# 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 + + +def write_layout(path: Path, labels: list[str]) -> None: + partitions = "".join( + f"--partition --name={label} --size=1KB " + "--type-guid=00000000-0000-0000-0000-000000000000\n" + for label in labels + ) + path.write_text( + "--disk --type=ufs --size=1048576\n" + partitions, + encoding="utf-8", + ) + + +def test_generate_rules_filters_and_combines_layouts(tmp_path: Path) -> None: + first = tmp_path / "first.conf" + second = tmp_path / "second.conf" + write_layout(first, ["rootfs", "xbl_a"]) + write_layout(second, ["xbl_a", "ALIGN_TO_128K_7"]) + + rules = gen_udev_rules.generate_rules([first, second]) + + assert 'ENV{PARTNAME}=="xbl_a"' in rules + assert 'ENV{PARTNAME}=="ALIGN_TO_128K_7"' in rules + assert rules.count('ENV{PARTNAME}=="xbl_a"') == 1 + assert "ALIGN_TO_128K_*" not in rules + assert "rootfs" not in rules + + +def test_generate_rules_supports_negated_character_class( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + policy = tmp_path / "patterns.list" + policy.write_text("xbl_[!b]\n", encoding="utf-8") + monkeypatch.setattr(gen_udev_rules, "POLICY_FILE", policy) + monkeypatch.setattr(gen_udev_rules, "collect_labels", lambda _: {"xbl_a", "xbl_b"}) + + rules = gen_udev_rules.generate_rules([]) + + assert 'ENV{PARTNAME}=="xbl_a"' in rules + assert 'ENV{PARTNAME}=="xbl_b"' not in rules + + +def test_generate_rules_rejects_unsafe_approved_label( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + gen_udev_rules, + "collect_labels", + lambda _: {'ALIGN_TO_128K_", RUN+="/bin/true'}, + ) + + with pytest.raises(ValueError, match="invalid approved partition label"): + gen_udev_rules.generate_rules([]) + + +def test_load_patterns_rejects_unsafe_pattern( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + policy = tmp_path / "patterns.list" + policy.write_text('xbl_[ab]\nxbl_", RUN+="/bin/true\n', encoding="utf-8") + monkeypatch.setattr(gen_udev_rules, "POLICY_FILE", policy) + + with pytest.raises(ValueError, match="invalid pattern") as error: + gen_udev_rules.load_patterns() + + assert f"{policy}:2:" in str(error.value) + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ("xbl_a\nxbl_a\n", "duplicate pattern"), + ("# comments only\n", "approved pattern list is empty"), + ], +) +def test_load_patterns_rejects_invalid_policy( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + content: str, + message: str, +) -> None: + policy = tmp_path / "patterns.list" + policy.write_text(content, encoding="utf-8") + monkeypatch.setattr(gen_udev_rules, "POLICY_FILE", policy) + + with pytest.raises(ValueError, match=message): + gen_udev_rules.load_patterns() + + +def test_main_writes_rules(tmp_path: Path) -> None: + layout = tmp_path / "partitions.conf" + output = tmp_path / "rules.d" / "55-qcom.rules" + write_layout(layout, ["cdt"]) + + 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_leaves_output_unchanged_without_approved_labels( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + layout = tmp_path / "partitions.conf" + output = tmp_path / "rules.d" / "55-qcom.rules" + write_layout(layout, ["rootfs"]) + output.parent.mkdir(parents=True) + output.write_text("unrelated rules", encoding="utf-8") + + assert gen_udev_rules.main(["-i", str(layout), "-o", str(output)]) == 0 + assert output.read_text(encoding="utf-8") == "unrelated rules" + message = capsys.readouterr().out + assert "no approved raw partitions" in message + assert "output left unchanged" in message