diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e808767..f49d8ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,10 @@ Here are a few things you can do that will increase the likelihood of your pull - Follow the existing style where possible. - Write tests. +- Keep the partition source formats in sync: when adding an option to + the legacy `.conf` loader, add the matching typed field to the YAML + schemas (`qcom_ptool/schema/`) and the YAML loader in the same pull + request, with a test pinning that both formats load identically. - Keep your change as focused as possible. If you want to make multiple independent changes, please consider submitting them as separate pull requests. - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). diff --git a/qcom_ptool/loaders/yaml.py b/qcom_ptool/loaders/yaml.py index 13d00f3..816cc08 100644 --- a/qcom_ptool/loaders/yaml.py +++ b/qcom_ptool/loaders/yaml.py @@ -105,6 +105,23 @@ def _partition_from_node( attribute_bits = int(str(node["attributes"]), 16) entry["bootable"] = "true" if attribute_bits & (1 << 2) else "false" entry["readonly"] = "true" if attribute_bits & (1 << 60) else "false" + # Named fields mirror the .conf options; applied after attributes so they win. + if "bootable" in node: + entry["bootable"] = _bool_str(node["bootable"]) + if "readonly" in node: + entry["readonly"] = _bool_str(node["readonly"]) + if "priority" in node: + entry["priority"] = str(int(node["priority"]) & 0x03) + if "tries-remaining" in node: + entry["triesremaining"] = str(int(node["tries-remaining"]) & 0x07) + if "active" in node: + entry["active"] = _bool_str(node["active"]) + if "successful" in node: + entry["successful"] = _bool_str(node["successful"]) + if "unbootable" in node: + entry["unbootable"] = _bool_str(node["unbootable"]) + if "unique-guid" in node: + entry["uniqueguid"] = str(node["unique-guid"]) if "filename" in node: entry["filename"] = str(node["filename"]) if "sparse" in node: diff --git a/qcom_ptool/schema/board.schema.json b/qcom_ptool/schema/board.schema.json index 9217434..475ea07 100644 --- a/qcom_ptool/schema/board.schema.json +++ b/qcom_ptool/schema/board.schema.json @@ -56,6 +56,14 @@ "type-guid": {"type": "string", "pattern": "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"}, "filename": {"type": "string"}, "attributes": {"type": "string", "pattern": "^[0-9A-Fa-f]+$"}, + "bootable": {"type": "boolean"}, + "readonly": {"type": "boolean"}, + "priority": {"type": "integer", "minimum": 0, "maximum": 3}, + "tries-remaining": {"type": "integer", "minimum": 0, "maximum": 7}, + "active": {"type": "boolean"}, + "successful": {"type": "boolean"}, + "unbootable": {"type": "boolean"}, + "unique-guid": {"type": "string", "pattern": "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"}, "sparse": {"type": "boolean"}, "lun": {"type": "integer", "minimum": 0}, "phys-part": {"type": "integer", "minimum": 0} diff --git a/qcom_ptool/schema/include.schema.json b/qcom_ptool/schema/include.schema.json index 904e26e..9a058cd 100644 --- a/qcom_ptool/schema/include.schema.json +++ b/qcom_ptool/schema/include.schema.json @@ -22,6 +22,14 @@ "type-guid": {"type": "string", "pattern": "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"}, "filename": {"type": "string"}, "attributes": {"type": "string", "pattern": "^[0-9A-Fa-f]+$"}, + "bootable": {"type": "boolean"}, + "readonly": {"type": "boolean"}, + "priority": {"type": "integer", "minimum": 0, "maximum": 3}, + "tries-remaining": {"type": "integer", "minimum": 0, "maximum": 7}, + "active": {"type": "boolean"}, + "successful": {"type": "boolean"}, + "unbootable": {"type": "boolean"}, + "unique-guid": {"type": "string", "pattern": "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"}, "sparse": {"type": "boolean"}, "lun": {"type": "integer", "minimum": 0}, "phys-part": {"type": "integer", "minimum": 0} diff --git a/qcom_ptool/schema/partitions.schema.json b/qcom_ptool/schema/partitions.schema.json index 9bed21f..612cd29 100644 --- a/qcom_ptool/schema/partitions.schema.json +++ b/qcom_ptool/schema/partitions.schema.json @@ -68,10 +68,47 @@ "type": "string" }, "attributes": { - "description": "GPT attribute bits as a hexadecimal string.", + "description": "GPT attribute bits as a hexadecimal string. The named fields below override the bits they cover.", "type": "string", "pattern": "^[0-9A-Fa-f]+$" }, + "bootable": { + "description": "Legacy BIOS bootable flag (GPT attribute bit 2).", + "type": "boolean" + }, + "readonly": { + "description": "Read-only flag (GPT attribute bit 60).", + "type": "boolean" + }, + "priority": { + "description": "A/B slot priority (GPT attribute bits 48-49).", + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "tries-remaining": { + "description": "A/B slot retry count (GPT attribute bits 51-53).", + "type": "integer", + "minimum": 0, + "maximum": 7 + }, + "active": { + "description": "A/B slot active flag (GPT attribute bit 50).", + "type": "boolean" + }, + "successful": { + "description": "A/B slot boot-successful flag (GPT attribute bit 54).", + "type": "boolean" + }, + "unbootable": { + "description": "A/B slot unbootable flag (GPT attribute bit 55).", + "type": "boolean" + }, + "unique-guid": { + "description": "Partition unique GUID; must be a quoted string to avoid integer misparsing.", + "type": "string", + "pattern": "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$" + }, "sparse": { "type": "boolean" }, diff --git a/tests/unit/test_loaders_yaml.py b/tests/unit/test_loaders_yaml.py index d459cdf..a410bc1 100644 --- a/tests/unit/test_loaders_yaml.py +++ b/tests/unit/test_loaders_yaml.py @@ -201,3 +201,141 @@ def test_schema_rejects_invalid(tmp_path, text): def test_top_level_must_be_mapping(tmp_path): with pytest.raises(yaml_loader.YamlParseError): load(_write(tmp_path, "list.yaml", "- 1\n- 2\n")) + + +# --------------------------------------------------------------------------- +# Named GPT attribute fields (conf parity) +# --------------------------------------------------------------------------- + +_GUID = "DEA0BA2C-CBDD-4805-B4F9-F428251C3E98" +_UGUID = "11111111-2222-3333-4444-555555555555" + + +def _conf_with(tmp_path, extra: str) -> str: + return _write( + tmp_path, + "p.conf", + "--disk --type=nvme --size=68719476736\n" + "--partition --name=boot_a --size=3584KB --type-guid=%s %s\n" + % (_GUID, extra), + ) + + +def _yaml_with(tmp_path, extra: str) -> str: + return _write( + tmp_path, + "p.yaml", + _VALID_DISK + + "partitions:\n" + + " - name: boot_a\n" + + ' size: "3584KB"\n' + + ' type-guid: "%s"\n' % _GUID + + " %s\n" % extra, + ) + + +@pytest.mark.parametrize( + ("yaml_field", "conf_opt", "entry_key", "expected"), + [ + ("bootable: true", "--bootable=yes", "bootable", "true"), + ("readonly: false", "--readonly=no", "readonly", "false"), + ("priority: 2", "--priority=2", "priority", "2"), + ("tries-remaining: 6", "--tries-remaining=6", "triesremaining", "6"), + ("active: true", "--active=1", "active", "true"), + ("successful: false", "--successful=0", "successful", "false"), + ("unbootable: true", "--unbootable=true", "unbootable", "true"), + ('unique-guid: "%s"' % _UGUID, "--uniqueguid=%s" % _UGUID, "uniqueguid", _UGUID), + ("sparse: true", "--sparse=yes", "sparse", "true"), + ], +) +def test_named_field_matches_conf(tmp_path, yaml_field, conf_opt, entry_key, expected): + """Each named field loads to the exact entry the .conf option produces.""" + conf_spec = load(_conf_with(tmp_path, conf_opt)) + yaml_spec = load(_yaml_with(tmp_path, yaml_field)) + assert yaml_spec == conf_spec + assert yaml_spec["partitions"]["0"][0][entry_key] == expected + + +def test_named_fields_override_attributes(tmp_path): + """A named field wins over the raw attributes hex, as in the .conf loader.""" + # attributes sets bootable (bit 2) and readonly (bit 60); named fields + # invert both. + conf_spec = load( + _conf_with( + tmp_path, "--attributes=1000000000000004 --bootable=no --readonly=no" + ) + ) + yaml_spec = load( + _write( + tmp_path, + "o.yaml", + _VALID_DISK + + "partitions:\n" + + " - name: boot_a\n" + + ' size: "3584KB"\n' + + ' type-guid: "%s"\n' % _GUID + + ' attributes: "1000000000000004"\n' + + " bootable: false\n" + + " readonly: false\n", + ) + ) + assert yaml_spec == conf_spec + entry = yaml_spec["partitions"]["0"][0] + assert entry["bootable"] == "false" + assert entry["readonly"] == "false" + + +def test_named_fields_emit_byte_identical_xml(tmp_path): + """All named fields together still emit byte-identical XML per format.""" + extra_conf = ( + "--bootable=yes --readonly=no --priority=2 --tries-remaining=6 " + "--active=yes --successful=no --unbootable=no " + "--uniqueguid=%s --sparse=true" % _UGUID + ) + extra_yaml = ( + " bootable: true\n" + " readonly: false\n" + " priority: 2\n" + " tries-remaining: 6\n" + " active: true\n" + " successful: false\n" + " unbootable: false\n" + ' unique-guid: "%s"\n' + " sparse: true\n" % _UGUID + ) + conf_spec = load(_conf_with(tmp_path, extra_conf)) + yaml_spec = load( + _write( + tmp_path, + "full.yaml", + _VALID_DISK + + "partitions:\n" + + " - name: boot_a\n" + + ' size: "3584KB"\n' + + ' type-guid: "%s"\n' % _GUID + + extra_yaml, + ) + ) + conf_xml = tmp_path / "from_conf.xml" + yaml_xml = tmp_path / "from_yaml.xml" + generate_partition_xml(conf_spec["disk"], conf_spec["partitions"], str(conf_xml)) + generate_partition_xml(yaml_spec["disk"], yaml_spec["partitions"], str(yaml_xml)) + assert yaml_xml.read_bytes() == conf_xml.read_bytes() + + +@pytest.mark.parametrize( + "text", + [ + # priority beyond the 2-bit field. + _partition(priority="4"), + # tries-remaining beyond the 3-bit field. + _partition(**{"tries-remaining": "8"}), + # named flags must be booleans, not strings. + _partition(active='"yes"'), + # malformed unique GUID. + _partition(**{"unique-guid": '"not-a-guid"'}), + ], +) +def test_schema_rejects_invalid_named_fields(tmp_path, text): + with pytest.raises(yaml_loader.YamlParseError): + load(_write(tmp_path, "bad.yaml", text))