Skip to content

Commit 096813c

Browse files
committed
tighten metadata and plugin map parsing
1 parent f0a0a55 commit 096813c

5 files changed

Lines changed: 156 additions & 43 deletions

File tree

scripts/validate_plugins/detect_changed_plugins.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,18 @@
88
import sys
99
from pathlib import Path
1010

11+
if __package__ in {None, ""}:
12+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
13+
14+
from scripts.validate_plugins.plugins_map import load_plugins_map_text
15+
1116

1217
DEFAULT_ASTRBOT_REF = "master"
1318
ASTRBOT_REMOTE_URL = "https://github.com/AstrBotDevs/AstrBot"
1419

1520

1621
def load_plugins_map(text: str, *, source_name: str) -> dict[str, dict]:
17-
try:
18-
data = json.loads(text)
19-
except json.JSONDecodeError as exc:
20-
raise ValueError(f"plugins.json is invalid on the {source_name}: {exc}") from exc
21-
22-
if not isinstance(data, dict):
23-
raise ValueError("plugins.json must contain a JSON object")
24-
return data
22+
return load_plugins_map_text(text, source_name=source_name)
2523

2624

2725
def detect_changed_plugin_names(*, base: dict[str, dict], head: dict[str, dict]) -> list[str]:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
7+
def validate_plugins_map(data: object, *, source_name: str) -> dict[str, dict]:
8+
if not isinstance(data, dict):
9+
raise ValueError("plugins.json must contain a JSON object")
10+
11+
for name, payload in data.items():
12+
if not isinstance(name, str):
13+
raise ValueError(
14+
f"plugins.json on the {source_name} has a non-string key: {name!r}"
15+
)
16+
if not isinstance(payload, dict):
17+
raise ValueError(
18+
f"plugins.json entry {name!r} on the {source_name} must be a JSON object"
19+
)
20+
21+
return data
22+
23+
24+
def load_plugins_map_text(text: str, *, source_name: str) -> dict[str, dict]:
25+
try:
26+
data = json.loads(text)
27+
except json.JSONDecodeError as exc:
28+
raise ValueError(f"plugins.json is invalid on the {source_name}: {exc}") from exc
29+
30+
return validate_plugins_map(data, source_name=source_name)
31+
32+
33+
def load_plugins_map_file(path: Path, *, source_name: str) -> dict[str, dict]:
34+
return load_plugins_map_text(path.read_text(encoding="utf-8"), source_name=source_name)

scripts/validate_plugins/run.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
from pathlib import Path
1717
from urllib.parse import urlparse
1818

