gabriel / muse public
test_core_validation.py python
556 lines 18.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for muse.core.validation — all trust-boundary primitives.
2
3 Every function in the validation module operates on untrusted input and must
4 either return a safe value or raise ValueError / TypeError with a descriptive
5 message. These tests verify correctness of the allow-lists, reject-lists, and
6 edge cases for each guard.
7 """
8
9 from __future__ import annotations
10
11 import math
12 import pathlib
13
14 import pytest
15
16 from muse.core._types import fake_id
17 from muse.core.validation import (
18 MAX_FILE_BYTES,
19 MAX_RESPONSE_BYTES,
20 MAX_SYSEX_BYTES,
21 clamp_int,
22 contain_path,
23 finite_float,
24 sanitize_display,
25 sanitize_glob_prefix,
26 validate_branch_name,
27 validate_domain_name,
28 validate_object_id,
29 validate_ref_id,
30 validate_repo_id,
31 )
32
33
34 # ---------------------------------------------------------------------------
35 # Constants
36 # ---------------------------------------------------------------------------
37
38
39 class TestConstants:
40 def test_max_file_bytes_is_256mb(self) -> None:
41 assert MAX_FILE_BYTES == 256 * 1024 * 1024
42
43 def test_max_response_bytes_is_64mb(self) -> None:
44 assert MAX_RESPONSE_BYTES == 64 * 1024 * 1024
45
46 def test_max_sysex_bytes_is_64kib(self) -> None:
47 assert MAX_SYSEX_BYTES == 65_536
48
49
50 # ---------------------------------------------------------------------------
51 # validate_object_id
52 # ---------------------------------------------------------------------------
53
54
55 class TestValidateObjectId:
56 """validate_object_id must accept valid 64-char hex and reject everything else."""
57
58 def test_valid_all_zeros(self) -> None:
59 oid = fake_id("zeros")
60 assert validate_object_id(oid) == oid
61
62 def test_valid_all_lowercase_hex(self) -> None:
63 oid = fake_id("lowercase")
64 assert validate_object_id(oid) == oid
65
66 def test_valid_mixed_hex(self) -> None:
67 oid = fake_id("mixed")
68 assert validate_object_id(oid) == oid
69
70 def test_returns_same_string(self) -> None:
71 oid = fake_id("identity")
72 result = validate_object_id(oid)
73 assert result is oid # identity, not a copy
74
75 def test_rejects_uppercase(self) -> None:
76 with pytest.raises(ValueError, match="64 lowercase hex"):
77 validate_object_id("sha256:" + "A" * 64)
78
79 def test_rejects_63_chars(self) -> None:
80 with pytest.raises(ValueError):
81 validate_object_id("sha256:" + "a" * 63)
82
83 def test_rejects_65_chars(self) -> None:
84 with pytest.raises(ValueError):
85 validate_object_id("a" * 65)
86
87 def test_rejects_empty_string(self) -> None:
88 with pytest.raises(ValueError):
89 validate_object_id("")
90
91 def test_rejects_non_hex_chars(self) -> None:
92 oid = "g" + "a" * 63 # 'g' is not hex
93 with pytest.raises(ValueError):
94 validate_object_id(oid)
95
96 def test_rejects_path_traversal_string(self) -> None:
97 with pytest.raises(ValueError):
98 validate_object_id("../evil/../path/" + "a" * 48)
99
100 def test_rejects_null_byte_in_id(self) -> None:
101 with pytest.raises(ValueError):
102 validate_object_id("\x00" * 64)
103
104
105
106 # ---------------------------------------------------------------------------
107 # validate_ref_id
108 # ---------------------------------------------------------------------------
109
110
111 class TestValidateRefId:
112 """validate_ref_id is an alias for the same 64-char hex rule."""
113
114 def test_valid_commit_id(self) -> None:
115 rid = fake_id("commit")
116 assert validate_ref_id(rid) == rid
117
118 def test_rejects_short_id(self) -> None:
119 with pytest.raises(ValueError):
120 validate_ref_id("abc123")
121
122 def test_rejects_uppercase(self) -> None:
123 with pytest.raises(ValueError):
124 validate_ref_id("sha256:" + "B" * 64)
125
126 def test_error_message_mentions_ref_id(self) -> None:
127 with pytest.raises(ValueError, match="ref ID"):
128 validate_ref_id("short")
129
130
131 # ---------------------------------------------------------------------------
132 # validate_branch_name
133 # ---------------------------------------------------------------------------
134
135
136 class TestValidateBranchName:
137 """Branch names follow Git conventions — forward slashes allowed,
138 backslashes and null bytes are not."""
139
140 # --- valid names ---
141
142 def test_simple_name(self) -> None:
143 assert validate_branch_name("main") == "main"
144
145 def test_dev_branch(self) -> None:
146 assert validate_branch_name("dev") == "dev"
147
148 def test_feature_slash_style(self) -> None:
149 assert validate_branch_name("feature/my-branch") == "feature/my-branch"
150
151 def test_fix_slash_style(self) -> None:
152 assert validate_branch_name("fix/auth-token-exposure") == "fix/auth-token-exposure"
153
154 def test_nested_path(self) -> None:
155 assert validate_branch_name("feat/v2/core") == "feat/v2/core"
156
157 def test_max_length_255(self) -> None:
158 name = "a" * 255
159 assert validate_branch_name(name) == name
160
161 def test_digits_hyphens_underscores(self) -> None:
162 assert validate_branch_name("branch-123_test") == "branch-123_test"
163
164 # --- rejected names ---
165
166 def test_rejects_empty(self) -> None:
167 with pytest.raises(ValueError, match="must not be empty"):
168 validate_branch_name("")
169
170 def test_rejects_too_long(self) -> None:
171 with pytest.raises(ValueError, match="too long"):
172 validate_branch_name("a" * 256)
173
174 def test_rejects_backslash(self) -> None:
175 with pytest.raises(ValueError, match="forbidden"):
176 validate_branch_name("evil\\branch")
177
178 def test_rejects_null_byte(self) -> None:
179 with pytest.raises(ValueError):
180 validate_branch_name("branch\x00name")
181
182 def test_rejects_carriage_return(self) -> None:
183 with pytest.raises(ValueError):
184 validate_branch_name("branch\rname")
185
186 def test_rejects_linefeed(self) -> None:
187 with pytest.raises(ValueError):
188 validate_branch_name("branch\nname")
189
190 def test_rejects_tab(self) -> None:
191 with pytest.raises(ValueError):
192 validate_branch_name("branch\tname")
193
194 def test_rejects_leading_dot(self) -> None:
195 with pytest.raises(ValueError):
196 validate_branch_name(".hidden")
197
198 def test_rejects_trailing_dot(self) -> None:
199 with pytest.raises(ValueError):
200 validate_branch_name("branch.")
201
202 def test_rejects_consecutive_dots(self) -> None:
203 with pytest.raises(ValueError):
204 validate_branch_name("branch..name")
205
206 def test_rejects_triple_dot(self) -> None:
207 with pytest.raises(ValueError):
208 validate_branch_name("branch...name")
209
210 def test_rejects_consecutive_slashes(self) -> None:
211 with pytest.raises(ValueError):
212 validate_branch_name("feat//branch")
213
214 def test_rejects_leading_slash(self) -> None:
215 with pytest.raises(ValueError):
216 validate_branch_name("/branch")
217
218 def test_rejects_trailing_slash(self) -> None:
219 with pytest.raises(ValueError):
220 validate_branch_name("branch/")
221
222
223
224 # ---------------------------------------------------------------------------
225 # validate_repo_id
226 # ---------------------------------------------------------------------------
227
228
229 class TestValidateRepoId:
230 def test_valid_uuid_style(self) -> None:
231 rid = "abc123-def456-ghi789"
232 assert validate_repo_id(rid) == rid
233
234 def test_valid_simple_id(self) -> None:
235 assert validate_repo_id("myrepo") == "myrepo"
236
237 def test_rejects_empty(self) -> None:
238 with pytest.raises(ValueError, match="must not be empty"):
239 validate_repo_id("")
240
241 def test_rejects_too_long(self) -> None:
242 with pytest.raises(ValueError, match="too long"):
243 validate_repo_id("x" * 256)
244
245 def test_rejects_dotdot_component(self) -> None:
246 with pytest.raises(ValueError):
247 validate_repo_id("repo..evil")
248
249 def test_rejects_null_byte(self) -> None:
250 with pytest.raises(ValueError):
251 validate_repo_id("repo\x00id")
252
253
254
255 # ---------------------------------------------------------------------------
256 # validate_domain_name
257 # ---------------------------------------------------------------------------
258
259
260 class TestValidateDomainName:
261 def test_midi(self) -> None:
262 assert validate_domain_name("midi") == "midi"
263
264 def test_code(self) -> None:
265 assert validate_domain_name("code") == "code"
266
267 def test_scaffold(self) -> None:
268 assert validate_domain_name("scaffold") == "scaffold"
269
270 def test_with_hyphen(self) -> None:
271 assert validate_domain_name("my-domain") == "my-domain"
272
273 def test_with_underscore(self) -> None:
274 assert validate_domain_name("my_domain") == "my_domain"
275
276 def test_with_digits(self) -> None:
277 assert validate_domain_name("domain2") == "domain2"
278
279 def test_rejects_empty(self) -> None:
280 with pytest.raises(ValueError):
281 validate_domain_name("")
282
283 def test_rejects_leading_digit(self) -> None:
284 with pytest.raises(ValueError):
285 validate_domain_name("2domain")
286
287 def test_rejects_uppercase(self) -> None:
288 with pytest.raises(ValueError):
289 validate_domain_name("MIDI")
290
291 def test_rejects_space(self) -> None:
292 with pytest.raises(ValueError):
293 validate_domain_name("my domain")
294
295 def test_rejects_slash(self) -> None:
296 with pytest.raises(ValueError):
297 validate_domain_name("midi/ext")
298
299 def test_rejects_dot(self) -> None:
300 with pytest.raises(ValueError):
301 validate_domain_name("midi.ext")
302
303 def test_rejects_too_long(self) -> None:
304 with pytest.raises(ValueError):
305 # > 63 chars (the regex allows a start letter + up to 62 more)
306 validate_domain_name("a" + "b" * 63)
307
308
309 # ---------------------------------------------------------------------------
310 # contain_path
311 # ---------------------------------------------------------------------------
312
313
314 class TestContainPath:
315 def test_simple_subpath(self, tmp_path: pathlib.Path) -> None:
316 result = contain_path(tmp_path, "file.txt")
317 assert result == (tmp_path / "file.txt").resolve()
318
319 def test_nested_subpath(self, tmp_path: pathlib.Path) -> None:
320 result = contain_path(tmp_path, "sub/dir/file.txt")
321 assert result == (tmp_path / "sub" / "dir" / "file.txt").resolve()
322
323 def test_returns_resolved_path(self, tmp_path: pathlib.Path) -> None:
324 result = contain_path(tmp_path, "a/./b")
325 assert "./" not in str(result)
326
327 def test_rejects_dotdot_traversal(self, tmp_path: pathlib.Path) -> None:
328 with pytest.raises(ValueError, match="traversal"):
329 contain_path(tmp_path, "../escape")
330
331 def test_rejects_double_dotdot(self, tmp_path: pathlib.Path) -> None:
332 with pytest.raises(ValueError):
333 contain_path(tmp_path, "sub/../../etc/passwd")
334
335 def test_rejects_absolute_path(self, tmp_path: pathlib.Path) -> None:
336 with pytest.raises(ValueError):
337 contain_path(tmp_path, "/etc/passwd")
338
339 def test_rejects_empty_rel(self, tmp_path: pathlib.Path) -> None:
340 with pytest.raises(ValueError, match="must not be empty"):
341 contain_path(tmp_path, "")
342
343
344 def test_path_equal_to_child_is_fine(self, tmp_path: pathlib.Path) -> None:
345 # A path that resolves exactly to a direct child should pass.
346 result = contain_path(tmp_path, "direct_child")
347 assert result.parent == tmp_path.resolve()
348
349 def test_rejects_symlink_escaping_base(self, tmp_path: pathlib.Path) -> None:
350 # Create a symlink inside base that points outside.
351 outside = tmp_path.parent / "outside.txt"
352 outside.write_text("secret")
353 link = tmp_path / "link.txt"
354 link.symlink_to(outside)
355 # contain_path resolves the path — symlink target is outside base.
356 with pytest.raises(ValueError, match="traversal"):
357 contain_path(tmp_path, "link.txt")
358
359
360 # ---------------------------------------------------------------------------
361 # sanitize_glob_prefix
362 # ---------------------------------------------------------------------------
363
364
365 class TestSanitizeGlobPrefix:
366 def test_clean_prefix_unchanged(self) -> None:
367 assert sanitize_glob_prefix("abcdef") == "abcdef"
368
369 def test_strips_asterisk(self) -> None:
370 assert sanitize_glob_prefix("abc*def") == "abcdef"
371
372 def test_strips_question_mark(self) -> None:
373 assert sanitize_glob_prefix("abc?def") == "abcdef"
374
375 def test_strips_open_bracket(self) -> None:
376 assert sanitize_glob_prefix("abc[def") == "abcdef"
377
378 def test_strips_close_bracket(self) -> None:
379 assert sanitize_glob_prefix("abc]def") == "abcdef"
380
381 def test_strips_open_brace(self) -> None:
382 assert sanitize_glob_prefix("abc{def") == "abcdef"
383
384 def test_strips_close_brace(self) -> None:
385 assert sanitize_glob_prefix("abc}def") == "abcdef"
386
387 def test_strips_all_metacharacters(self) -> None:
388 assert sanitize_glob_prefix("*?[]{} abc") == " abc"
389
390 def test_empty_string(self) -> None:
391 assert sanitize_glob_prefix("") == ""
392
393 def test_hex_prefix_unaffected(self) -> None:
394 prefix = "deadbeef01"
395 assert sanitize_glob_prefix(prefix) == prefix
396
397
398 # ---------------------------------------------------------------------------
399 # sanitize_display
400 # ---------------------------------------------------------------------------
401
402
403 class TestSanitizeDisplay:
404 def test_clean_ascii_unchanged(self) -> None:
405 assert sanitize_display("Hello, World!") == "Hello, World!"
406
407 def test_newline_preserved(self) -> None:
408 s = "line1\nline2"
409 assert sanitize_display(s) == s
410
411 def test_tab_preserved(self) -> None:
412 s = "col1\tcol2"
413 assert sanitize_display(s) == s
414
415 def test_strips_ansi_escape_sequence(self) -> None:
416 ansi = "\x1b[31mred text\x1b[0m"
417 result = sanitize_display(ansi)
418 assert "\x1b" not in result
419 assert "red text" in result
420
421 def test_strips_bel(self) -> None:
422 assert sanitize_display("ring\x07bell") == "ringbell"
423
424 def test_strips_null_byte(self) -> None:
425 assert sanitize_display("no\x00null") == "nonull"
426
427 def test_strips_osc_sequence(self) -> None:
428 # OSC sequences start with \x9b (C1 CSI) or ESC [
429 osc = "\x9bmalicious"
430 result = sanitize_display(osc)
431 assert "\x9b" not in result
432
433 def test_strips_cr(self) -> None:
434 assert sanitize_display("text\r") == "text"
435
436 def test_strips_vertical_tab(self) -> None:
437 assert sanitize_display("text\x0bmore") == "textmore"
438
439 def test_strips_form_feed(self) -> None:
440 assert sanitize_display("text\x0cmore") == "textmore"
441
442 def test_strips_del(self) -> None:
443 assert sanitize_display("text\x7fmore") == "textmore"
444
445 def test_multiline_message_sanitized(self) -> None:
446 msg = "commit: \x1b[1mAdd feature\x1b[0m\nSigned-off-by: Alice"
447 result = sanitize_display(msg)
448 assert "\x1b" not in result
449 assert "Add feature" in result
450 assert "Signed-off-by: Alice" in result
451
452 def test_empty_string(self) -> None:
453 assert sanitize_display("") == ""
454
455 def test_unicode_letters_preserved(self) -> None:
456 s = "Héllo Wörld — 日本語"
457 assert sanitize_display(s) == s
458
459
460 # ---------------------------------------------------------------------------
461 # clamp_int
462 # ---------------------------------------------------------------------------
463
464
465 class TestClampInt:
466 def test_value_in_range_returned_unchanged(self) -> None:
467 assert clamp_int(5, 1, 10) == 5
468
469 def test_value_at_lower_bound(self) -> None:
470 assert clamp_int(1, 1, 10) == 1
471
472 def test_value_at_upper_bound(self) -> None:
473 assert clamp_int(10, 1, 10) == 10
474
475 def test_below_min_raises(self) -> None:
476 with pytest.raises(ValueError, match="between"):
477 clamp_int(0, 1, 10)
478
479 def test_above_max_raises(self) -> None:
480 with pytest.raises(ValueError, match="between"):
481 clamp_int(11, 1, 10)
482
483 def test_name_in_error_message(self) -> None:
484 with pytest.raises(ValueError, match="depth"):
485 clamp_int(-1, 0, 100, name="depth")
486
487 def test_negative_range(self) -> None:
488 assert clamp_int(-5, -10, 0) == -5
489
490 def test_equal_lo_hi(self) -> None:
491 assert clamp_int(42, 42, 42) == 42
492
493
494 # ---------------------------------------------------------------------------
495 # finite_float
496 # ---------------------------------------------------------------------------
497
498
499 class TestFiniteFloat:
500 def test_finite_value_returned_unchanged(self) -> None:
501 assert finite_float(120.0, 120.0) == 120.0
502
503 def test_zero_is_finite(self) -> None:
504 assert finite_float(0.0, 1.0) == 0.0
505
506 def test_negative_finite_returned(self) -> None:
507 assert finite_float(-5.5, 0.0) == -5.5
508
509 def test_positive_inf_returns_fallback(self) -> None:
510 assert finite_float(math.inf, 120.0) == 120.0
511
512 def test_negative_inf_returns_fallback(self) -> None:
513 assert finite_float(-math.inf, 120.0) == 120.0
514
515 def test_nan_returns_fallback(self) -> None:
516 assert finite_float(math.nan, 120.0) == 120.0
517
518 def test_large_finite_returned(self) -> None:
519 big = 1e300
520 assert finite_float(big, 0.0) == big
521
522
523 # ---------------------------------------------------------------------------
524 # Stress: contain_path with many adversarial inputs
525 # ---------------------------------------------------------------------------
526
527
528 class TestContainPathStress:
529 """Fuzz-style test — generate many adversarial path strings and verify
530 that contain_path rejects all traversal attempts."""
531
532 TRAVERSAL_ATTEMPTS: list[str] = [
533 "..",
534 "../etc/passwd",
535 "../../etc/shadow",
536 "sub/../../../etc/passwd",
537 "/absolute/path",
538 "/",
539 "//double-slash",
540 # Note: URL-encoded dots (%2e%2e) are NOT traversal from a filesystem
541 # perspective — contain_path is a filesystem guard, not an HTTP parser.
542 # Null bytes cause an OS-level ValueError, which we also accept.
543 "\x00null",
544 "sub/\x00null",
545 ]
546
547 def test_all_traversal_attempts_rejected(self, tmp_path: pathlib.Path) -> None:
548 for attempt in self.TRAVERSAL_ATTEMPTS:
549 with pytest.raises((ValueError, TypeError)):
550 contain_path(tmp_path, attempt)
551
552 def test_large_number_of_valid_paths_accepted(self, tmp_path: pathlib.Path) -> None:
553 for i in range(200):
554 rel = f"subdir/track_{i:04d}.mid"
555 result = contain_path(tmp_path, rel)
556 assert str(result).startswith(str(tmp_path.resolve()))
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago