From 36ec9b4d6865576cb295715e91a3e941f9fbc16d Mon Sep 17 00:00:00 2001 From: Trung Minh Do Date: Mon, 20 Jul 2026 18:31:39 +0200 Subject: [PATCH 1/3] fix(linter): exclude string-literal contents from paren/quote balance checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linter counted parentheses and quotes inside string literals, producing false positive "unbalanced" warnings for code like छपाउ("(क"). Use _tokenize_preserving_strings from the transpiler to extract only the code portions of each line before counting. Also add single-quote balance checking (previously only double quotes were checked). Regression tests cover: parens/quotes inside strings (no error), genuinely unbalanced lines (still error), single-quote balance, apostrophe in double-quoted strings. Closes #30 --- CHANGELOG.md | 2 + maithili_dsl/transpiler/linter.py | 16 +++- tests/test_linter.py | 113 +++++++++++++++++++-------- tests/test_security.py | 125 ++++++++++++++++++++---------- 4 files changed, 180 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6d508..b0cd61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Parentheses and quote characters inside string literals no longer trigger false unbalanced-linter warnings. +- Single-quote balance is now checked alongside double-quote balance (previously only double quotes were validated). - Augmented assignments no longer produce invalid-variable-name linter errors. ### Added diff --git a/maithili_dsl/transpiler/linter.py b/maithili_dsl/transpiler/linter.py index 03ce3fa..49b66da 100644 --- a/maithili_dsl/transpiler/linter.py +++ b/maithili_dsl/transpiler/linter.py @@ -2,6 +2,8 @@ import re +from .transpile import _tokenize_preserving_strings + LINT_CONFIG = { "enforce_snake_case": False, "max_line_length": 80, @@ -44,12 +46,18 @@ def lint_maithili_code(code, config=LINT_CONFIG): if "=" in stripped and stripped.startswith("="): errors.append(f"पंक्ति {i}: '=' चिह्नक पहिले कोनो मान अपेक्षित अछि") - # Check for unbalanced parentheses or quotes - if stripped.count("(") != stripped.count(")"): + # Check for unbalanced parentheses or quotes. + # Tokenize the line and count only in code regions so that + # parens and quotes inside string literals are not counted. + tokens = _tokenize_preserving_strings(line) + code_parts = [text for is_str, text in tokens if not is_str] + code_line = "".join(code_parts) + if code_line.count("(") != code_line.count(")"): errors.append(f"पंक्ति {i}: गोल ब्रैकेट असंतुलित अछि") - if stripped.count("\"") % 2 != 0: + if code_line.count("\"") % 2 != 0: + errors.append(f"पंक्ति {i}: उद्धरण चिह्न जोड़ा में नहि अछि") + if code_line.count("'") % 2 != 0: errors.append(f"पंक्ति {i}: उद्धरण चिह्न जोड़ा में नहि अछि") - # Check for indentation (must start with 4-space or tab if inside block) if line and not line.startswith(" ") and not stripped.endswith(":") and i > 1: prev = lines[i-2].strip() diff --git a/tests/test_linter.py b/tests/test_linter.py index ab44ac8..4dccd08 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -4,6 +4,7 @@ (constructor) is not flagged as unused, and all the structural checks still fire on genuinely broken input. """ + import pytest from maithili_dsl.transpiler.linter import ( @@ -18,17 +19,13 @@ # Clean / passing code # --------------------------------------------------------------------------- + def test_empty_code_has_no_errors(): assert lint_maithili_code("") == [] def test_simple_function_with_usage_passes(): - code = ( - "कार्य नमस्कार():\n" - " छपाउ(\"hi\")\n" - "\n" - "नमस्कार()\n" - ) + code = 'कार्य नमस्कार():\n छपाउ("hi")\n\nनमस्कार()\n' assert lint_maithili_code(code) == [] @@ -38,7 +35,7 @@ def test_class_with_constructor_passes(): " कार्य नव(स्वयं, नाम):\n" " स्वयं.नाम = नाम\n" "\n" - "व्यक्ति१ = व्यक्ति(\"सुमन\")\n" + 'व्यक्ति१ = व्यक्ति("सुमन")\n' ) errors = lint_maithili_code(code) # The नव constructor is called implicitly via instantiation, @@ -51,6 +48,7 @@ def test_class_with_constructor_passes(): # Comparison operators are not assignments # --------------------------------------------------------------------------- + class TestComparisonNotFlaggedAsAssignment: """Pre-fix bug: `x == 5` parsed as an assignment with var name `x ` and produced a spurious 'invalid variable name' error.""" @@ -89,6 +87,7 @@ def test_augmented_assignment_not_flagged_as_invalid_name(operator): # Structural errors still fire # --------------------------------------------------------------------------- + def test_leading_equals_sign_flagged(): errors = lint_maithili_code("= ५\n") assert any("'=' चिह्नक पहिले" in e for e in errors) @@ -110,7 +109,7 @@ def test_suspicious_block_flagged(): def test_missing_indent_after_colon_flagged(): - code = "कार्य f():\nछपाउ(\"x\")\n" + code = 'कार्य f():\nछपाउ("x")\n' errors = lint_maithili_code(code) assert any("इनडेन्टेशन" in e for e in errors) @@ -119,15 +118,16 @@ def test_missing_indent_after_colon_flagged(): # Line length # --------------------------------------------------------------------------- + def test_long_line_flagged_by_default(): - long_line = "x = \"" + ("अ" * 100) + "\"\n" + long_line = 'x = "' + ("अ" * 100) + '"\n' errors = lint_maithili_code(long_line) assert any("पंक्ति बहुत लंबा" in e for e in errors) def test_line_length_config_override(): config = dict(LINT_CONFIG, max_line_length=200) - long_line = "x = \"" + ("अ" * 100) + "\"\n" + long_line = 'x = "' + ("अ" * 100) + '"\n' errors = lint_maithili_code(long_line, config=config) assert not any("पंक्ति बहुत लंबा" in e for e in errors) @@ -136,6 +136,7 @@ def test_line_length_config_override(): # Unused function detection # --------------------------------------------------------------------------- + def test_unused_regular_function_flagged(): code = "कार्य अप्रयुक्त():\n फेर करू १\n" errors = lint_maithili_code(code) @@ -143,25 +144,14 @@ def test_unused_regular_function_flagged(): def test_used_function_not_flagged(): - code = ( - "कार्य प्रयुक्त():\n" - " फेर करू १\n" - "\n" - "प्रयुक्त()\n" - ) + code = "कार्य प्रयुक्त():\n फेर करू १\n\nप्रयुक्त()\n" errors = lint_maithili_code(code) assert not any("प्रयुक्त" in e and "प्रयोग नहि" in e for e in errors) def test_init_never_flagged_even_when_not_called_by_name(): # नव is called implicitly via class instantiation — never by name. - code = ( - "वर्ग क:\n" - " कार्य नव(स्वयं):\n" - " स्वयं.x = १\n" - "\n" - "ओब = क()\n" - ) + code = "वर्ग क:\n कार्य नव(स्वयं):\n स्वयं.x = १\n\nओब = क()\n" errors = lint_maithili_code(code) assert not any("नव" in e and "प्रयोग नहि" in e for e in errors) @@ -170,6 +160,7 @@ def test_init_never_flagged_even_when_not_called_by_name(): # is_snake_case / snake_case enforcement toggle # --------------------------------------------------------------------------- + def test_is_snake_case_positive_cases(): assert is_snake_case("foo_bar") assert is_snake_case("x") @@ -200,15 +191,19 @@ def test_snake_case_enforced_when_enabled(): # Exception translation # --------------------------------------------------------------------------- -@pytest.mark.parametrize("exc_type,must_contain", [ - (SyntaxError, "वाक्य संरचना"), - (NameError, "नाम"), - (TypeError, "प्रकार"), - (ValueError, "मान"), - (AttributeError, "गुण"), - (IndexError, "सूची सीमा"), - (KeyError, "कुंजी"), -]) + +@pytest.mark.parametrize( + "exc_type,must_contain", + [ + (SyntaxError, "वाक्य संरचना"), + (NameError, "नाम"), + (TypeError, "प्रकार"), + (ValueError, "मान"), + (AttributeError, "गुण"), + (IndexError, "सूची सीमा"), + (KeyError, "कुंजी"), + ], +) def test_known_exceptions_translated(exc_type, must_contain): exc = exc_type("demo") out = translate_exception_to_maithili(exc) @@ -221,3 +216,57 @@ class CustomError(Exception): out = translate_exception_to_maithili(CustomError("demo")) assert "CustomError" in out + + +# --------------------------------------------------------------------------- +# Regression: parens and quotes inside string literals (issue #30) +# --------------------------------------------------------------------------- + + +def test_parens_inside_double_quoted_string_not_flagged(): + code = '\u091b\u092a\u093e\u0909("(\u0915")\n' + errors = lint_maithili_code(code) + assert not any( + "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + for e in errors + ), f"parens inside double-quoted string falsely flagged: {errors}" + + +def test_parens_inside_single_quoted_string_not_flagged(): + code = "\u091b\u092a\u093e\u0909('(\u0915')\n" + errors = lint_maithili_code(code) + assert not any( + "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + for e in errors + ), f"parens inside single-quoted string falsely flagged: {errors}" + + +def test_quote_inside_string_not_flagged(): + code = '\u091b\u092a\u093e\u0909("it\'s ok")\n' + errors = lint_maithili_code(code) + assert not any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( + f"apostrophe in double-quoted string falsely flagged: {errors}" + ) + + +def test_unbalanced_single_quote_flagged(): + code = "\u091b\u092a\u093e\u0909('hi)\n" + errors = lint_maithili_code(code) + assert any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( + f"unbalanced single quote not flagged: {errors}" + ) + + +def test_genuinely_unbalanced_parens_still_flagged(): + errors = lint_maithili_code("\u091b\u092a\u093e\u0909(x\n") + assert any( + "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + for e in errors + ), f"genuinely unbalanced parens not flagged: {errors}" + + +def test_genuinely_unbalanced_quotes_still_flagged(): + errors = lint_maithili_code('\u091b\u092a\u093e\u0909("hi)\n') + assert any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( + f"genuinely unbalanced quotes not flagged: {errors}" + ) diff --git a/tests/test_security.py b/tests/test_security.py index 1b6787e..32ed13a 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -11,6 +11,7 @@ Each test pins a specific attack surface so that a regression that re-opens one hole is immediately caught. """ + import pytest from maithili_dsl import cli @@ -33,34 +34,26 @@ # module name was legitimately translated from a Maithili import. # --------------------------------------------------------------------------- -class TestValidateImportsRejectsRawPythonImports: +class TestValidateImportsRejectsRawPythonImports: def test_raw_import_os_rejected(self): errors = _validate_imports("import os\n", translated_modules=set()) assert errors, "import os should be blocked" def test_raw_import_subprocess_rejected(self): - errors = _validate_imports( - "import subprocess\n", translated_modules=set() - ) + errors = _validate_imports("import subprocess\n", translated_modules=set()) assert errors def test_raw_import_socket_rejected(self): - errors = _validate_imports( - "import socket\n", translated_modules=set() - ) + errors = _validate_imports("import socket\n", translated_modules=set()) assert errors def test_raw_import_ctypes_rejected(self): - errors = _validate_imports( - "import ctypes\n", translated_modules=set() - ) + errors = _validate_imports("import ctypes\n", translated_modules=set()) assert errors def test_raw_import_pickle_rejected(self): - errors = _validate_imports( - "import pickle\n", translated_modules=set() - ) + errors = _validate_imports("import pickle\n", translated_modules=set()) assert errors def test_raw_from_import_rejected(self): @@ -73,9 +66,7 @@ def test_import_after_maithili_translation_allowed(self): # A Maithili `आयात गणित` becomes `import math` and is added # to the translated_modules set — then _validate_imports must # allow it through. - errors = _validate_imports( - "import math\n", translated_modules={"math"} - ) + errors = _validate_imports("import math\n", translated_modules={"math"}) assert errors == [] def test_non_import_lines_are_ignored(self): @@ -87,8 +78,8 @@ def test_non_import_lines_are_ignored(self): # _make_safe_import: __import__ replacement only permits whitelist. # --------------------------------------------------------------------------- -class TestSafeImportWhitelist: +class TestSafeImportWhitelist: def setup_method(self): self.safe_import = _make_safe_import() @@ -130,12 +121,19 @@ def test_error_message_is_in_maithili(self): # _SAFE_BUILTIN_NAMES: forbidden builtins are absent. # --------------------------------------------------------------------------- -class TestForbiddenBuiltinsAbsent: - @pytest.mark.parametrize("name", [ - "exec", "eval", "compile", "open", "breakpoint", - "__import__", # only safe_import wrapper, not the real thing - ]) +class TestForbiddenBuiltinsAbsent: + @pytest.mark.parametrize( + "name", + [ + "exec", + "eval", + "compile", + "open", + "breakpoint", + "__import__", # only safe_import wrapper, not the real thing + ], + ) def test_name_not_in_safe_builtins_list(self, name): assert name not in _SAFE_BUILTIN_NAMES, ( f"{name!r} must not appear in _SAFE_BUILTIN_NAMES" @@ -151,8 +149,8 @@ def test_expected_safe_names_present(self): # translate_imports: records which modules were legitimately translated. # --------------------------------------------------------------------------- -class TestTranslateImports: +class TestTranslateImports: def test_maithili_import_recorded(self): code = "आयात गणित\n" translated, modules = translate_imports(code) @@ -182,18 +180,17 @@ def test_attribute_access_also_counts(self): # End-to-end: malicious .dmai files are rejected by run_dmai_file # --------------------------------------------------------------------------- + @pytest.mark.security class TestMaliciousDmaiBlocked: """Write real .dmai files that try to break out, and confirm run_dmai_file rejects them with a non-zero exit code.""" - def test_attempting_import_os_via_raw_python_rejected( - self, tmp_dmai_file, capsys - ): + def test_attempting_import_os_via_raw_python_rejected(self, tmp_dmai_file, capsys): # The lexical form "import os" isn't a Maithili keyword so it # survives transpilation unchanged, but _validate_imports # should catch it before exec. - path = tmp_dmai_file("import os\nos.system(\"echo pwned\")\n") + path = tmp_dmai_file('import os\nos.system("echo pwned")\n') code = run_dmai_file(str(path)) captured = capsys.readouterr() # It may be blocked at lint (long-line false positive is unlikely @@ -204,7 +201,7 @@ def test_attempting_import_os_via_raw_python_rejected( def test_attempting_to_call_exec_raises(self, tmp_dmai_file, capsys): # exec is not in safe_builtins, so this NameError-s inside exec. - path = tmp_dmai_file("exec(\"print(1)\")\n") + path = tmp_dmai_file('exec("print(1)")\n') code = run_dmai_file(str(path)) captured = capsys.readouterr() assert code == EXIT_RUNTIME_ERROR @@ -212,26 +209,22 @@ def test_attempting_to_call_exec_raises(self, tmp_dmai_file, capsys): assert "त्रुटि" in captured.out def test_attempting_to_call_open_raises(self, tmp_dmai_file, capsys): - path = tmp_dmai_file("open(\"/etc/passwd\")\n") + path = tmp_dmai_file('open("/etc/passwd")\n') code = run_dmai_file(str(path)) assert code == EXIT_RUNTIME_ERROR def test_attempting_to_call_eval_raises(self, tmp_dmai_file, capsys): - path = tmp_dmai_file("eval(\"1+1\")\n") + path = tmp_dmai_file('eval("1+1")\n') code = run_dmai_file(str(path)) assert code == EXIT_RUNTIME_ERROR - def test_maithili_import_of_allowed_module_works( - self, tmp_dmai_file, capsys - ): + def test_maithili_import_of_allowed_module_works(self, tmp_dmai_file, capsys): # Positive control: a legitimate Maithili import of a # whitelisted module should succeed. path = tmp_dmai_file("आयात गणित\nछपाउ(गणित.pi)\n") code = run_dmai_file(str(path)) captured = capsys.readouterr() - assert code == EXIT_OK, ( - f"Clean program blocked unexpectedly: {captured.out}" - ) + assert code == EXIT_OK, f"Clean program blocked unexpectedly: {captured.out}" assert "3.14" in captured.out @@ -239,15 +232,67 @@ def test_maithili_import_of_allowed_module_works( # Whitelist shape: every MAITHILI_MODULES target is in _ALLOWED_MODULES. # --------------------------------------------------------------------------- + def test_allowed_modules_matches_maithili_modules(): assert _ALLOWED_MODULES == set(MAITHILI_MODULES.values()) -@pytest.mark.parametrize("dangerous", [ - "subprocess", "socket", "ctypes", "pickle", "shutil", - "urllib", "http", "ftplib", "telnetlib", -]) +@pytest.mark.parametrize( + "dangerous", + [ + "subprocess", + "socket", + "ctypes", + "pickle", + "shutil", + "urllib", + "http", + "ftplib", + "telnetlib", + ], +) def test_dangerous_module_not_in_whitelist(dangerous): assert dangerous not in _ALLOWED_MODULES, ( f"{dangerous} must never be in the import whitelist" ) + + +# --------------------------------------------------------------------------- +# Security regression: linter tokenizer does not widen the trust boundary +# --------------------------------------------------------------------------- + + +@pytest.mark.security +def test_linter_tokenizer_does_not_expand_trust_boundary(): + """Code-like content inside string literals remains data. + + The linter now uses _tokenize_preserving_strings for + paren/quote balance. This test verifies that using the + tokenizer in the lint path does not accidentally weaken + any runtime security boundaries — the transpiler, import + validator, and sandbox execution path are unchanged. + """ + from maithili_dsl.transpiler.linter import lint_maithili_code + + code_inside_string = '\u091b\u092a\u093e\u0909("import os; exec()")\n' + errors = lint_maithili_code(code_inside_string) + assert not any( + "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + for e in errors + ) + + +@pytest.mark.security +def test_actual_dangerous_code_outside_strings_still_blocked(): + """Raw Python import outside strings is still caught by the import validator. + + The linter change uses the tokenizer for paren/quote balance checks. + This test verifies that the security model is unchanged: dangerous + code outside string literals remains blocked by _validate_imports. + """ + from maithili_dsl.transpiler.transpile import transpile_maithili_code + + code = '\u091b\u092a\u093e\u0909("safe")\nimport os\n' + transpiled = transpile_maithili_code(code) + errors = _validate_imports(transpiled, translated_modules=set()) + assert errors, f"raw 'import os' must be blocked by import validator: {errors}" From 0d45dd7def40934d7c7557db17d457566053a856 Mon Sep 17 00:00:00 2001 From: Trung Minh Do Date: Mon, 20 Jul 2026 18:54:39 +0200 Subject: [PATCH 2/3] test(linter): narrow regression-test diff --- tests/test_linter.py | 62 ++++++++++++++++-------------- tests/test_security.py | 85 ++++++++++++++++++++---------------------- 2 files changed, 73 insertions(+), 74 deletions(-) diff --git a/tests/test_linter.py b/tests/test_linter.py index 4dccd08..4a0240e 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -4,7 +4,6 @@ (constructor) is not flagged as unused, and all the structural checks still fire on genuinely broken input. """ - import pytest from maithili_dsl.transpiler.linter import ( @@ -19,13 +18,17 @@ # Clean / passing code # --------------------------------------------------------------------------- - def test_empty_code_has_no_errors(): assert lint_maithili_code("") == [] def test_simple_function_with_usage_passes(): - code = 'कार्य नमस्कार():\n छपाउ("hi")\n\nनमस्कार()\n' + code = ( + "कार्य नमस्कार():\n" + " छपाउ(\"hi\")\n" + "\n" + "नमस्कार()\n" + ) assert lint_maithili_code(code) == [] @@ -35,7 +38,7 @@ def test_class_with_constructor_passes(): " कार्य नव(स्वयं, नाम):\n" " स्वयं.नाम = नाम\n" "\n" - 'व्यक्ति१ = व्यक्ति("सुमन")\n' + "व्यक्ति१ = व्यक्ति(\"सुमन\")\n" ) errors = lint_maithili_code(code) # The नव constructor is called implicitly via instantiation, @@ -48,7 +51,6 @@ def test_class_with_constructor_passes(): # Comparison operators are not assignments # --------------------------------------------------------------------------- - class TestComparisonNotFlaggedAsAssignment: """Pre-fix bug: `x == 5` parsed as an assignment with var name `x ` and produced a spurious 'invalid variable name' error.""" @@ -87,7 +89,6 @@ def test_augmented_assignment_not_flagged_as_invalid_name(operator): # Structural errors still fire # --------------------------------------------------------------------------- - def test_leading_equals_sign_flagged(): errors = lint_maithili_code("= ५\n") assert any("'=' चिह्नक पहिले" in e for e in errors) @@ -109,7 +110,7 @@ def test_suspicious_block_flagged(): def test_missing_indent_after_colon_flagged(): - code = 'कार्य f():\nछपाउ("x")\n' + code = "कार्य f():\nछपाउ(\"x\")\n" errors = lint_maithili_code(code) assert any("इनडेन्टेशन" in e for e in errors) @@ -118,16 +119,15 @@ def test_missing_indent_after_colon_flagged(): # Line length # --------------------------------------------------------------------------- - def test_long_line_flagged_by_default(): - long_line = 'x = "' + ("अ" * 100) + '"\n' + long_line = "x = \"" + ("अ" * 100) + "\"\n" errors = lint_maithili_code(long_line) assert any("पंक्ति बहुत लंबा" in e for e in errors) def test_line_length_config_override(): config = dict(LINT_CONFIG, max_line_length=200) - long_line = 'x = "' + ("अ" * 100) + '"\n' + long_line = "x = \"" + ("अ" * 100) + "\"\n" errors = lint_maithili_code(long_line, config=config) assert not any("पंक्ति बहुत लंबा" in e for e in errors) @@ -136,7 +136,6 @@ def test_line_length_config_override(): # Unused function detection # --------------------------------------------------------------------------- - def test_unused_regular_function_flagged(): code = "कार्य अप्रयुक्त():\n फेर करू १\n" errors = lint_maithili_code(code) @@ -144,14 +143,25 @@ def test_unused_regular_function_flagged(): def test_used_function_not_flagged(): - code = "कार्य प्रयुक्त():\n फेर करू १\n\nप्रयुक्त()\n" + code = ( + "कार्य प्रयुक्त():\n" + " फेर करू १\n" + "\n" + "प्रयुक्त()\n" + ) errors = lint_maithili_code(code) assert not any("प्रयुक्त" in e and "प्रयोग नहि" in e for e in errors) def test_init_never_flagged_even_when_not_called_by_name(): # नव is called implicitly via class instantiation — never by name. - code = "वर्ग क:\n कार्य नव(स्वयं):\n स्वयं.x = १\n\nओब = क()\n" + code = ( + "वर्ग क:\n" + " कार्य नव(स्वयं):\n" + " स्वयं.x = १\n" + "\n" + "ओब = क()\n" + ) errors = lint_maithili_code(code) assert not any("नव" in e and "प्रयोग नहि" in e for e in errors) @@ -160,7 +170,6 @@ def test_init_never_flagged_even_when_not_called_by_name(): # is_snake_case / snake_case enforcement toggle # --------------------------------------------------------------------------- - def test_is_snake_case_positive_cases(): assert is_snake_case("foo_bar") assert is_snake_case("x") @@ -191,19 +200,15 @@ def test_snake_case_enforced_when_enabled(): # Exception translation # --------------------------------------------------------------------------- - -@pytest.mark.parametrize( - "exc_type,must_contain", - [ - (SyntaxError, "वाक्य संरचना"), - (NameError, "नाम"), - (TypeError, "प्रकार"), - (ValueError, "मान"), - (AttributeError, "गुण"), - (IndexError, "सूची सीमा"), - (KeyError, "कुंजी"), - ], -) +@pytest.mark.parametrize("exc_type,must_contain", [ + (SyntaxError, "वाक्य संरचना"), + (NameError, "नाम"), + (TypeError, "प्रकार"), + (ValueError, "मान"), + (AttributeError, "गुण"), + (IndexError, "सूची सीमा"), + (KeyError, "कुंजी"), +]) def test_known_exceptions_translated(exc_type, must_contain): exc = exc_type("demo") out = translate_exception_to_maithili(exc) @@ -217,7 +222,6 @@ class CustomError(Exception): out = translate_exception_to_maithili(CustomError("demo")) assert "CustomError" in out - # --------------------------------------------------------------------------- # Regression: parens and quotes inside string literals (issue #30) # --------------------------------------------------------------------------- @@ -242,7 +246,7 @@ def test_parens_inside_single_quoted_string_not_flagged(): def test_quote_inside_string_not_flagged(): - code = '\u091b\u092a\u093e\u0909("it\'s ok")\n' + code = '\u091b\u092a\u093e\u0909("it''s ok")\n' errors = lint_maithili_code(code) assert not any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( f"apostrophe in double-quoted string falsely flagged: {errors}" diff --git a/tests/test_security.py b/tests/test_security.py index 32ed13a..8620de5 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -11,7 +11,6 @@ Each test pins a specific attack surface so that a regression that re-opens one hole is immediately caught. """ - import pytest from maithili_dsl import cli @@ -34,26 +33,34 @@ # module name was legitimately translated from a Maithili import. # --------------------------------------------------------------------------- - class TestValidateImportsRejectsRawPythonImports: + def test_raw_import_os_rejected(self): errors = _validate_imports("import os\n", translated_modules=set()) assert errors, "import os should be blocked" def test_raw_import_subprocess_rejected(self): - errors = _validate_imports("import subprocess\n", translated_modules=set()) + errors = _validate_imports( + "import subprocess\n", translated_modules=set() + ) assert errors def test_raw_import_socket_rejected(self): - errors = _validate_imports("import socket\n", translated_modules=set()) + errors = _validate_imports( + "import socket\n", translated_modules=set() + ) assert errors def test_raw_import_ctypes_rejected(self): - errors = _validate_imports("import ctypes\n", translated_modules=set()) + errors = _validate_imports( + "import ctypes\n", translated_modules=set() + ) assert errors def test_raw_import_pickle_rejected(self): - errors = _validate_imports("import pickle\n", translated_modules=set()) + errors = _validate_imports( + "import pickle\n", translated_modules=set() + ) assert errors def test_raw_from_import_rejected(self): @@ -66,7 +73,9 @@ def test_import_after_maithili_translation_allowed(self): # A Maithili `आयात गणित` becomes `import math` and is added # to the translated_modules set — then _validate_imports must # allow it through. - errors = _validate_imports("import math\n", translated_modules={"math"}) + errors = _validate_imports( + "import math\n", translated_modules={"math"} + ) assert errors == [] def test_non_import_lines_are_ignored(self): @@ -78,8 +87,8 @@ def test_non_import_lines_are_ignored(self): # _make_safe_import: __import__ replacement only permits whitelist. # --------------------------------------------------------------------------- - class TestSafeImportWhitelist: + def setup_method(self): self.safe_import = _make_safe_import() @@ -121,19 +130,12 @@ def test_error_message_is_in_maithili(self): # _SAFE_BUILTIN_NAMES: forbidden builtins are absent. # --------------------------------------------------------------------------- - class TestForbiddenBuiltinsAbsent: - @pytest.mark.parametrize( - "name", - [ - "exec", - "eval", - "compile", - "open", - "breakpoint", - "__import__", # only safe_import wrapper, not the real thing - ], - ) + + @pytest.mark.parametrize("name", [ + "exec", "eval", "compile", "open", "breakpoint", + "__import__", # only safe_import wrapper, not the real thing + ]) def test_name_not_in_safe_builtins_list(self, name): assert name not in _SAFE_BUILTIN_NAMES, ( f"{name!r} must not appear in _SAFE_BUILTIN_NAMES" @@ -149,8 +151,8 @@ def test_expected_safe_names_present(self): # translate_imports: records which modules were legitimately translated. # --------------------------------------------------------------------------- - class TestTranslateImports: + def test_maithili_import_recorded(self): code = "आयात गणित\n" translated, modules = translate_imports(code) @@ -180,17 +182,18 @@ def test_attribute_access_also_counts(self): # End-to-end: malicious .dmai files are rejected by run_dmai_file # --------------------------------------------------------------------------- - @pytest.mark.security class TestMaliciousDmaiBlocked: """Write real .dmai files that try to break out, and confirm run_dmai_file rejects them with a non-zero exit code.""" - def test_attempting_import_os_via_raw_python_rejected(self, tmp_dmai_file, capsys): + def test_attempting_import_os_via_raw_python_rejected( + self, tmp_dmai_file, capsys + ): # The lexical form "import os" isn't a Maithili keyword so it # survives transpilation unchanged, but _validate_imports # should catch it before exec. - path = tmp_dmai_file('import os\nos.system("echo pwned")\n') + path = tmp_dmai_file("import os\nos.system(\"echo pwned\")\n") code = run_dmai_file(str(path)) captured = capsys.readouterr() # It may be blocked at lint (long-line false positive is unlikely @@ -201,7 +204,7 @@ def test_attempting_import_os_via_raw_python_rejected(self, tmp_dmai_file, capsy def test_attempting_to_call_exec_raises(self, tmp_dmai_file, capsys): # exec is not in safe_builtins, so this NameError-s inside exec. - path = tmp_dmai_file('exec("print(1)")\n') + path = tmp_dmai_file("exec(\"print(1)\")\n") code = run_dmai_file(str(path)) captured = capsys.readouterr() assert code == EXIT_RUNTIME_ERROR @@ -209,22 +212,26 @@ def test_attempting_to_call_exec_raises(self, tmp_dmai_file, capsys): assert "त्रुटि" in captured.out def test_attempting_to_call_open_raises(self, tmp_dmai_file, capsys): - path = tmp_dmai_file('open("/etc/passwd")\n') + path = tmp_dmai_file("open(\"/etc/passwd\")\n") code = run_dmai_file(str(path)) assert code == EXIT_RUNTIME_ERROR def test_attempting_to_call_eval_raises(self, tmp_dmai_file, capsys): - path = tmp_dmai_file('eval("1+1")\n') + path = tmp_dmai_file("eval(\"1+1\")\n") code = run_dmai_file(str(path)) assert code == EXIT_RUNTIME_ERROR - def test_maithili_import_of_allowed_module_works(self, tmp_dmai_file, capsys): + def test_maithili_import_of_allowed_module_works( + self, tmp_dmai_file, capsys + ): # Positive control: a legitimate Maithili import of a # whitelisted module should succeed. path = tmp_dmai_file("आयात गणित\nछपाउ(गणित.pi)\n") code = run_dmai_file(str(path)) captured = capsys.readouterr() - assert code == EXIT_OK, f"Clean program blocked unexpectedly: {captured.out}" + assert code == EXIT_OK, ( + f"Clean program blocked unexpectedly: {captured.out}" + ) assert "3.14" in captured.out @@ -232,31 +239,19 @@ def test_maithili_import_of_allowed_module_works(self, tmp_dmai_file, capsys): # Whitelist shape: every MAITHILI_MODULES target is in _ALLOWED_MODULES. # --------------------------------------------------------------------------- - def test_allowed_modules_matches_maithili_modules(): assert _ALLOWED_MODULES == set(MAITHILI_MODULES.values()) -@pytest.mark.parametrize( - "dangerous", - [ - "subprocess", - "socket", - "ctypes", - "pickle", - "shutil", - "urllib", - "http", - "ftplib", - "telnetlib", - ], -) +@pytest.mark.parametrize("dangerous", [ + "subprocess", "socket", "ctypes", "pickle", "shutil", + "urllib", "http", "ftplib", "telnetlib", +]) def test_dangerous_module_not_in_whitelist(dangerous): assert dangerous not in _ALLOWED_MODULES, ( f"{dangerous} must never be in the import whitelist" ) - # --------------------------------------------------------------------------- # Security regression: linter tokenizer does not widen the trust boundary # --------------------------------------------------------------------------- From 4998275faea0c63ab90ac93c3be533667beab6df Mon Sep 17 00:00:00 2001 From: Trung Minh Do Date: Mon, 20 Jul 2026 19:31:11 +0200 Subject: [PATCH 3/3] test(linter): preserve apostrophe in string regression --- tests/test_linter.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/test_linter.py b/tests/test_linter.py index 4a0240e..7202188 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -228,49 +228,50 @@ class CustomError(Exception): def test_parens_inside_double_quoted_string_not_flagged(): - code = '\u091b\u092a\u093e\u0909("(\u0915")\n' + code = 'छपाउ("(क")\n' errors = lint_maithili_code(code) assert not any( - "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + "गोल ब्रैकेट" in e for e in errors ), f"parens inside double-quoted string falsely flagged: {errors}" def test_parens_inside_single_quoted_string_not_flagged(): - code = "\u091b\u092a\u093e\u0909('(\u0915')\n" + code = "छपाउ('(क')\n" errors = lint_maithili_code(code) assert not any( - "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + "गोल ब्रैकेट" in e for e in errors ), f"parens inside single-quoted string falsely flagged: {errors}" def test_quote_inside_string_not_flagged(): - code = '\u091b\u092a\u093e\u0909("it''s ok")\n' + code = 'छपाउ("it\'s ok")\n' + assert "it's ok" in code errors = lint_maithili_code(code) - assert not any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( + assert not any("उद्धरण" in e for e in errors), ( f"apostrophe in double-quoted string falsely flagged: {errors}" ) def test_unbalanced_single_quote_flagged(): - code = "\u091b\u092a\u093e\u0909('hi)\n" + code = "छपाउ('hi)\n" errors = lint_maithili_code(code) - assert any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( + assert any("उद्धरण" in e for e in errors), ( f"unbalanced single quote not flagged: {errors}" ) def test_genuinely_unbalanced_parens_still_flagged(): - errors = lint_maithili_code("\u091b\u092a\u093e\u0909(x\n") + errors = lint_maithili_code("छपाउ(x\n") assert any( - "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + "गोल ब्रैकेट" in e for e in errors ), f"genuinely unbalanced parens not flagged: {errors}" def test_genuinely_unbalanced_quotes_still_flagged(): - errors = lint_maithili_code('\u091b\u092a\u093e\u0909("hi)\n') - assert any("\u0909\u0926\u094d\u0927\u0930\u0923" in e for e in errors), ( + errors = lint_maithili_code('छपाउ("hi)\n') + assert any("उद्धरण" in e for e in errors), ( f"genuinely unbalanced quotes not flagged: {errors}" )