19+
if __package__ in {None, ""}:
20+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
21+
22+
from scripts.validate_plugins.plugins_map import load_plugins_map_file
23+
1924
try:
2025
import yaml
2126
except ImportError: # pragma: no cover - optional in local unit tests
@@ -98,6 +103,15 @@ def select_plugins(
98103

99104

100105
def _parse_simple_yaml(path: Path) -> dict:
106+
"""Very small YAML subset parser used as a fallback when PyYAML is unavailable.
107+
108+
Supported format:
109+
- Flat mapping of `key: value` pairs
110+
- No indentation (no nested objects or multiline continuations)
111+
- No lists (`- item` syntax)
112+
- `#` starts a comment when preceded by whitespace (or at line start)
113+
"""
114+
101115
def parse_value(raw_value: str) -> str:
102116
value = raw_value.strip()
103117
if not value:
@@ -112,13 +126,36 @@ def parse_value(raw_value: str) -> str:
112126
value = re.split(r"\s+#", value, maxsplit=1)[0].rstrip()
113127
return value.strip("\"'")
114128

115-
result = {}
116-
for raw_line in path.read_text(encoding="utf-8").splitlines():
117-
line = raw_line.strip()
118-
if not line or line.startswith("#") or ":" not in line:
129+
result: dict[str, str] = {}
130+
for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
131+
stripped = raw_line.strip()
132+
if not stripped:
133+
continue
134+
if stripped.startswith("#"):
119135
continue
136+
if raw_line[0].isspace():
137+
raise ValueError(
138+
f"Unsupported YAML indentation in {path} at line {lineno}: {raw_line!r}"
139+
)
140+
141+
line = stripped
142+
if line.startswith("-"):
143+
raise ValueError(
144+
f"Unsupported YAML list syntax in {path} at line {lineno}: {raw_line!r}"
145+
)
146+
if ":" not in line:
147+
raise ValueError(
148+
f"Unsupported YAML content (expected 'key: value') in {path} at line {lineno}: {raw_line!r}"
149+
)
150+
120151
key, value = line.split(":", 1)
121-
result[key.strip()] = parse_value(value)
152+
key = key.strip()
153+
if not key:
154+
raise ValueError(f"Empty key is not allowed in {path} at line {lineno}: {raw_line!r}")
155+
if key in result:
156+
raise ValueError(f"Duplicate key '{key}' in {path} at line {lineno}")
157+
158+
result[key] = parse_value(value)
122159
return result
123160

124161

@@ -139,7 +176,11 @@ def load_metadata(path: Path) -> dict:
139176
if not isinstance(loaded, dict):
140177
raise MetadataLoadError("metadata.yaml must contain a mapping at the top level")
141178
return loaded
142-
return _parse_simple_yaml(path)
179+
180+
try:
181+
return _parse_simple_yaml(path)
182+
except ValueError as exc:
183+
raise MetadataLoadError(str(exc)) from exc
143184

144185

145186
def precheck_plugin_directory(plugin_dir: Path) -> dict:
@@ -232,20 +273,7 @@ def build_report(results: list[dict]) -> dict:
232273

233274

234275
def load_plugins_index(path: Path) -> dict[str, dict]:
235-
data = json.loads(path.read_text(encoding="utf-8"))
236-
if not isinstance(data, dict):
237-
raise ValueError("plugins.json must contain a JSON object")
238-
239-
for key, value in data.items():
240-
if not isinstance(key, str):
241-
raise ValueError(
242-
f"plugins.json keys must be strings, got {type(key).__name__!r}: {key!r}"
243-
)
244-
if not isinstance(value, dict):
245-
raise ValueError(
246-
f"plugins.json values must be objects/dicts; key {key!r} has {type(value).__name__!r}"
247-
)
248-
return data
276+
return load_plugins_map_file(path, source_name="plugins.json")
249277

250278

251279
def combine_requested_names(

tests/test_detect_changed_plugins.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ def test_load_plugins_map_returns_dict_for_valid_json(self):
3636

3737
self.assertEqual(plugins, {"plugin-a": {"repo": "https://github.com/example/a"}})
3838

39+
def test_load_plugins_map_rejects_non_dict_entries(self):
40+
module = load_detection_module()
41+
42+
with self.assertRaisesRegex(ValueError, "plugins.json entry 'plugin-a' on the PR head must be a JSON object"):
43+
module.load_plugins_map('{"plugin-a": "bad"}', source_name="PR head")
44+
3945

4046
class ChangedPluginDetectionTests(unittest.TestCase):
4147
def test_detect_changed_plugin_names_returns_only_modified_entries(self):

tests/test_validate_plugins.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,11 @@ def test_parse_simple_yaml_handles_comments_quotes_and_whitespace(self):
203203

204204
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
205205
handle.write(
206-
"""
207-
# leading comment
208-
209-
key1: value1 # trailing comment
210-
key2: \" spaced value \"
211-
key3: 'another value'
212-
key4: value-with-#-hash
213-
"""
206+
"# leading comment\n\n"
207+
"key1: value1 # trailing comment\n"
208+
'key2: " spaced value "\n'
209+
"key3: 'another value'\n"
210+
"key4: value-with-#-hash\n"
214211
)
215212
metadata_path = Path(handle.name)
216213

@@ -224,6 +221,45 @@ def test_parse_simple_yaml_handles_comments_quotes_and_whitespace(self):
224221
self.assertEqual(parsed["key3"], "another value")
225222
self.assertEqual(parsed["key4"], "value-with-#-hash")
226223

224+
def test_parse_simple_yaml_rejects_indented_lines(self):
225+
module = load_validator_module()
226+
227+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
228+
handle.write("name: demo\n nested: nope\n")
229+
metadata_path = Path(handle.name)
230+
231+
try:
232+
with self.assertRaisesRegex(ValueError, "Unsupported YAML indentation"):
233+
module._parse_simple_yaml(metadata_path)
234+
finally:
235+
os.remove(metadata_path)
236+
237+
def test_parse_simple_yaml_rejects_list_syntax(self):
238+
module = load_validator_module()
239+
240+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
241+
handle.write("- item\n")
242+
metadata_path = Path(handle.name)
243+
244+
try:
245+
with self.assertRaisesRegex(ValueError, "Unsupported YAML list syntax"):
246+
module._parse_simple_yaml(metadata_path)
247+
finally:
248+
os.remove(metadata_path)
249+
250+
def test_parse_simple_yaml_rejects_duplicate_keys(self):
251+
module = load_validator_module()
252+
253+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
254+
handle.write("name: first\nname: second\n")
255+
metadata_path = Path(handle.name)
256+
257+
try:
258+
with self.assertRaisesRegex(ValueError, "Duplicate key 'name'"):
259+
module._parse_simple_yaml(metadata_path)
260+
finally:
261+
os.remove(metadata_path)
262+
227263
def test_load_metadata_uses_yaml_safe_load_when_available(self):
228264
module = load_validator_module()
229265

@@ -268,12 +304,7 @@ def test_load_metadata_uses_simple_parser_when_yaml_unavailable(self):
268304
module = load_validator_module()
269305

270306
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
271-
handle.write(
272-
"""
273-
name: demo-plugin
274-
version: \"0.2.3\"
275-
"""
276-
)
307+
handle.write('name: demo-plugin\nversion: "0.2.3"\n')
277308
metadata_path = Path(handle.name)
278309

279310
yaml_backup = getattr(module, "yaml", None)
@@ -287,6 +318,22 @@ def test_load_metadata_uses_simple_parser_when_yaml_unavailable(self):
287318
self.assertEqual(metadata.get("name"), "demo-plugin")
288319
self.assertEqual(metadata.get("version"), "0.2.3")
289320

321+
def test_load_metadata_wraps_fallback_parse_errors(self):
322+
module = load_validator_module()
323+
324+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
325+
handle.write("name: demo\n nested: nope\n")
326+
metadata_path = Path(handle.name)
327+
328+
yaml_backup = getattr(module, "yaml", None)
329+
try:
330+
module.yaml = None
331+
with self.assertRaisesRegex(module.MetadataLoadError, "Unsupported YAML indentation"):
332+
module.load_metadata(metadata_path)
333+
finally:
334+
module.yaml = yaml_backup
335+
os.remove(metadata_path)
336+
290337
def test_load_plugins_index_accepts_valid_object(self):
291338
module = load_validator_module()
292339

@@ -332,7 +379,7 @@ def test_load_plugins_index_rejects_non_dict_values(self):
332379
index_path = Path(handle.name)
333380

334381
try:
335-
with self.assertRaisesRegex(ValueError, "plugins.json values must be objects/dicts"):
382+
with self.assertRaisesRegex(ValueError, "plugins.json entry 'not-a-dict'.*must be a JSON object"):
336383
module.load_plugins_index(index_path)
337384
finally:
338385
os.remove(index_path)

0 commit comments

Comments
 (0)