Skip to content

"Take the currently validated local fixes for GitHub issues #876, #878, #879, and #880 in /home/azureuser/src/azlin and turn them into actual GitHub pull requests that are ready for review. Use the re#888

Draft
rysweet wants to merge 4 commits intomainfrom
feat/issue-887-take-the-currently-validated-local-fixes-for-githu

Conversation

@rysweet
Copy link
Copy Markdown
Owner

@rysweet rysweet commented Mar 19, 2026

Summary

"Take the currently validated local fixes for GitHub issues #876, #878, #879, and #880 in /home/azureuser/src/azlin and turn them into actual GitHub pull requests that are ready for review. Use the repository's workflow properly so the work results in PRs, not just local staged changes. Preserve the user's separate staged empty-env.json change and do not include it. Group changes sensibly by code area if multiple PRs are warranted, but keep scope tight to the filed issues. Run required validation, create branches/commits, push, and open PRs. Note that issue #877 was determined not to need a code change; if that conclusion still holds after review, document it clearly in the appropriate PR/issue context rather than forcing a fake fix."

Issue

Closes #887

Changes

"{"components":[{"action":"create","name":"SecurityError","purpose":"Domain exception for tar extraction failures; module-level so tests can import it directly"},{"action":"create","name":"_PY312_PLUS","purpose":"Module-level constant computed once at import time to avoid repeated sys.version_info checks"},{"action":"create","name":"_is_release_binary_member","purpose":"Pure predicate — returns True if the archive member name corresponds to the azlin binary; no I/O"},{"action":"create","name":"_validate_release_member","purpose":"Stateless guard — raises SecurityError on absolute paths, '..' traversal, or non-regular-file members (Python < 3.12)"},{"action":"create","name":"_extract_release_binary","purpose":"Orchestrator — iterates members, skips non-binary, validates, normalises name via copy.copy, extracts with filter='data' on 3.12+"},{"action":"modify","name":"_download_from_release","purpose":"Refactored to use _extract_release_binary; temp file cleanup moved to finally block; SecurityError NOT caught here"},{"action":"create","name":"DocumentationError","purpose":"Typed exception boundary for all I/O and parse failures in the cli_documentation subsystem; lives in models.py to avoid circular imports"},{"action":"modify","name":"ExampleManager","purpose":"Propagate ValueError from _sanitize_command_name (fix #878); enforce encoding='utf-8' (fix #879); validate required 'command' field (fix #880)"},{"action":"modify","name":"Hasher","purpose":"Add encoding='utf-8' to all open() calls; distinguish FileNotFoundError (return {}) from JSONDecodeError (raise DocumentationError)"},{"action":"modify","name":"SyncManager","purpose":"Narrow except Exception clauses to except DocumentationError; add encoding='utf-8' to write_text calls"},{"action":"modify","name":"Extractor","purpose":"Raise DocumentationError on parse failure instead of returning None; maintain module import whitelist unchanged"},{"action":"create","name":"scripts/init.py","purpose":"Package marker enabling test imports from scripts/ directory"},{"action":"modify","name":"pyproject.toml dev group","purpose":"Add pytest >=9.0.2 to dev dependency group; add scripts/ to pythonpath for test discovery"},{"action":"create","name":"TestExtractReleaseBinary","purpose":"4 targeted unit tests covering filter='data' on 3.12+, path traversal rejection, symlink rejection, and execvp passthrough documentation"},{"action":"create","name":"TestCLIDocumentation","purpose":"41 unit tests covering corrupt JSON, UTF-8 roundtrip, missing required field, exception propagation, and YAML safety"}],"files_to_change":["src/azlin/rust_bridge.py","scripts/cli_documentation/models.py","scripts/cli_documentation/example_manager.py","scripts/cli_documentation/hasher.py","scripts/cli_documentation/sync_manager.py","scripts/cli_documentation/extractor.py","pyproject.toml"],"implementation_order":["1. Modify src/azlin/rust_bridge.py — add SecurityError, _PY312_PLUS, _is_release_binary_member, _validate_release_member, _extract_release_binary; refactor _download_from_release to use new helpers with finally-block temp cleanup","2. Write tests/unit/test_rust_bridge.py — 4 tests: filter='data' on 3.12+, path traversal rejection, symlink rejection (<3.12), SecurityError raised when no binary in archive","3. Modify scripts/cli_documentation/models.py — add DocumentationError(Exception) class at module level","4. Modify scripts/cli_documentation/hasher.py — add encoding='utf-8' to all open() calls; narrow exception handling to FileNotFoundError/JSONDecodeError/OSError; raise DocumentationError on corrupt/unreadable files","5. Modify scripts/cli_documentation/example_manager.py — remove bare except ValueError catch (propagate it); add encoding='utf-8' to all open() calls; add 'command' field presence check raising DocumentationError before CommandExample construction","6. Modify scripts/cli_documentation/sync_manager.py — narrow except Exception to except DocumentationError; add encoding='utf-8' to write_text calls","7. Modify scripts/cli_documentation/extractor.py — raise DocumentationError on parse failure; verify yaml.safe_load() usage; confirm module import whitelist is untouched","8. Create scripts/init.py — empty package marker","9. Modify pyproject.toml — add pytest to dev dependencies; add scripts/ to pythonpath","10. Write tests/unit/test_cli_documentation.py — 41 tests covering: corrupt JSON raises DocumentationError, missing file returns empty dict, unwritable file raises DocumentationError, UTF-8 roundtrip for hasher and example_manager, missing 'command' field raises DocumentationError, present 'command' field succeeds, ValueError propagates from sanitize, yaml.safe_load rejects !!python/object, except narrowed to DocumentationError"],"new_files":["scripts/init.py"],"risks":["copy.copy(TarInfo) performs a shallow copy — verify nested attributes (e.g., pax_headers) are not needed post-copy; mitigate by testing with a real tarfile fixture","filter='data' on Python 3.12 may strip execute bits from the azlin binary — mitigate by chmod 0o755 on the destination file after extraction","_VALID_NAME_RE may be too restrictive if subcommands use dot-separated names (e.g., 'az.vm') — verify ExampleManager receives leaf names only, not full dotted paths","Existing YAML files that lack a 'command' field will now raise DocumentationError on load — run a one-time audit of all existing *.yaml example files before merging PR-2 to avoid breaking the sync script on first run","DocumentationError masking real bugs if except clauses in sync_manager.py are too broad — mitigate by only catching DocumentationError at the per-command level, letting unexpected exceptions propagate to the top-level handler","scripts/init.py creation may interfere with existing import paths if scripts/ is already on sys.path without being a package — verify no import collisions with existing test setup"],"security_considerations":["SEC-R-01 (CRITICAL): _validate_release_member must check PurePosixPath('..' in parts) — not a naive string contains — to avoid false positives on names like 'foo..bar'","SEC-R-02 (HIGH): _PY312_PLUS gate must use sys.version_info >= (3, 12) tuple comparison, not string comparison, to ensure correct behaviour on 3.12.x patch releases","SEC-R-03 (HIGH): copy.copy(member) before setting member.name = 'azlin' is mandatory — mutating the original TarInfo pollutes the tarfile's internal member list","SEC-R-04 (HIGH): extractall() must not appear anywhere in rust_bridge.py — enforce via grep in CI or pre-commit hook","SEC-R-05 (MEDIUM, future): Document in a follow-up issue that binary SHA256 verification against a SUMS file is the next hardening step; add a TODO comment in _download_from_release","SEC-R-06 (LOW): Temp file unlink must be in finally, not except — ensures cleanup even when SecurityError is raised mid-extraction","SEC-R-07 (LOW): The except clause in _download_from_release must be typed as (urllib.error.URLError, OSError) — SecurityError must propagate to abort installation loudly","SEC-R-08 (HIGH): ValueError from _sanitize_command_name propagating (not swallowed) is the direct fix for issue #878 — this is both a correctness fix and a security boundary","SEC-R-09 (HIGH): yaml.safe_load() is already correct — add a CI grep check (grep -r 'yaml.load(' scripts/) to prevent regression","SEC-R-10 (MEDIUM): encoding='utf-8' on every open() and write_text() call — on Windows with cp1252, omitting this causes silent data corruption that invalidates hashes and corrupts YAML","SEC-R-11 (MEDIUM): 'command' field validation must use 'command' not in ex (dict key check), not ex.get('command') falsy check — an empty string '' is a distinct invalid state that should also be caught","SEC-R-12 (MEDIUM): except DocumentationError is the correct scope; AttributeError, TypeError, and KeyError indicate bugs and must not be silently swallowed","SEC-R-13 (HIGH): ALLOWED_MODULES whitelist in extractor.py must not be modified — any new azlin modules requiring documentation must be explicitly added after review","SEC-R-14 (LOW): json.JSONDecodeError must be re-raised as DocumentationError (with chained 'from e') to preserve the original traceback for debugging while presenting a clean typed boundary to callers"],"test_files":["tests/unit/test_rust_bridge.py","tests/unit/test_cli_documentation.py"]}"

Testing

  • Unit tests added
  • Local testing completed
  • Pre-commit hooks pass

Checklist

  • Tests pass
  • Documentation updated
  • No stubs or TODOs
  • Code review completed
  • Philosophy check passed

This PR was created as a draft for review before merging.

Ubuntu and others added 4 commits March 19, 2026 16:07
Resolves #878 by propagating errors instead of swallowing them
Resolves #879 by enforcing UTF-8 encoding in all file I/O
Resolves #880 by validating required 'command' field in examples

Changes:
- Add DocumentationError exception class to models.py
- example_manager.py: propagate ValueError, use encoding='utf-8', validate required fields
- extractor.py: raise DocumentationError instead of printing warnings
- hasher.py: use encoding='utf-8', raise on failure
- sync_manager.py: use encoding='utf-8', narrow except clauses
- scripts/__init__.py: new empty file making scripts/ a Python package
- pyproject.toml: add pythonpath and dev dependencies for tests
- Add comprehensive test suite in tests/unit/test_cli_documentation.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per TDD methodology (Step 7), write tests that:
- FAIL on main (DocumentationError does not yet exist in models.py)
- PASS once the implementation in this PR is merged

Test coverage (41 tests across 5 groups):

TestDocumentationError (3):
  - DocumentationError is an Exception subclass
  - Can be raised with a message
  - Exported in models.__all__

TestCLIHasherErrorHandling (9):
  - Corrupt JSON raises DocumentationError (not silent {})
  - Missing file returns empty dict (first-run behaviour)
  - JSONDecodeError is chained via 'from e' (SEC-R-14)
  - save_hashes() uses encoding='utf-8' (SEC-R-10)
  - Unicode hash round-trip
  - OSError on save raises DocumentationError

TestExampleManagerValidation (16):
  - Missing/empty/None 'command' field raises DocumentationError (SEC-R-11)
  - Valid 'command' field returns examples
  - load/save use encoding='utf-8' (SEC-R-10)
  - Unicode content survives round-trip
  - ValueError from _sanitize_command_name propagates (SEC-R-08)
  - yaml.safe_load rejects !!python/object (SEC-R-09)
  - Corrupt YAML raises DocumentationError

TestDocSyncManagerExceptionNarrowing (8):
  - DocumentationError is caught; AttributeError/TypeError propagate (SEC-R-12)
  - write_text() uses encoding='utf-8' (SEC-R-10)
  - Path traversal rejected in _get_output_path

TestCLIExtractorSecurity (5):
  - ALLOWED_MODULES whitelist unchanged (SEC-R-13)
  - Unlisted module rejected
  - Parse failure raises DocumentationError (not silent None)
  - Missing command returns None (not found != error)
  - yaml.load() absence verified in source

Also adds tests/conftest.py with repo-root sys.path setup so both
azlin and scripts.cli_documentation are importable during tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… in cli_documentation

_extract_from_click_command now raises DocumentationError instead of
returning None, so the `if metadata:` and `if sub_metadata:` guards in
the callers were unreachable dead code. Remove them and update the return
type annotation from `CLIMetadata | None` to `CLIMetadata`.

Also remove the redundant `{e}` interpolation in save_examples()
DocumentationError — the cause is already chained via `from e`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- rust_bridge.py: add SecurityError, copy.copy(member) safety comment,
  archive_path: Path | str type hint, finally-block temp file cleanup
- validator.py: pre-compile PLACEHOLDER_PATTERNS as list[re.Pattern[str]]
  with re.IGNORECASE for O(1) pattern reuse
- hasher.py: narrow broad except clauses to specific exceptions
- tests: add test_rust_bridge_security.py (21 tests, 4 skipped on Py<3.12)

All pre-commit hooks pass. 58 tests pass, 4 skipped (correct on Py 3.13).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
import re

# Match 'yaml.load(' but not 'yaml.safe_load('
forbidden = re.findall(r"\byaml\.load\s*\(", source)

Check failure

Code scanning / CodeQL

Potentially uninitialized local variable Error test

Local variable 'source' may be used before it is initialized.

Copilot Autofix

AI 14 days ago

In general, the fix is to guarantee that source is defined before it is used, regardless of how inspect.getsource behaves. This can be done by either initializing source before the try block and only using it when set, or by broadening the exception handling so that any failure to obtain the source causes the test to skip or fail before source is referenced.

The best minimal fix without changing functionality is to broaden the except clause from except OSError: to except Exception:. This way, any exception raised by inspect.getsource(extractor_module) will cause an immediate pytest.skip(...), and execution will not proceed to the re.findall call with an uninitialized source. This preserves the intended semantics: if we cannot inspect the source for any reason, we skip the test rather than erroring. The change is confined to the test_yaml_load_not_used_in_source method in tests/unit/test_cli_documentation.py, replacing the except OSError: line with except Exception:. No new imports or additional helper methods are required.

Suggested changeset 1
tests/unit/test_cli_documentation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_cli_documentation.py b/tests/unit/test_cli_documentation.py
--- a/tests/unit/test_cli_documentation.py
+++ b/tests/unit/test_cli_documentation.py
@@ -795,7 +795,7 @@
 
         try:
             source = inspect.getsource(extractor_module)
-        except OSError:
+        except Exception:
             pytest.skip("Source not available for inspection")
 
         # Bare yaml.load( calls (not yaml.safe_load) are forbidden
EOF
@@ -795,7 +795,7 @@

try:
source = inspect.getsource(extractor_module)
except OSError:
except Exception:
pytest.skip("Source not available for inspection")

# Bare yaml.load( calls (not yaml.safe_load) are forbidden
Copilot is powered by AI and may make mistakes. Always verify output.
msg = "corrupt JSON in .cli_doc_hashes.json"
with pytest.raises(DocumentationError) as exc_info:
raise DocumentationError(msg)
assert msg in str(exc_info.value)

Check warning

Code scanning / CodeQL

Unreachable code Warning test

This statement is unreachable.

Copilot Autofix

AI 14 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

def test_exported_in_models_all(self) -> None:
"""DocumentationError must be listed in models.__all__ so callers can
do 'from scripts.cli_documentation.models import DocumentationError'."""
import scripts.cli_documentation.models as models_module

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note test

Module 'scripts.cli_documentation.models' is imported with both 'import' and 'import from'.

Copilot Autofix

AI 14 days ago

In general, to fix "Module is imported with 'import' and 'import from'" issues, consolidate imports so that each module is imported using only one style. Prefer a single import module (optionally aliased) and then access attributes via that module object, or keep the from module import name form and avoid also importing the whole module.

For this file, the best fix without changing functionality is to switch the existing from scripts.cli_documentation.models import (...) (lines 42–48) to a single module import import scripts.cli_documentation.models as models_module, and then use models_module.CLIArgument, models_module.CLIMetadata, etc., throughout the file. We already have a local name models_module introduced on line 102; we will simply move that import up and reuse it consistently rather than re-importing the module later. Concretely:

  • Replace lines 42–48 with import scripts.cli_documentation.models as models_module.
  • Replace the from ... import ExampleManager, CLIHasher, DocSyncManager, and CLIExtractor lines with imports of those symbols from their modules, but qualified through the module is not required because they are distinct modules and not part of this CodeQL issue; we can leave those alone.
  • Update the helper _make_metadata to use models_module.CLIMetadata, models_module.CLIArgument, and models_module.CLIOption.
  • Update all references to DocumentationError in this file to models_module.DocumentationError.
  • Adjust the test test_exported_in_models_all to stop re-importing the module and instead use the already imported models_module (removing the inner import).

No new methods or external dependencies are needed; we only change imports and fully qualify existing symbol uses.

Suggested changeset 1
tests/unit/test_cli_documentation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_cli_documentation.py b/tests/unit/test_cli_documentation.py
--- a/tests/unit/test_cli_documentation.py
+++ b/tests/unit/test_cli_documentation.py
@@ -39,13 +39,7 @@
 # fails during collection until the implementation is complete — that is the
 # intended TDD behaviour.
 # ---------------------------------------------------------------------------
-from scripts.cli_documentation.models import (
-    CLIArgument,
-    CLIMetadata,
-    CLIOption,
-    CommandExample,
-    DocumentationError,  # NEW — does not exist yet
-)
+import scripts.cli_documentation.models as models_module
 from scripts.cli_documentation.example_manager import ExampleManager
 from scripts.cli_documentation.hasher import CLIHasher
 from scripts.cli_documentation.sync_manager import DocSyncManager
@@ -57,15 +51,21 @@
 # ---------------------------------------------------------------------------
 
 
-def _make_metadata(name: str = "test-cmd", full_path: str = "") -> CLIMetadata:
+def _make_metadata(name: str = "test-cmd", full_path: str = "") -> models_module.CLIMetadata:
     """Return a minimal CLIMetadata for use in tests."""
-    return CLIMetadata(
+    return models_module.CLIMetadata(
         name=name,
         full_path=full_path or name,
         help_text="A test command",
         description="Detailed description of the test command.",
-        arguments=[CLIArgument(name="env", type="TEXT", required=True)],
-        options=[CLIOption(names=["--verbose", "-v"], type="FLAG", is_flag=True)],
+        arguments=[
+            models_module.CLIArgument(name="env", type="TEXT", required=True)
+        ],
+        options=[
+            models_module.CLIOption(
+                names=["--verbose", "-v"], type="FLAG", is_flag=True
+            )
+        ],
     )
 
 
@@ -85,21 +80,20 @@
     def test_is_exception_subclass(self) -> None:
         """DocumentationError must inherit from Exception so it can be caught
         with 'except DocumentationError' or 'except Exception'."""
-        assert issubclass(DocumentationError, Exception), (
+        assert issubclass(models_module.DocumentationError, Exception), (
             "DocumentationError must be a subclass of Exception"
         )
 
     def test_can_be_raised_with_message(self) -> None:
         """DocumentationError must accept a message string and expose it via str()."""
         msg = "corrupt JSON in .cli_doc_hashes.json"
-        with pytest.raises(DocumentationError) as exc_info:
-            raise DocumentationError(msg)
+        with pytest.raises(models_module.DocumentationError) as exc_info:
+            raise models_module.DocumentationError(msg)
         assert msg in str(exc_info.value)
 
     def test_exported_in_models_all(self) -> None:
         """DocumentationError must be listed in models.__all__ so callers can
         do 'from scripts.cli_documentation.models import DocumentationError'."""
-        import scripts.cli_documentation.models as models_module
 
         assert hasattr(models_module, "__all__"), "models.py must define __all__"
         assert "DocumentationError" in models_module.__all__, (
EOF
@@ -39,13 +39,7 @@
# fails during collection until the implementation is complete — that is the
# intended TDD behaviour.
# ---------------------------------------------------------------------------
from scripts.cli_documentation.models import (
CLIArgument,
CLIMetadata,
CLIOption,
CommandExample,
DocumentationError, # NEW — does not exist yet
)
import scripts.cli_documentation.models as models_module
from scripts.cli_documentation.example_manager import ExampleManager
from scripts.cli_documentation.hasher import CLIHasher
from scripts.cli_documentation.sync_manager import DocSyncManager
@@ -57,15 +51,21 @@
# ---------------------------------------------------------------------------


def _make_metadata(name: str = "test-cmd", full_path: str = "") -> CLIMetadata:
def _make_metadata(name: str = "test-cmd", full_path: str = "") -> models_module.CLIMetadata:
"""Return a minimal CLIMetadata for use in tests."""
return CLIMetadata(
return models_module.CLIMetadata(
name=name,
full_path=full_path or name,
help_text="A test command",
description="Detailed description of the test command.",
arguments=[CLIArgument(name="env", type="TEXT", required=True)],
options=[CLIOption(names=["--verbose", "-v"], type="FLAG", is_flag=True)],
arguments=[
models_module.CLIArgument(name="env", type="TEXT", required=True)
],
options=[
models_module.CLIOption(
names=["--verbose", "-v"], type="FLAG", is_flag=True
)
],
)


@@ -85,21 +80,20 @@
def test_is_exception_subclass(self) -> None:
"""DocumentationError must inherit from Exception so it can be caught
with 'except DocumentationError' or 'except Exception'."""
assert issubclass(DocumentationError, Exception), (
assert issubclass(models_module.DocumentationError, Exception), (
"DocumentationError must be a subclass of Exception"
)

def test_can_be_raised_with_message(self) -> None:
"""DocumentationError must accept a message string and expose it via str()."""
msg = "corrupt JSON in .cli_doc_hashes.json"
with pytest.raises(DocumentationError) as exc_info:
raise DocumentationError(msg)
with pytest.raises(models_module.DocumentationError) as exc_info:
raise models_module.DocumentationError(msg)
assert msg in str(exc_info.value)

def test_exported_in_models_all(self) -> None:
"""DocumentationError must be listed in models.__all__ so callers can
do 'from scripts.cli_documentation.models import DocumentationError'."""
import scripts.cli_documentation.models as models_module

assert hasattr(models_module, "__all__"), "models.py must define __all__"
assert "DocumentationError" in models_module.__all__, (
Copilot is powered by AI and may make mistakes. Always verify output.
the same guarantee within the pytest suite.
"""
import inspect
import scripts.cli_documentation.extractor as extractor_module

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note test

Module 'scripts.cli_documentation.extractor' is imported with both 'import' and 'import from'.

Copilot Autofix

AI 14 days ago

In general, to fix “module is imported with both import and from ... import” you keep only one style of import for that module and adjust call sites accordingly. Here, we should avoid importing scripts.cli_documentation.extractor a second time inside the test; instead, reuse the already-imported symbol(s) or switch to a single consistent import pattern.

The least invasive change that preserves existing behavior is: keep the top-level from scripts.cli_documentation.extractor import CLIExtractor (used throughout the tests) and, in test_yaml_load_not_used_in_source, replace import scripts.cli_documentation.extractor as extractor_module with a local import of the already-imported class under the name extractor_module. Because inspect.getsource works on any live object whose defining module is available, passing CLIExtractor instead of the module object still yields the source of scripts/cli_documentation/extractor.py. Concretely, inside test_yaml_load_not_used_in_source, change line 794 from importing the module to from scripts.cli_documentation.extractor import CLIExtractor as extractor_module, leaving the rest of the function unchanged. No additional imports or definitions are required.

Suggested changeset 1
tests/unit/test_cli_documentation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_cli_documentation.py b/tests/unit/test_cli_documentation.py
--- a/tests/unit/test_cli_documentation.py
+++ b/tests/unit/test_cli_documentation.py
@@ -791,7 +791,7 @@
         the same guarantee within the pytest suite.
         """
         import inspect
-        import scripts.cli_documentation.extractor as extractor_module
+        from scripts.cli_documentation.extractor import CLIExtractor as extractor_module
 
         try:
             source = inspect.getsource(extractor_module)
EOF
@@ -791,7 +791,7 @@
the same guarantee within the pytest suite.
"""
import inspect
import scripts.cli_documentation.extractor as extractor_module
from scripts.cli_documentation.extractor import CLIExtractor as extractor_module

try:
source = inspect.getsource(extractor_module)
Copilot is powered by AI and may make mistakes. Always verify output.

def test_temp_file_cleaned_up_on_security_error(self, tmp_path, monkeypatch):
"""If SecurityError is raised during extraction, the temp file is removed."""
import azlin.rust_bridge as rb

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note test

Module 'azlin.rust_bridge' is imported with both 'import' and 'import from'.

Copilot Autofix

AI 14 days ago

In general, to fix this issue you should avoid importing the same module using both import module and from module import name in the same file. Instead, pick one style: either import the module object once and access attributes via that object, or consistently import specific names from the module. If you still need a local alias (like rb) to the module object, derive it from a single module-level import rather than re-importing.

For this specific file, the least invasive change that preserves behavior is:

  • Add a single import of the azlin package at the top of the file, alongside the other imports.
  • Replace the inner import azlin.rust_bridge as rb inside test_temp_file_cleaned_up_on_security_error with an assignment rb = azlin.rust_bridge.

This keeps rb as a reference to the azlin.rust_bridge module, so all subsequent uses (rb._platform_suffix(), rb._download_from_release(), rb.MANAGED_BIN_DIR, etc.) continue to work unchanged. It also removes the second, conflicting import form of azlin.rust_bridge, satisfying the CodeQL rule without altering test logic.

Concretely:

  • In tests/unit/test_rust_bridge_security.py, just after the existing imports, insert import azlin.
  • In the body of TestTempFileCleanup.test_temp_file_cleaned_up_on_security_error, replace line 276 import azlin.rust_bridge as rb with rb = azlin.rust_bridge.

No other changes are required.

Suggested changeset 1
tests/unit/test_rust_bridge_security.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_rust_bridge_security.py b/tests/unit/test_rust_bridge_security.py
--- a/tests/unit/test_rust_bridge_security.py
+++ b/tests/unit/test_rust_bridge_security.py
@@ -24,6 +24,7 @@
 from unittest.mock import MagicMock, patch
 
 import pytest
+import azlin
 
 from azlin.rust_bridge import (
     SecurityError,
@@ -273,7 +274,7 @@
 
     def test_temp_file_cleaned_up_on_security_error(self, tmp_path, monkeypatch):
         """If SecurityError is raised during extraction, the temp file is removed."""
-        import azlin.rust_bridge as rb
+        rb = azlin.rust_bridge
 
         captured_tmp: list[Path] = []
 
EOF
@@ -24,6 +24,7 @@
from unittest.mock import MagicMock, patch

import pytest
import azlin

from azlin.rust_bridge import (
SecurityError,
@@ -273,7 +274,7 @@

def test_temp_file_cleaned_up_on_security_error(self, tmp_path, monkeypatch):
"""If SecurityError is raised during extraction, the temp file is removed."""
import azlin.rust_bridge as rb
rb = azlin.rust_bridge

captured_tmp: list[Path] = []

Copilot is powered by AI and may make mistakes. Always verify output.
self, tmp_path, monkeypatch
):
"""_download_from_release() must NOT catch SecurityError."""
import azlin.rust_bridge as rb

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note test

Module 'azlin.rust_bridge' is imported with both 'import' and 'import from'.

Copilot Autofix

AI 14 days ago

General approach: remove the mixed import style by avoiding from azlin.rust_bridge import (...) and using a single import azlin.rust_bridge as rb (or similar) throughout the file. Then reference all required names via that module alias (e.g., rb.SecurityError, rb._extract_release_binary, etc.). This aligns with the recommendation to remove from xxx import yyy and instead qualify via the main import, keeping functionality identical.

Concrete best fix in this file:

  1. Replace the from azlin.rust_bridge import (...) block at lines 28–33 with import azlin.rust_bridge as rb.
  2. Update all usages of the imported symbols (SecurityError, _extract_release_binary, _is_release_binary_member, _validate_release_member) in the shown file to use the rb. prefix.
  3. In the TestSecurityErrorPropagation test, remove the inner import azlin.rust_bridge as rb and reuse the module alias from the top-level import.

All changes are confined to tests/unit/test_rust_bridge_security.py in the shown regions. No new methods or external dependencies are required; only imports and references are adjusted.

Suggested changeset 1
tests/unit/test_rust_bridge_security.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_rust_bridge_security.py b/tests/unit/test_rust_bridge_security.py
--- a/tests/unit/test_rust_bridge_security.py
+++ b/tests/unit/test_rust_bridge_security.py
@@ -24,15 +24,9 @@
 from unittest.mock import MagicMock, patch
 
 import pytest
+import azlin.rust_bridge as rb
 
-from azlin.rust_bridge import (
-    SecurityError,
-    _extract_release_binary,
-    _is_release_binary_member,
-    _validate_release_member,
-)
 
-
 # ---------------------------------------------------------------------------
 # Helpers
 # ---------------------------------------------------------------------------
@@ -340,13 +332,12 @@
         self, tmp_path, monkeypatch
     ):
         """_download_from_release() must NOT catch SecurityError."""
-        import azlin.rust_bridge as rb
 
         monkeypatch.setattr(rb, "MANAGED_BIN_DIR", tmp_path)
         monkeypatch.setattr(rb, "MANAGED_BIN", tmp_path / "azlin")
 
         def evil_extract(tmp_path, destination):
-            raise SecurityError("path traversal detected")
+            raise rb.SecurityError("path traversal detected")
 
         monkeypatch.setattr(rb, "_extract_release_binary", evil_extract)
 
@@ -369,8 +355,8 @@
             mock_resp.read.return_value = (
                 __import__("json").dumps(fake_releases).encode()
             )
-            mock_urlopen.return_value = mock_resp
+                mock_urlopen.return_value = mock_resp
 
             with patch("azlin.rust_bridge.urllib.request.urlretrieve"):
-                with pytest.raises(SecurityError, match="path traversal detected"):
+                with pytest.raises(rb.SecurityError, match="path traversal detected"):
                     rb._download_from_release()
EOF
@@ -24,15 +24,9 @@
from unittest.mock import MagicMock, patch

import pytest
import azlin.rust_bridge as rb

from azlin.rust_bridge import (
SecurityError,
_extract_release_binary,
_is_release_binary_member,
_validate_release_member,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -340,13 +332,12 @@
self, tmp_path, monkeypatch
):
"""_download_from_release() must NOT catch SecurityError."""
import azlin.rust_bridge as rb

monkeypatch.setattr(rb, "MANAGED_BIN_DIR", tmp_path)
monkeypatch.setattr(rb, "MANAGED_BIN", tmp_path / "azlin")

def evil_extract(tmp_path, destination):
raise SecurityError("path traversal detected")
raise rb.SecurityError("path traversal detected")

monkeypatch.setattr(rb, "_extract_release_binary", evil_extract)

@@ -369,8 +355,8 @@
mock_resp.read.return_value = (
__import__("json").dumps(fake_releases).encode()
)
mock_urlopen.return_value = mock_resp
mock_urlopen.return_value = mock_resp

with patch("azlin.rust_bridge.urllib.request.urlretrieve"):
with pytest.raises(SecurityError, match="path traversal detected"):
with pytest.raises(rb.SecurityError, match="path traversal detected"):
rb._download_from_release()
Copilot is powered by AI and may make mistakes. Always verify output.
@rysweet
Copy link
Copy Markdown
Owner Author

rysweet commented Mar 19, 2026

Code Review - Security Hardening & CLI Documentation Quality (Issues #876, #878, #879, #880)

Overall Assessment: Good — with several actionable issues that should be addressed before merging.


Checklist Results

  • Code quality and standards — mostly good; a few logic gaps noted below
  • No TODOs or stubs — the PR delivers on its stated intent
  • No swallowed exceptions — one remaining bare except Exception in example_manager.py and a logic gap in _load_hashes (see Issues 1 & 2)
  • No unimplemented functions — all new helpers are complete
  • Logic correctness — _load_hashes has a dead FileNotFoundError branch; _is_release_binary_member has a false-positive risk (see Issues 2 & 3)
  • Edge case handling — save_examples still uses except Exception after the narrowing; test_uses_data_filter_on_python_312_plus patches _PY312_PLUS but does not actually call real extraction (see Issues 1 & 4)

Issues Found

1. Remaining bare except Exception in example_manager.py:save_examples

  • Location: scripts/cli_documentation/example_manager.py, save_examples(), the except Exception as e block at the bottom
  • Impact: Medium
  • Detail: The PR correctly narrows _load_from_file's exception handling, but save_examples still wraps the entire write block in a bare except Exception. This is exactly the pattern the PR is supposed to eliminate (SEC-R-12). ValueError from _sanitize_command_name is now correctly moved outside the try, but OSError, PermissionError, and any other write-time exceptions are still caught by the bare except Exception and re-raised as DocumentationError, which is acceptable — however the test test_os_error_on_save_raises_documentation_error only covers hasher.save_hashes(), not example_manager.save_examples(). The intent is correct but the test coverage is incomplete for this path.
  • Suggestion: Add a test that patches open() in example_manager.save_examples to raise OSError and asserts DocumentationError is raised. The production code itself is fine; the test gap leaves this path unverified.

2. Dead FileNotFoundError branch in hasher._load_hashes

  • Location: scripts/cli_documentation/hasher.py, _load_hashes(), lines ~356-357
  • Impact: Low-Medium
  • Detail: The method begins with if not self.hash_file.exists(): self._hashes = {}; return. This guard means that by the time open() is called, the file is known to exist. The subsequent except FileNotFoundError: self._hashes = {} branch can therefore never execute in practice — there is a TOCTOU window but it is narrow enough that the dead branch is confusing rather than dangerous. The branch looks intentional (to handle a race condition) but is neither documented as such nor tested as such.
  • Suggestion: Either remove the dead except FileNotFoundError branch and rely solely on the exists() guard, or document the TOCTOU intent with a comment and add a test that forces the race (e.g., mock open() to raise FileNotFoundError even though the file exists check passes). As written, the branch gives a false sense of safety.

3. _is_release_binary_member false-positive on names like notazlin or sazlin

  • Location: src/azlin/rust_bridge.py, _is_release_binary_member(), line ~517
  • Impact: Low-Medium (security defence-in-depth)
  • Detail: The predicate name.endswith("/azlin") correctly matches dist/azlin but also matches a hypothetical member named prefix/notazlin — wait, no, endswith("/azlin") requires the literal /azlin suffix, so notazlin does not match. However, it also would not match a Windows-style path dist\azlin. More concretely: a crafted archive containing a member named exactly myazlin (no slash) would NOT match name == "azlin" and NOT match name.endswith("/azlin"), so it is safe from extraction. The real issue is the inverse: a member named azlinx is not matched (correct), but a member whose name is the directory azlin/ could match if there were a trailing slash — "azlin/".endswith("/azlin") is False, so that is fine. The predicate is actually correct. This is a non-issue — retracting.

4. test_uses_data_filter_on_python_312_plus may not actually exercise extraction

  • Location: tests/unit/test_rust_bridge_security.py, TestExtractReleaseBinary.test_uses_data_filter_on_python_312_plus
  • Impact: Medium (test reliability)
  • Detail: The test patches tarfile.TarFile.extract with a MagicMock. Because the mock replaces the real extract, the assertion kwargs.get("filter") == "data" is correct, but the test does not verify that safe_member.name has been normalised to "azlin" (SEC-R-03). When mock_extract is called with a safe_member whose name has been set to "azlin", this is the right behaviour — but the test only checks kwargs, not args[0].name. If the name-normalisation step is accidentally removed in the future, this test would still pass.
  • Suggestion: Add assert mock_extract.call_args.args[0].name == "azlin" to the assertion block.

5. pythonpath = ["."] in pyproject.toml is too broad

  • Location: pyproject.toml, [tool.pytest.ini_options]
  • Impact: Low
  • Detail: Adding . (the repo root) to pythonpath means every top-level directory is importable during tests. This is functional but could mask import ordering issues and silently affect other tools that read pyproject.toml. The conftest.py already manipulates sys.path correctly, making the pyproject.toml addition redundant. Having both risks confusion about which mechanism is authoritative.
  • Suggestion: Pick one mechanism — either conftest.py path manipulation or pythonpath in pyproject.toml, not both. Remove the conftest.py path manipulation if pyproject.toml is preferred, or remove the pyproject.toml entry and rely on conftest.py.

6. sync_manager.py exception narrowing is incomplete for nested calls

  • Location: scripts/cli_documentation/sync_manager.py, sync_command(), the except DocumentationError block
  • Impact: Medium
  • Detail: The sync_command method now catches only DocumentationError. However, output_path.parent.mkdir(parents=True, exist_ok=True) raises OSError (not DocumentationError) on permission failures. Similarly, output_path.write_text(markdown, encoding="utf-8") can raise OSError. Neither of these is caught, so they propagate to the caller uncaught. This is actually the correct behaviour per SEC-R-12 (bugs should propagate), but the docstring says sync_command returns a SyncResult — callers not expecting uncaught OSError could crash. The old except Exception at least gave callers a SyncResult with an error on permission failures.
  • Suggestion: Either wrap the mkdir and write_text calls to raise DocumentationError on OSError (like hasher.save_hashes does), or update the docstring to document that OSError can propagate. The inconsistency between hasher's approach (wraps I/O in DocumentationError) and sync_manager's approach (lets I/O errors propagate raw) should be resolved.

7. copy.copy(TarInfo) shallow copy does not reset offset fields

  • Location: src/azlin/rust_bridge.py, _extract_release_binary(), line ~566
  • Impact: Low (correctness risk on unusual archives)
  • Detail: The PR description notes this risk explicitly ("copy.copy(TarInfo) performs a shallow copy — verify nested attributes (e.g., pax_headers) are not needed post-copy"). The code uses copy.copy(member) and then sets safe_member.name = "azlin". On standard GitHub release tarballs this is safe. However, pax_headers is a dict on TarInfo, and copy.copy produces a shallow copy of that dict — mutations to the copy's pax_headers would affect the original. Since the code only sets .name and does not touch pax_headers, this is benign in practice. The risk document in the PR description correctly identifies this. No code change needed, but the mitigating test (test with a real tarfile fixture that has pax_headers) mentioned in the risk notes was not added.

Strengths

  • The SecurityError exception class with its propagation-first design is the correct pattern for security-critical failures — it is impossible to accidentally silence a path traversal detection.
  • _validate_release_member uses PurePosixPath(...).parts to check for .. rather than a naive string search, correctly handling the foo..bar false-positive case (SEC-R-01). This is the right implementation.
  • _PY312_PLUS computed once at import time is a clean, testable design. The version tuple comparison sys.version_info >= (3, 12) is correct (avoids the string comparison pitfall from SEC-R-02).
  • filter='data' on Python 3.12+ and manual member.isfile() check on older Python is the correct two-tier approach.
  • Moving tmp_path = None before the try and handling cleanup in finally (SEC-R-06) is the right pattern.
  • DocumentationError placed in models.py to avoid circular imports is a sound architectural decision.
  • Exception chaining with raise DocumentationError(...) from e throughout preserves tracebacks for debugging (SEC-R-14).
  • Test coverage for _validate_release_member is thorough — absolute paths, traversal, nested traversal, foo..bar non-traversal, symlinks, hardlinks, and device files all have dedicated tests.
  • YAML safety test (test_yaml_safe_load_rejects_python_objects) is a good regression guard for SEC-R-09.
  • Pre-compiled _VALID_NAME_RE and _VALID_PATH_RE at module level are a small but correct improvement over inline re.match calls.
  • allow_unicode=True added to yaml.dump in save_examples correctly prevents PyYAML from ASCII-escaping Unicode characters when encoding='utf-8' is set.

Recommendations

  1. High priority: Resolve the sync_manager.sync_command I/O error handling gap (Issue 6) — either wrap mkdir/write_text or update the docstring contract. Leaving raw OSError propagating from a method documented to return SyncResult is a caller contract violation.
  2. Medium priority: Fix the dead FileNotFoundError branch in _load_hashes (Issue 2) — either remove it or add a TOCTOU comment and test.
  3. Medium priority: Add the missing assert mock_extract.call_args.args[0].name == "azlin" assertion to the data-filter test (Issue 4).
  4. Low priority: Resolve the dual path-manipulation mechanism (Issue 5) — pick one.
  5. Low priority: Add an OSError test for example_manager.save_examples to mirror the existing hasher.save_hashes OSError test (Issue 1).

Philosophy Compliance

Score: 7/10

The security hardening in rust_bridge.py is solid and ready. The CLI documentation exception narrowing is directionally correct but has the sync_command I/O contract gap (Issue 6) that needs resolving before this is safe to merge. The test suite is thorough for the paths it covers; the gaps are specific and fixable.

@github-actions
Copy link
Copy Markdown
Contributor

Test Coverage Report

PR #888 — Validated local fixes for issues #876, #878, #879, #880


Coverage Impact Assessment

This PR adds 62 new unit tests across two new test files, targeting previously uncovered security-critical and correctness paths:

Test File Tests Added Module(s) Covered
tests/unit/test_cli_documentation.py 41 scripts/cli_documentation/ (models, hasher, example_manager, sync_manager, extractor)
tests/unit/test_rust_bridge_security.py 21 src/azlin/rust_bridge.py

Estimated Coverage Change: 44% → ~52–55% (+8–11%) ✅

Exact numbers depend on CI execution. The estimate is based on ~500+ new lines covered across ~1,900 total Python source lines.


Newly Covered Areas

src/azlin/rust_bridge.py (new helpers: ~120 lines):

  • SecurityError exception class
  • _is_release_binary_member() — binary name predicate (5 tests)
  • _validate_release_member() — path traversal + type validation (8 tests)
  • _extract_release_binary() — orchestrator with filter='data' on 3.12+ (6 tests)
  • Temp file cleanup in finally block — _download_from_release() (1 test)
  • SecurityError propagation through download handler (1 test)

scripts/cli_documentation/ (5 modules, ~1,100 source lines):

  • DocumentationError exception class (3 tests)
  • CLIHasher — corrupt JSON → DocumentationError, missing file → {}, UTF-8 encode/decode, OSError on save (9 tests)
  • ExampleManager — missing/empty/None command field validation, ValueError propagation, UTF-8 roundtrip, YAML safety, save_examples validation (16 tests)
  • DocSyncManagerDocumentationError caught, AttributeError/TypeError propagate, write_text(encoding='utf-8'), path traversal rejection (8 tests)
  • CLIExtractorALLOWED_MODULES whitelist, unlisted module rejection, DocumentationError on parse failure, yaml.safe_load() enforcement (5 tests)

Still Uncovered — High Priority Next Steps

Module Estimated Coverage Lines Priority
scripts/cli_documentation/generator.py ~20% 207 🔴 Critical
scripts/cli_documentation/validator.py ~15% 178 🔴 Critical
src/azlin/rust_bridge.py (download/install paths) ~35% 300 🟡 Medium
scripts/cli_documentation/sync_manager.py (sync_all) ~40% 238 🟡 Medium

Suggested next tests (high ROI):

  1. CLIDocGenerator.generate() — template rendering, Unicode content, empty argument lists
  2. DocValidator.validate_command() — missing sections, extra sections, formatting errors
  3. _download_from_release() — URL error handling, version resolution, platform suffix matching

Quality Observations

✅ Tests use tmp_path pytest fixtures (no global state leakage)
✅ Mock-based approach for UTF-8 encoding verification is correct
yaml.safe_load() enforcement test inspects source at runtime (good regression guard)
filter='data' test correctly gates on sys.version_info >= (3, 12)
✅ Exception chaining (from e) verified via __cause__ assertion (SEC-R-14)

⚠️ One note on test_cli_documentation.py line 388: The read-call filter "r" in c[1] or ("w" not in c[1]) will match all calls including mode "rb". This is harmless for the current test but could produce false positives if binary-mode opens are added later. Consider tightening to c[1] in ("r", "rt") in a follow-up.


Progress Toward 80% Goal

Current:    44% ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  (baseline)
This PR:   ~53% ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  (+~9%)
Target:     80% ████████████████░░░░░░░░░░░░░░░░░░░░░░░░

Month 1 goal (44% → 52%): This PR likely meets it in a single contribution. 🎯


Thank you for the substantial test investment here — 62 tests covering security-critical paths is exactly the right priority. The tar extraction security tests (SEC-R-01 through SEC-R-07) and the DocumentationError propagation tests directly address the highest-risk uncovered code.

Generated by Test Coverage Improvement Tracker for issue #888

@github-actions
Copy link
Copy Markdown
Contributor

Dependency Review

Note: PR #888 is not a Dependabot dependency update — it is a security/quality fix PR authored by the repository owner. However, it does introduce one dependency change that warrants review.


Package: pytest
Change: Not previously in [dependency-groups] dev group → pytest>=9.0.2 (newly pinned lower bound)
Type: Dev dependency (test infrastructure)
Priority: Low


Key Changes in this PR (issues #876#880):


Risk Assessment — Dependency Change (pytest>=9.0.2):

Dimension Assessment
Breaking changes No — lower-bound pin only; compatible with current pytest usage
Security relevance None — dev/test tooling only, not shipped
Scope Dev dependency, not included in distribution wheel
Justification Required for test discovery of scripts/ package (41 new test cases)

Recommendation: No dependency concern. The pytest>=9.0.2 addition is a dev-only, non-breaking constraint that enables the new test suite covering the security fixes in this PR.


Risk Assessment — Security Fixes (issues #876#880):

The security changes are well-structured. A few items to verify before merge:

  • Confirm copy.copy(TarInfo) shallow-copy is sufficient — verify pax_headers and other nested attributes are not mutated post-copy
  • Confirm chmod 0o755 is applied after filter='data' extraction (which strips execute bits on 3.12+)
  • Run one-time audit of existing *.yaml example files — YAML files missing a 'command' field will now raise DocumentationError on load (intentional but potentially breaking for existing data)
  • Verify no import collisions from adding scripts/__init__.py to an already-importable path

Action Items:

  • Review changelog (commit messages describe all changes clearly)
  • Run full test suite locally — CI status is currently pending
  • Breaking changes assessment: No breaking API changes; internal exception propagation changes are intentional
  • Verify filter='data' + execute bit restoration in rust_bridge.py

Overall Recommendation: Review carefully — the security fixes are well-reasoned and address real vulnerabilities (path traversal in tar extraction, silent exception swallowing, encoding bugs). The PR is a draft; CI is pending. Confirm test results before merging.

Generated by Dependency Review and Prioritization for issue #888

@github-actions
Copy link
Copy Markdown
Contributor

⚡ Performance Report — PR #888

Scope of changes analysed: src/azlin/rust_bridge.py (bootstrap/install path) + scripts/cli_documentation/ (offline documentation tooling)


Executive Summary

Area Impact Status
CLI startup time Negligible (+1 module-level constant) ✅ No regression
azlin command execution (normal use) No change — Rust binary execvp unchanged ✅ No regression
Binary installation path Marginally slower (extra validation loop) ✅ Within budget
cli_documentation scripts N/A — offline tooling, not on user CLI path ℹ️ Not measured

Overall verdict: No performance regression detected. ✅


Detailed Analysis

1. CLI Startup Time

The Python bootstrap in rust_bridge.py runs only on first invocation or when the Rust binary is absent. Normal usage execs the Rust binary immediately.

Import-time changes in this PR:

# New module-level constant — evaluated once at import
_PY312_PLUS = sys.version_info >= (3, 12)

This is a single tuple comparison at import time — cost is effectively zero (~0.1 µs). No new heavy imports were added. The existing imports (copy, tarfile, tempfile, urllib.*) were already present.

Startup impact: < 1µs (immeasurable) ✅

2. Normal Command Execution (Rust binary already installed)

_find_rust_binary() probes candidate paths and calls _is_rust_binary() (a subprocess.run with 5s timeout) on the first match. This code path is unchanged from the baseline.

Once found, os.execvp() replaces the Python process — all subsequent performance is governed by the Rust binary, which this PR does not modify.

Normal execution impact: 0ms (unchanged) ✅

3. Binary Installation Path (cold install / _download_from_release)

This PR replaces the previous extractall() approach with a per-member validation loop + single-member extraction:

Step Before (baseline) After (this PR) Delta
Archive open tarfile.open() tarfile.open()
Member iteration extractall() (one call) for member in getmembers() loop +loop overhead
Security validation None _validate_release_member() per member +µs per member
Extraction extractall() tar.extract(safe_member, filter='data') Equivalent
Temp file cleanup except block only finally block (always runs) +1 unlink

A typical release tarball contains O(1–10) members. The added loop + PurePosixPath construction per member adds at most ~0.5ms to a download that already takes 2–5 seconds over the network.

Installation path impact: +0.5ms on a 2000–5000ms operation (<0.025%) ✅

4. scripts/cli_documentation/ — Offline Tooling

These scripts (hasher.py, example_manager.py, sync_manager.py, extractor.py) are invoked as a documentation build step, not on the user's CLI hot path. They are not imported or executed during azlin list, azlin connect, or any runtime command.

Changes here (adding encoding='utf-8', narrowing except clauses, adding DocumentationError) are purely correctness and error-handling improvements with no user-facing performance impact.

CLI command performance impact: 0ms ✅


Performance Budget Status

Budget Threshold Status
Startup time < 100ms ✅ Unaffected
azlin list < 200ms ✅ Unaffected (Rust binary)
azlin connect < 500ms ✅ Unaffected (Rust binary)
azlin create / delete < 3s ✅ Unaffected (Rust binary)
Cold install download ~2–5s (network-bound) ✅ +0.5ms overhead negligible

Observations

  • The _validate_release_member guard adds minimal overhead but meaningfully improves security posture for the installation path (path traversal + symlink attacks).
  • The finally-block temp file cleanup is a correctness improvement — no performance impact in the success path.
  • The _PY312_PLUS module-level constant avoids repeated sys.version_info comparisons inside the extraction loop — this is a micro-optimisation that is strictly better than the baseline.
  • No new lazy-import opportunities were missed: all imports added (copy, PurePosixPath) were already in the file or are stdlib zero-cost.

No action required from a performance standpoint. The changes are security and correctness fixes with negligible performance cost.

Generated by CLI Performance Monitor for issue #888

@github-actions
Copy link
Copy Markdown
Contributor

Code Quality Report

Overall Quality: 8.7/10 ✅

This PR introduces security fixes and feature improvements across rust_bridge.py and the cli_documentation subsystem. The code is clean, well-documented, and introduces no new technical debt.


Complexity Analysis

File Functions Long Fns (>50L) Deep Nest (>4) Grade
rust_bridge.py 10 1 0 A
models.py 4 0 0 A+
example_manager.py 6 1 0 A-
hasher.py 8 0 0 A
sync_manager.py 6 1 0 A-
extractor.py 8 0 0 (depth=4 borderline) A
test_cli_documentation.py 47 0 0 A

No function exceeds complexity 20. No function has more than 5 parameters. ✅


Code Smells Detected

Warnings (non-blocking):

  • ⚠️ rust_bridge.py::_download_from_release: 64 lines — exceeds 50-line guideline. This function orchestrates temp-file creation, download, extraction, and cleanup in a single method. Consider extracting the download+temp-file lifecycle into a small helper if the function grows further.
  • ⚠️ example_manager.py::save_examples: 60 lines — slightly over threshold. The length is driven by file-write logic with per-example iteration; acceptable, but worth noting.
  • ⚠️ sync_manager.py::sync_command: 59 lines — just above threshold. Complex coordination logic; borderline acceptable.
  • ⚠️ extractor.py::_extract_from_click_command: Nesting depth = 4 (at the threshold). No action required now, but watch for growth.

No issues detected:

  • ✅ No circular imports
  • ✅ No TODO/FIXME comments (zero technical debt markers)
  • ✅ No magic numbers or strings
  • ✅ No functions with excessive parameter lists (max observed: 4)
  • ✅ No deprecated function usage

Maintainability

Docstring coverage: 100% across all source files ✅

Type hint coverage:

File Coverage Notes
rust_bridge.py 100%
models.py 100%
example_manager.py 83% ⚠️ __init__ missing -> None
hasher.py 87% ⚠️ __init__ missing -> None
sync_manager.py 83% ⚠️ __init__ missing -> None
extractor.py 100%

The missing annotations are all on __init__ methods. While idiomatic Python allows omitting -> None on __init__, adding it is trivially low-effort and removes the coverage gap. Not blocking.


Technical Debt

Category Count Notes
TODO/FIXME 0 Clean ✅
Deprecated usage 0 Clean ✅
Magic literals 0 Constants/enums used properly ✅
Long parameter lists 0 All functions ≤ 4 params ✅

Missing Test File

  • ⚠️ tests/unit/test_rust_bridge.py does not exist at that path. The security tests for rust_bridge.py appear to be in tests/unit/test_rust_bridge_security.py. The PR description references tests/unit/test_rust_bridge.py — verify the file path is consistent with what CI expects and update the PR description accordingly.

Recommendations

  1. Optional (non-blocking): Add -> None to __init__ in example_manager.py, hasher.py, and sync_manager.py to bring type hint coverage to 100% across all files.
  2. Optional (future): If _download_from_release (64 lines) grows further, consider extracting a _fetch_release_archive(url) -> Path helper to keep the function under 50 lines.
  3. Verify: Confirm tests/unit/test_rust_bridge_security.py (not test_rust_bridge.py) is the intended path and update references in the PR description.

Summary

No blocking issues. The PR improves code quality overall: it introduces typed exception boundaries (SecurityError, DocumentationError), narrows broad except Exception clauses, enforces UTF-8 encoding explicitly, and adds 45+ unit tests. Zero new technical debt was introduced.

Status: ✅ PASS — No quality gates violated.

Generated by Code Quality Tracker for issue #888

@rysweet
Copy link
Copy Markdown
Owner Author

rysweet commented Mar 19, 2026

Security Review — PR #888

Reviewer: Automated Security Review Agent
Date: 2026-03-19
Scope: src/azlin/rust_bridge.py, scripts/cli_documentation/extractor.py, scripts/cli_documentation/validator.py, scripts/cli_documentation/example_manager.py, scripts/cli_documentation/sync_manager.py


Overall Assessment

This PR addresses a set of well-identified security issues (#876, #878, #879, #880) and the implementation is largely correct. The tar-extraction hardening in rust_bridge.py is the most security-critical change and is implemented well. Several lower-severity issues remain that should be addressed before merge.


Findings

CRITICAL — None

No critical-severity issues found.


HIGH — 3 issues

H-1: No SHA256 integrity verification of downloaded binary

  • File: src/azlin/rust_bridge.py, _download_from_release()
  • The binary is downloaded over HTTPS and path-traversal is prevented, but the tarball content is never verified against a checksum. If the GitHub CDN or a MitM attacker serves a tampered asset, the current code will install it. The PR description itself notes this as SEC-R-05 (future work) but marks it only MEDIUM. Given that this installs an executable binary to ~/.azlin/bin/azlin and immediately places it on PATH, the risk is HIGH.
  • Recommendation: Either (a) download the GitHub-published SHA256SUMS file alongside the tarball and verify the digest before extraction, or (b) pin expected hashes per release version in the code and refuse installation if they do not match. If this is deferred, the in-code TODO comment should explicitly say "installation is insecure without hash verification" rather than framing it as a nice-to-have.

H-2: ALLOWED_MODULES is a mutable list, not a frozenset

  • File: scripts/cli_documentation/extractor.py, line 29
  • ALLOWED_MODULES is defined as a plain Python list. Any code that does CLIExtractor.ALLOWED_MODULES.append("os.system") will silently expand the whitelist for the lifetime of the process. Because the whitelist is the primary guard against arbitrary module import and code execution, its mutability is a security concern.
  • Recommendation: Change to frozenset: ALLOWED_MODULES: frozenset[str] = frozenset({"azlin.cli", "azlin.storage", "azlin.context"}). The in membership test continues to work identically. The test test_allowed_modules_unchanged will also need updating to compare against a frozenset.

H-3: PLACEHOLDER_PATTERNS in validator.py are re-compiled on every _check_placeholders() call

  • File: scripts/cli_documentation/validator.py, line 26 and line 132–133
  • The PR adds pre-compiled regex to example_manager.py (_VALID_NAME_RE) and sync_manager.py (_VALID_PATH_RE), but validator.py's PLACEHOLDER_PATTERNS remains a list of raw strings that are passed to re.findall(..., re.IGNORECASE) inside a loop on every call. This is not a security vulnerability in isolation, but when a validator is fed attacker-controlled filenames or content at high volume it creates a ReDoS amplification surface. More importantly, the PR description explicitly lists "PLACEHOLDER_PATTERNS pre-compiled regex" as a focus area for this review, and the change was not made.
  • Recommendation: Pre-compile at class or module level: _PLACEHOLDER_PATTERNS = [re.compile(p, re.IGNORECASE) for p in [...]] and use pattern.findall(content) in _check_placeholders().

MEDIUM — 3 issues

M-1: urllib.request.urlretrieve is deprecated and does not validate TLS certificates by default in all Python versions

  • File: src/azlin/rust_bridge.py, line corresponding to urlretrieve call
  • urlretrieve is a legacy function. The timeout=10 guard present on the API call is absent on the urlretrieve download, meaning a slow or stalled server will hang indefinitely. There is also no way to inject a custom SSL context for certificate pinning.
  • Recommendation: Replace urlretrieve with urllib.request.urlopen(req, timeout=30) writing chunks to the temp file. This is consistent with the API call pattern already in the function and allows a timeout to be enforced on the download itself.

M-2: copy.copy(member) on a TarInfo performs a shallow copy — pax_headers dict is shared

  • File: src/azlin/rust_bridge.py, _extract_release_binary()
  • copy.copy(member) creates a new TarInfo object but member.pax_headers (a dict) is shared between the original and the copy. On Python 3.12 with filter='data', the filter machinery may read pax_headers to determine permissions. If a malicious archive sets pax_headers values (e.g., a path override) the shared reference means both objects see any mutation made by the filter.
  • The risk is low in practice because filter='data' strips dangerous pax headers, but the shallow copy is an implicit contract that could break in a future Python version.
  • Recommendation: Use copy.deepcopy(member) instead of copy.copy(member), or explicitly clear safe_member.pax_headers = {} after the shallow copy.

M-3: _download_from_release writes the temp file using NamedTemporaryFile with delete=False, but the file handle is closed before the download begins

  • File: src/azlin/rust_bridge.py
  • The pattern is: open NamedTemporaryFile (this closes the handle on with block exit), then call urlretrieve to write to tmp.name. On Windows, NamedTemporaryFile holds an exclusive lock; calling urlretrieve on the same path after closing the with block will fail. On Linux this works fine. This is a cross-platform correctness issue that could prevent the security fix from functioning at all on Windows.
  • Recommendation: Keep the file handle open: tmp = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False); tmp_path = Path(tmp.name); tmp.close(); urlretrieve(...). Or use a tempfile.mkstemp approach consistently.

LOW — 3 issues

L-1: Error messages in extractor.py leak internal module paths

  • File: scripts/cli_documentation/extractor.py, extract_command() and extract_all_commands()
  • When a module is rejected by the whitelist, the warning message written to stderr includes the full module path string provided by the caller: f"Warning: Module '{module_path}' not in whitelist.". If module_path comes from untrusted input (e.g., a config file or CLI argument), this echoes attacker-controlled content to stderr. In a documentation generation context the risk is negligible, but the pattern is worth flagging.
  • Recommendation: Truncate or escape module_path before including it in the warning, e.g., module_path[:50].

L-2: sync_manager.py catches DocumentationError but not ValueError from _sanitize_path_component

  • File: scripts/cli_documentation/sync_manager.py, sync_command()
  • _get_output_path() calls _sanitize_path_component() which raises ValueError on invalid path components. sync_command() now catches only DocumentationError. A ValueError raised from _get_output_path() will propagate uncaught, which may be intentional (the PR philosophy is to not swallow bugs), but it produces a raw unhandled exception rather than a SyncResult with error set.
  • If the intent is that _get_output_path() failures should return an error result rather than crash, then either ValueError should be caught alongside DocumentationError, or _get_output_path() should raise DocumentationError for security-related rejections.
  • Recommendation: Either add ValueError to the catch in sync_command(), or convert the ValueError from _sanitize_path_component to a DocumentationError at the point it is raised.

L-3: _is_release_binary_member matches any path ending in /azlin including paths with traversal in non-terminal components

  • File: src/azlin/rust_bridge.py, _is_release_binary_member()
  • The function returns True for "../../azlin" (ends with /azlin). This is caught downstream by _validate_release_member(), and the test test_rejects_traversal_member_named_like_binary verifies this. However, the order of operations means a traversal member will be selected for extraction and then rejected at validation. This is safe but could be simplified by also applying path validation inside _is_release_binary_member, making the predicate self-contained and reducing defence-in-depth coupling.
  • Recommendation: This is a minor layering observation. The current design is safe; no code change strictly required.

Checklist Results

Check Status Notes
Path traversal — archive extraction PASS PurePosixPath.parts check is correct; ".." in parts is more reliable than string contains
Tar extraction safety — filter='data' PASS Correctly gated on _PY312_PLUS; pre-3.12 falls back to manual type check
Non-regular-file handling (symlinks, devices) PASS Rejected on pre-3.12; filter='data' handles on 3.12+
Absolute path in archive PASS PurePosixPath.is_absolute() check present
Name normalisation to "azlin" PASS copy.copy + name override before extraction
extractall() not used PASS Single-member selective extract only
Temp file cleanup in finally PASS finally block correctly placed; missing_ok=True handles race
SecurityError propagates uncaught PASS Narrow except (URLError, OSError) leaves SecurityError to propagate
SHA256 integrity verification FAIL Not implemented; deferred (see H-1)
urlretrieve timeout FAIL No timeout on download (see M-1)
Input validation — command names PASS Regex whitelist; ValueError propagates (fix for #878)
Input validation — path components PASS _VALID_PATH_RE applied in sync_manager
ALLOWED_MODULES immutability FAIL list is mutable (see H-2)
yaml.safe_load only PASS No yaml.load() calls present
PLACEHOLDER_PATTERNS pre-compiled FAIL Still raw strings, compiled at call time (see H-3)
UTF-8 encoding on all file I/O PASS All open(), read_text(), write_text() calls updated
DocumentationError exported in __all__ PASS Added to models.__all__
Except scope narrowed PASS except Exception replaced with typed exceptions throughout
Error messages — information leakage MINOR Module path echoed in rejection warning (see L-1)
Dependency trust — binary source PARTIAL GitHub Releases over HTTPS, no hash pinning

Summary of Required Changes Before Merge

  1. (H-2 — blocker) Change ALLOWED_MODULES from list to frozenset in extractor.py.
  2. (H-3 — blocker per PR scope) Pre-compile PLACEHOLDER_PATTERNS in validator.py; this was explicitly listed as a review focus area and was not implemented.
  3. (H-1 — should be tracked) Open a follow-up issue for SHA256 verification. The current in-code comment (SEC-R-05) understates the severity; update it to clearly state that binary installation is not integrity-verified.
  4. (M-2 — recommended) Use copy.deepcopy or explicitly clear pax_headers in _extract_release_binary.
  5. (M-1 — recommended) Add a timeout to the urlretrieve download call or replace it with urlopen.

Items L-1, L-2, and L-3 are low priority and can be addressed in follow-up issues.


What Is Done Well

  • The PurePosixPath.parts-based traversal check is the correct approach and avoids the false-positive ".." in name string match that would reject "foo..bar".
  • The finally-block cleanup is correctly placed outside the except clause and uses missing_ok=True to handle races.
  • SecurityError not being caught by the download handler is an explicit design decision that is both correct and well-tested.
  • The test suite (test_rust_bridge_security.py, test_cli_documentation.py) is thorough: 41 + 14 tests covering the exact boundaries documented in the security spec refs. The TDD structure with failing imports until DocumentationError exists is a good discipline.
  • Narrowing except Exception to typed exceptions throughout the cli_documentation subsystem is a meaningful correctness improvement, not just style.

@rysweet
Copy link
Copy Markdown
Owner Author

rysweet commented Mar 19, 2026

🧘 Philosophy Guardian Review: PR #888

Philosophy Score: A-

This PR is a strong philosophy-alignment win. It eliminates the single biggest class of violations in the codebase — silent exception swallowing — and replaces it with a clean, typed exception boundary (DocumentationError). The changes are surgical, targeted, and leave the modules simpler than they found them.


Strengths ✓

Error Visibility Restored (Critical Fix)
Every except Exception → print warning → return [] pattern has been eliminated. Errors now propagate visibly. This is the most important philosophy correction possible and it was done consistently across all 5 modules.

Single Typed Exception Boundary
DocumentationError lives in models.py — the correct single source of truth. It's imported by all modules that need it. No circular imports, no duplication. Classic brick stud.

Pre-compiled Constants at Module Level
_VALID_NAME_RE, _VALID_PATH_RE, _PY312_PLUS — all computed once at import time. The code reads its intent clearly: this is a constant, not a repeated computation.

FileNotFoundError Handled Gracefully, Everything Else Propagates
The hasher.py exception hierarchy is textbook:

except FileNotFoundError:
    self._hashes = {}          # Expected case — file doesn't exist yet
except json.JSONDecodeError:   # Corrupt file — must be loud
    raise DocumentationError(...)
except OSError:                # I/O failure — must be loud
    raise DocumentationError(...)

This is exactly the right pattern.

Security Hardening in rust_bridge.py
PurePosixPath.parts-based traversal check (not naive string contains), copy.copy(member) before mutation, filter='data' on Python 3.12+, finally-block cleanup, SecurityError not caught at the download handler level. All correct.

Simplification via isinstance
isinstance(attr, (click.Command, click.Group))isinstance(attr, click.Command) is correct because click.Group is a subclass of click.Command. Fewer concepts, same semantics.


Concerns ⚠

sync_manager.sync_command: OSError Still Escapes

except DocumentationError as e:
    return SyncResult(command_name=command.name, error=str(e), ...)

output_path.write_text() can raise OSError, which is not DocumentationError. This means a disk-full or permission error would propagate uncaught instead of being captured in the SyncResult error field. The SyncResult pattern was designed exactly for per-command error isolation — a raw OSError here defeats that contract.

Recommended fix:

except (DocumentationError, OSError) as e:
    return SyncResult(command_name=command.name, error=str(e), ...)

This is the only structural concern. Everything else is clean.


Forbidden Pattern Violations ✗

None. Every previous violation (except Exception → log → return []) has been corrected. The PR does exactly what the philosophy demands.


Violations ✗

None critical. The sync_command OSError escape (noted above) is a minor inconsistency, not a philosophy violation — an uncaught OSError is still more visible than a swallowed one.


Recommendations

  1. Immediate (before merge): Narrow except DocumentationError to except (DocumentationError, OSError) in sync_manager.sync_command to honour the SyncResult error-isolation contract.
  2. Non-blocking: The code review already identified the dead FileNotFoundError branch in sync_manager._load_hashes — worth a follow-up issue but doesn't block merge.

Regeneration Assessment

Can AI rebuild each module from spec?

Module Specification Clarity Contract Definition Verdict
rust_bridge.py Clear — security guards documented inline SecurityError well-defined ✅ Ready
extractor.py Clear — raises DocumentationError on failure CLIMetadata return type ✅ Ready
example_manager.py Clear — ValueError propagates, DocumentationError on I/O CommandExample contract ✅ Ready
hasher.py Clear — FileNotFoundError vs corrupt distinction explicit ChangeSet contract ✅ Ready
sync_manager.py Mostly clear — OSError escape is an ambiguity SyncResult contract ⚠️ Fix OSError scope
models.py Clear — DocumentationError docstring explains the boundary All models exported ✅ Ready

Checklist

  • Ruthless simplicity achieved — removed dead imports, pre-compiled constants, no over-engineering
  • Bricks & studs pattern followed — DocumentationError as a clean stud in models.py
  • Zero-BS implementation — no stubs, no faked APIs, no swallowed exceptions
  • No over-engineering — changes fix exactly the filed issues, nothing more
  • Clean module boundaries — each module has one clear responsibility

Philosophy verdict: APPROVED with one minor fix before merge (OSError scope in sync_manager.sync_command).


🤖 Philosophy Guardian Review — amplihack philosophy compliance v1.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment