diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3348a0fc..9cb2ccff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,7 +60,7 @@ repos: rev: 1.7.5 hooks: - id: bandit - args: ['-r', 'src/'] + args: ['-r', 'src/', 'scripts/'] exclude: tests/ # Dependency vulnerability checking is handled by safety/Bandit in CI @@ -105,6 +105,22 @@ repos: fail_fast: false verbose: true + # Safe logging guard: no raw f-string logging outside src/tui/ + - id: safe-logging-guard + name: No raw f-string logging (use log_*_safe + safe_format) + entry: scripts/check_safe_logging.sh + language: system + files: '^src/.*\.py$' + pass_filenames: false + + # Test import hygiene: no 'from src' / module-level sys.path.insert + - id: test-import-hygiene + name: Clean test imports (pcileechfwgenerator.*, no sys.path hacks) + entry: scripts/check_test_imports.sh + language: system + files: '^tests/.*\.py$' + pass_filenames: false + # Configuration for specific hooks ci: autofix_commit_msg: | diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c2f2d2a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,438 @@ +# AGENTS.md — PCILeech Firmware Generator + +This file is a concentrated reference for AI coding agents working in the +`PCILeechFWGenerator` repository. It assumes you know nothing about the project +and need to make safe, useful changes quickly. + +--- + +## Project overview + +**PCILeechFWGenerator** generates authentic PCIe DMA firmware bitstreams by +cloning the PCIe configuration of a real donor device. The output is a Xilinx +FPGA bitstream for PCILeech-family boards (e.g., PCIeSquirrel, EnigmaX1, +ScreamerM2, ZDMA, CaptainDMA, GBOX, NeTV2, AC701/FT601, Acorn/FT2232H, +SP605/FT601). + +- **Author:** Ramsey McGrath +- **License:** MIT +- **Language:** Python 3.11+ (supports 3.11 and 3.12) +- **Operating system:** POSIX Linux only — VFIO is required for Stage 1 data +collection. macOS, Windows, and WSL2 are not supported. +- **Repository:** +- **Documentation site:** + +The tool is deliberately designed around **real donor hardware**. Synthetic donor +profiles or placeholder IDs (`0xDEAD`, `0xBEEF`, `0x1234`, `0xFFFF`, etc.) are +considered regressions, and the codebase actively rejects them. + +Donor IDs are validated at multiple gates. Beyond the missing/zero check, builds +also reject **known synthetic placeholder pairs** (`KNOWN_PLACEHOLDER_IDS` in +`src/device_clone/constants.py`, enforced via `is_placeholder_donor_id`) such as +the fabricated Intel I210 default `0x8086:0x1533`, the Realtek `0x10EC:0x8168` +default, and the Xilinx FIFO defaults. A few of these pairs are also real device +IDs; cloning a genuine such device requires setting +`PCILEECH_ALLOW_PLACEHOLDER_IDS=1`, which bypasses **only** the placeholder-pair +check (never the missing/zero check) and must not be used for shippable builds. + +--- + +## Three-stage pipeline + +All functionality is organized around a host-container-host pipeline: + +1. **Stage 1 — Host collect** (`src/host_collect/`, `src/cli/vfio*.py`) + Reads the donor device's VID/PID, BARs, capability chain, MSI-X tables, and + timing behavior from a Linux host via VFIO. Requires root. + +2. **Stage 2 — Templating** (`src/templating/`, `src/templates/`) + Renders Jinja2 templates into SystemVerilog, TCL, XDC, COE, and HEX files + using the collected donor profile. This can run locally or inside an + isolated Podman container. The container does **not** access VFIO. + +3. **Stage 3 — Vivado build** (`src/vivado_handling/`) + Runs Xilinx Vivado synthesis to produce a `.bit` bitstream. Requires a + working Vivado install (WebPACK is sufficient for 7-series; UltraScale needs + a paid license). + +--- + +## Key configuration files + +| File | Purpose | +|------|---------| +| `pyproject.toml` | Project metadata, dependencies, entry points, setuptools-scm, pylint config | +| `setup.cfg` | Legacy flake8 and PyScaffold metadata only | +| `pytest.ini` | pytest test paths, markers, coverage defaults, asyncio mode | +| `tox.ini` | tox environments for test, build, clean, docs | +| `Makefile` | Primary developer interface: install, test, lint, format, build, container, release | +| `.pre-commit-config.yaml` | black, isort, flake8, mypy, bandit, pydocstyle, prettier, custom template validation | +| `Containerfile` | Ubuntu 24.04 multi-stage image for Stage 2 container builds | +| `entrypoint.sh` | Container entrypoint; conditionally skips VFIO ops when running in host-context-only mode | +| `MANIFEST.in` | Curates files bundled into the sdist/wheel, including `lib/voltcyclone-fpga` | +| `cliff.toml` | `git-cliff` configuration; release notes and `CHANGELOG.md` are generated from conventional commits | +| `configs/fallbacks.yaml` | Last-resort defaults for non-sensitive template variables; critical IDs must never have fallbacks | +| `.coveragerc` | Coverage.py source mapping and exclusions | + +--- + +## Technology stack + +### Core runtime dependencies + +- `psutil>=5.9.0` +- `pydantic>=2.0.0` +- `aiofiles>=23.0.0` +- `jinja2>=3.1.0` +- `PyYAML>=6.0.0` +- `colorlog>=6.7.0` +- `typing_extensions>=4.0.0` + +### Optional extras + +- `[tui]` — `textual>=4.0.0`, `rich>=13.0.0`, `watchdog>=3.0.0` +- `[testing]` / `[test]` — pytest, pytest-cov, pytest-mock, pytest-xdist, pytest-asyncio, packaging +- `[dev]` — testing + TUI + black, isort, flake8, flake8-docstrings, flake8-import-order, mypy, pre-commit, build, twine, wheel, bandit, safety + +### External tooling + +- Xilinx Vivado 2022.2+ for Stage 3 +- Podman/Docker for the optional Stage 2 container +- `git-cliff` for changelog generation +- `lspci`, `setpci`, kernel VFIO modules for Stage 1 + +--- + +## Code organization + +The package `pcileechfwgenerator` maps to the `src/` directory via +`pyproject.toml` (`package-dir = {"pcileechfwgenerator" = "src"}`). + +```text +src/ +├── pcileech_main.py # Installed-package CLI entry point +├── __init__.py # Curated public API +├── __version__.py # Runtime version resolver (setuptools-scm) +├── pcileech.py (root) # Unified source-checkout entry point +├── build.py # FirmwareBuilder and BuildConfiguration +├── cli/ # Argument parsing, VFIO binder, diagnostics, container helpers, flash +├── host_collect/ # Stage 1: VFIO-based donor extraction +├── device_clone/ # Donor profile parsing, BAR sizing, MSI-X, config space, board config +├── pci_capability/ # PCIe capability list construction and analyzers +├── templating/ # Stage 2: Jinja2 renderer, TCL builder, SV generators, context validator +├── templates/ # Jinja2 sources (sv/*.j2, python/*.j2, _helpers.j2) +├── vivado_handling/ # Stage 3: Vivado runner, error reporter, IP patchers +├── file_management/ # Board discovery, datastore, donor dumps, option ROMs +├── behavioral/ # Behavioral profilers for network, storage, media, USB devices +├── tui/ # Textual-based interactive UI +│ ├── commands/ # TUI command layer +│ ├── core/ # Orchestrators, state managers, device/build operations +│ ├── dialogs/ # Modal dialogs +│ ├── models/ # Pydantic/dataclass models +│ ├── plugins/ # Plugin system +│ ├── styles/ # Textual CSS +│ ├── utils/ # UI helpers +│ └── widgets/ # Custom widgets +├── utils/ # Logging, validation, unified context, post-build checks +├── donor_dump/ # Kernel-module donor-dump tooling (C + Makefile) +└── scripts/ # Driver scraping, kernel utilities, state-machine extraction +``` + +### Git submodules + +- `lib/voltcyclone-fpga` — Board definitions, IP, constraints, synthesis templates +- `site` — Hosted documentation source +- `wiki` — GitHub wiki content + +For local development, initialize submodules: + +```bash +git submodule update --init --recursive +``` + +The container clones `voltcyclone-fpga` during the image build, so submodule +initialization is not needed for container usage. + +--- + +## Entry points + +Installed console scripts (all dispatch to the same main function): + +- `pcileech` +- `pcileech-generate` +- `pcileech-build` +- `pcileech-tui` + +Source-checkout entry point: + +```bash +python3 pcileech.py +``` + +Common commands: + +```bash +pcileech version +pcileech tui +pcileech build --bdf 0000:03:00.0 --board pcileech_35t325_x1 +pcileech build --bdf 0000:03:00.0 --board pcileech_35t325_x1 --local +pcileech build --bdf 0000:03:00.0 --board pcileech_35t325_x1 \ + --vivado-path /tools/Xilinx/2025.1/Vivado --vivado-jobs 8 +pcileech check --device 0000:03:00.0 +pcileech flash pcileech_datastore/output/*.bit --board pcileech_35t325_x1 +``` + +VFIO operations require root; use the venv Python with `sudo -E`: + +```bash +sudo -E ~/.pcileech-venv/bin/python3 -m pcileechfwgenerator.pcileech_main tui +``` + +--- + +## Build and test commands + +### Development setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +pre-commit install +pre-commit install --hook-type commit-msg +``` + +### Running tests + +```bash +# Full suite (with coverage) +make test + +# Unit tests only, excluding hardware/TUI +make test-unit + +# TUI integration tests +make test-tui + +# Fast tests only +make test-fast + +# Direct pytest examples +pytest tests/ -x -q +pytest tests/ -n auto +pytest tests/ -m "not slow and not hardware" +pytest tests/e2e/ -m "e2e and not slow" +``` + +pytest markers defined in `pytest.ini`: + +- `unit` +- `integration` +- `e2e` +- `tui` +- `performance` +- `hardware` (skipped in CI) +- `slow` +- `requires_container_runtime` +- `requires_build_isolation` + +Coverage is configured with `--cov=src`, `--cov-fail-under=10`, HTML output in +`htmlcov/`, and XML output in `coverage.xml`. + +### Code quality + +```bash +make lint # flake8 + mypy +make format # black + isort +make security # bandit + safety +``` + +### Template validation + +```bash +make check-templates # non-blocking summary +make check-templates-strict # fail on issues +make check-templates-fix # generate suggested fixes +make check-templates-errors # warnings as errors +python scripts/validate_template_variables.py --format summary +``` + +### SystemVerilog lint + +```bash +make sv-lint +``` + +### Building and packaging + +```bash +make build # python -m build (sdist + wheel) +make build-pypi # full PyPI package generation with VFIO constants +make build-quick # quick build skipping quality checks +make test-build # test package build in a clean venv +make container # build Podman image +``` + +### Release + +```bash +make release VERSION=0.14.16 +``` + +This regenerates `CHANGELOG.md` with `git-cliff`, commits it if changed, tags +`v0.14.16`, and pushes. The `release.yml` GitHub Actions workflow then builds, +signs, generates release notes, and publishes to PyPI/TestPyPI. + +Versioning is driven entirely by `setuptools-scm` from git tags; do **not** edit +`src/_version.py` (it is generated and gitignored). + +--- + +## Code style guidelines + +- **Formatter:** Black with `--line-length=88`. +- **Import sorting:** isort with `--profile=black --line-length=88`. +- **Linter:** flake8 with `max-line-length=88` and `extend_ignore = E203, W503` + (Black-compatible). +- **Type checking:** mypy (`--ignore-missing-imports` in pre-commit; full mypy + in `make lint`). +- **Docstrings:** Google style for modules, classes, and public functions. +- **Logging:** Use `logger = get_logger(__name__)` from `src/log_config`. +- **Commit messages:** Conventional commits (`feat:`, `fix:`, `docs:`, + `refactor:`, `test:`, `chore:`). The changelog is generated from these. +- **Type hints:** Required for all public functions. + +### Project-specific hard rules + +1. **No placeholder donor IDs** in templates or template-rendering code. Real + device identifiers must propagate from the collected donor profile. +2. **No `shell=True` in subprocess calls.** Always use argv-list form; Bandit + will flag regressions. +3. **Use `log_*_safe` helpers** from `src/string_utils` for any log line that + interpolates donor data, to avoid leaking identifiers into shared logs. +4. **Jinja2 SystemVerilog templates:** mind quote/backslash escaping inside SV + string literals; always end `case` blocks with `default:`; declare `genvar` + outside generate blocks. +5. **Pydantic models** are preferred for structured donor data; search + `src/device_clone/` and `src/pci_capability/` before adding new dict-shaped + values. + +--- + +## Testing instructions + +- Tests live in `tests/` and use pytest. +- There are ~173 `test_*.py` files across unit, integration, e2e, and TUI + categories. +- `conftest.py` provides shared fixtures. +- `tests/mock_sysfs/` contains sysfs fixtures for hardware-free testing. +- Slow and hardware-dependent tests are marked and skipped by default in CI. +- E2E tests run in a separate GitHub Actions workflow + (`.github/workflows/e2e-testing.yml`) covering fast e2e, build-isolation e2e, + and container-build e2e. + +When adding new functionality, add tests and run the relevant subset before +committing: + +```bash +pytest tests/test_your_module.py -x +make test-unit +``` + +--- + +## CI/CD and deployment + +### Workflows + +- **`.github/workflows/consolidated-ci.yml`** — Template validation, CodeQL + security analysis, unit tests (Python 3.11/3.12), coverage upload to Codecov, + and packaging. +- **`.github/workflows/security.yml`** — Dependency review, Bandit, Safety, + Semgrep, and SARIF uploads. +- **`.github/workflows/e2e-testing.yml`** — Fast e2e, build-isolation e2e, and + container-build e2e. +- **`.github/workflows/release.yml`** — Triggered on `v*` tags; builds wheel/sdist, + attests provenance, generates release notes from `cliff.toml`, and publishes + to PyPI (stable) or TestPyPI (alpha/beta/rc). + +### Versioning and release + +- `setuptools-scm` derives the version from git tags using + `version_scheme = "no-guess-dev"`. +- `git-cliff` generates `CHANGELOG.md` and release notes from conventional + commits. +- Pre-releases containing `rc`, `beta`, or `alpha` go to TestPyPI; stable tags + go to production PyPI. + +--- + +## Security considerations + +- The tool is intended for educational research and legitimate PCIe development + only. Generated firmware contains real device identifiers and should be kept + private. +- Never build firmware on production or sensitive systems; use isolated + hardware. +- CI runs SAST and dependency scanning: Bandit, Safety, Semgrep, CodeQL. +- Do not introduce `shell=True`, hard-coded credentials, or synthetic donor + data. +- Report security issues through GitHub Security Advisories, not public issues. +- See `SECURITY.md` and the legal notice in `README.md`. + +--- + +## Agent-specific notes + +### Files you must not hand-edit + +- `src/_version.py` — generated by `setuptools-scm`. +- `lib/voltcyclone-fpga/` — upstream git submodule; changes must land in + `VoltCyclone/voltcyclone-fpga` first, then the submodule pointer is bumped. +- `CHANGELOG.md` — generated by `git-cliff`. + +### Common mistakes to avoid + +- Forgetting `--recurse-submodules` when cloning, which causes "no boards + available" errors. +- Running `sudo pcileech` without preserving the virtualenv path; use + `sudo -E ~/.pcileech-venv/bin/python3 -m pcileechfwgenerator.pcileech_main`. +- Installing into the system Python on modern Linux; always use a venv. +- Adding fallbacks in `configs/fallbacks.yaml` for device IDs or timing values. + +### Useful project tools + +- `scripts/validate_template_variables.py` — template variable validation +- `scripts/check_templates.sh` — convenient template checking +- `scripts/analyze_imports.py` — import analysis +- `scripts/generate_api_docs.py` — documentation generation +- `scripts/iommu_viewer.py` — lightweight IOMMU group viewer for VFIO debugging +- `scripts/barviz.py` — BAR visualization +- `scripts/lint_sv_block_decls.py` — SystemVerilog declaration-order linter +- `.claude/skills/vivado-log-analyzer/` — analyze failed Vivado runs +- `.claude/skills/new-board-target/` — guide for adding a new FPGA board +- `.claude/agents/hardware-safety-reviewer.md` — domain-specific review for + PCIe/template/Vivado changes + +--- + +## Quick reference + +```bash +# Install dev environment +python3 -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +pre-commit install && pre-commit install --hook-type commit-msg + +# Validate and test +make check-templates-strict +make test-unit +make lint + +# Build +make build-pypi + +# Release +make release VERSION=X.Y.Z +``` diff --git a/scripts/check_safe_logging.sh b/scripts/check_safe_logging.sh new file mode 100755 index 00000000..448addfd --- /dev/null +++ b/scripts/check_safe_logging.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# +# Guard against raw f-string logging in src/ (outside the TUI subtree). +# +# The project standardizes on the safe logging helpers in src/string_utils.py: +# log_*_safe(logger, safe_format("msg {x}", x=val), prefix="...") +# Raw f-string logging (logger.info(f"...")) evaluates eagerly and bypasses the +# project's missing-key tolerance and prefix formatting. src/tui/ uses its own +# logging convention and is excluded here (opt-in migration tracked separately). +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +# Match logger.(f" or logger.(f' +PATTERN='logger\.(debug|info|warning|error|critical|exception)\(\s*f["'"'"']' + +hits="$(grep -rnE "$PATTERN" src/ --include='*.py' --exclude-dir=tui || true)" + +if [ -n "$hits" ]; then + echo "ERROR: raw f-string logging found (use log_*_safe + safe_format):" + echo "$hits" + exit 1 +fi + +echo "OK: no raw f-string logging outside src/tui/" diff --git a/scripts/check_test_imports.sh b/scripts/check_test_imports.sh new file mode 100755 index 00000000..53254eb0 --- /dev/null +++ b/scripts/check_test_imports.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# +# Guard against test import anti-patterns. +# +# 1. `from src ...` / `import src...` — tests must import the installed package +# `pcileechfwgenerator.*`, never the bare `src` path (that creates a duplicate +# module identity and breaks mock-patch targets). +# 2. Module-level `sys.path.insert(...)` — redundant given pytest.ini's +# `pythonpath = .`. The only allowed use is in-body inserts that are the +# subject under test (test_cli_build_wrapper.py). +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +status=0 + +src_hits="$(grep -rnE '^(from|import) src(\.| import)' tests/ --include='*.py' || true)" +if [ -n "$src_hits" ]; then + echo "ERROR: 'from src' / 'import src' in tests — use pcileechfwgenerator.* instead:" + echo "$src_hits" + status=1 +fi + +# Module-level (column 0) sys.path.insert is redundant under pythonpath = . +syspath_hits="$(grep -rnE '^sys\.path\.insert' tests/ --include='*.py' || true)" +if [ -n "$syspath_hits" ]; then + echo "ERROR: module-level sys.path.insert in tests (redundant under pytest.ini pythonpath = .):" + echo "$syspath_hits" + status=1 +fi + +if [ "$status" -eq 0 ]; then + echo "OK: no 'from src' imports or module-level sys.path.insert in tests" +fi +exit "$status" diff --git a/scripts/ci_safety_checks.sh b/scripts/ci_safety_checks.sh index fef94c26..eb46b122 100755 --- a/scripts/ci_safety_checks.sh +++ b/scripts/ci_safety_checks.sh @@ -189,6 +189,26 @@ except Exception as e: " || OVERALL_SUCCESS=1 echo +# Test 7: Safe logging guard (no raw f-string logging outside src/tui/) +echo -e "${YELLOW}=== Safe Logging Guard ===${NC}" +if "$SCRIPT_DIR/check_safe_logging.sh"; then + echo -e "${GREEN}✓ No raw f-string logging found${NC}" +else + echo -e "${RED}✗ Raw f-string logging found — use log_*_safe + safe_format${NC}" + OVERALL_SUCCESS=1 +fi +echo + +# Test 8: Test import hygiene (no 'from src' / module-level sys.path.insert) +echo -e "${YELLOW}=== Test Import Hygiene ===${NC}" +if "$SCRIPT_DIR/check_test_imports.sh"; then + echo -e "${GREEN}✓ Test imports are clean${NC}" +else + echo -e "${RED}✗ Test import anti-patterns found${NC}" + OVERALL_SUCCESS=1 +fi +echo + # Summary echo -e "${GREEN}======================================${NC}" if [ $OVERALL_SUCCESS -eq 0 ]; then diff --git a/scripts/generate_pypi_package.py b/scripts/generate_pypi_package.py index 1d72c5da..11dc9a4c 100755 --- a/scripts/generate_pypi_package.py +++ b/scripts/generate_pypi_package.py @@ -15,7 +15,7 @@ import argparse import json import os -import re +import shlex import shutil import subprocess import sys @@ -23,7 +23,7 @@ import time from datetime import datetime from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Sequence try: from git import GitCommandError, InvalidGitRepositoryError, Repo @@ -90,25 +90,27 @@ class CommandRunner: @staticmethod def run( - cmd: str, + cmd: Sequence[str], check: bool = True, capture_output: bool = True, cwd: Optional[Path] = None, ) -> subprocess.CompletedProcess: - """Run a command with enhanced error handling.""" - Logger.debug(f"Running: {cmd}") + """Run a command (argv list, no shell) with enhanced error handling.""" + cmd_list = [str(part) for part in cmd] + printable = shlex.join(cmd_list) + Logger.debug(f"Running: {printable}") try: result = subprocess.run( - cmd, - shell=True, + cmd_list, + shell=False, capture_output=capture_output, text=True, cwd=cwd or PROJECT_ROOT, ) if check and result.returncode != 0: - Logger.error(f"Command failed: {cmd}") + Logger.error(f"Command failed: {printable}") if result.stderr: Logger.error(f"Error output: {result.stderr}") if result.stdout: @@ -118,11 +120,11 @@ def run( return result except Exception as e: - Logger.error(f"Failed to execute command: {cmd}") + Logger.error(f"Failed to execute command: {printable}") Logger.error(f"Exception: {str(e)}") if check: sys.exit(1) - return subprocess.CompletedProcess(cmd, 1, "", str(e)) + return subprocess.CompletedProcess(cmd_list, 1, "", str(e)) class PackageValidator: @@ -258,7 +260,7 @@ def install_security_tools() -> None: for tool in tools: if not shutil.which(tool): Logger.info(f"Installing {tool}...") - CommandRunner.run(f"pip3 install {tool}") + CommandRunner.run(["pip3", "install", tool]) @staticmethod def run_safety_check() -> None: @@ -266,7 +268,7 @@ def run_safety_check() -> None: Logger.info("Checking dependencies for vulnerabilities...") try: - result = CommandRunner.run("safety check --json", check=False) + result = CommandRunner.run(["safety", "check", "--json"], check=False) if result.returncode == 0: Logger.success("No known vulnerabilities found in dependencies") @@ -313,7 +315,7 @@ def install_dev_dependencies() -> None: for dep in dev_deps: if not shutil.which(dep.split(">=")[0]): - CommandRunner.run(f"pip3 install '{dep}'") + CommandRunner.run(["pip3", "install", dep]) @staticmethod def run_code_formatting_check() -> None: @@ -321,7 +323,7 @@ def run_code_formatting_check() -> None: Logger.info("Checking code formatting...") # Check black formatting - result = CommandRunner.run("black --check src/ tests/", check=False) + result = CommandRunner.run(["black", "--check", "src/", "tests/"], check=False) if result.returncode != 0: Logger.error( "Code formatting issues found. Run 'black src/ tests/' to fix." @@ -329,7 +331,9 @@ def run_code_formatting_check() -> None: sys.exit(1) # Check import sorting - result = CommandRunner.run("isort --check-only src/ tests/", check=False) + result = CommandRunner.run( + ["isort", "--check-only", "src/", "tests/"], check=False + ) if result.returncode != 0: Logger.error("Import sorting issues found. Run 'isort src/ tests/' to fix.") sys.exit(1) @@ -342,7 +346,15 @@ def run_linting() -> None: Logger.info("Running flake8 linting...") result = CommandRunner.run( - "flake8 src/ tests/ --count --max-line-length=88 --statistics", check=False + [ + "flake8", + "src/", + "tests/", + "--count", + "--max-line-length=88", + "--statistics", + ], + check=False, ) if result.returncode != 0: @@ -356,7 +368,7 @@ def run_type_checking() -> None: """Run mypy type checking.""" Logger.info("Running mypy type checking...") - result = CommandRunner.run("mypy src/", check=False) + result = CommandRunner.run(["mypy", "src/"], check=False) if result.returncode != 0: Logger.warning("Type checking issues found") Logger.warning(result.stdout) @@ -370,7 +382,13 @@ def run_tests() -> None: Logger.info("Running test suite...") result = CommandRunner.run( - "pytest tests/ --cov=src --cov-report=term-missing --cov-report=xml", + [ + "pytest", + "tests/", + "--cov=src", + "--cov-report=term-missing", + "--cov-report=xml", + ], check=False, ) @@ -423,7 +441,7 @@ def build_distributions() -> List[Path]: DIST_DIR.mkdir(exist_ok=True) # Build using python -m build - CommandRunner.run("python3 -m build") + CommandRunner.run(["python3", "-m", "build"]) # List built distributions distributions = list(DIST_DIR.glob("*")) @@ -444,8 +462,7 @@ def validate_distributions(distributions: List[Path]) -> None: Logger.info("Validating distributions...") # Check with twine - dist_paths = " ".join(str(d) for d in distributions) - CommandRunner.run(f"twine check {dist_paths}") + CommandRunner.run(["twine", "check", *(str(d) for d in distributions)]) # Verify tests are not included in the package PackageBuilder._verify_tests_excluded(distributions) @@ -498,7 +515,7 @@ def test_installation() -> None: venv_path = Path(temp_dir) / "test_venv" # Create virtual environment - CommandRunner.run(f"python -m venv {venv_path}") + CommandRunner.run(["python", "-m", "venv", str(venv_path)]) # Activate and install if os.name == "nt": # Windows @@ -511,16 +528,25 @@ def test_installation() -> None: # Install the built wheel wheel_files = list(DIST_DIR.glob("*.whl")) if wheel_files: - CommandRunner.run(f"{pip_path} install {wheel_files[0]}") + CommandRunner.run([str(pip_path), "install", str(wheel_files[0])]) # Test imports CommandRunner.run( - f"{python_path} -c 'import src; print(f\"Version: {{src.__version__}}\")'" + [ + str(python_path), + "-c", + 'import src; print(f"Version: {src.__version__}")', + ] ) # Test console scripts - result = CommandRunner.run( - f"{python_path} -c 'import pkg_resources; print([ep.name for ep in pkg_resources.iter_entry_points(\"console_scripts\")])'", + console_scripts_probe = ( + "import pkg_resources; " + + "print([ep.name for ep in " + + 'pkg_resources.iter_entry_points("console_scripts")])' + ) + CommandRunner.run( + [str(python_path), "-c", console_scripts_probe], check=False, ) @@ -548,15 +574,25 @@ def upload_to_pypi(test_pypi: bool = False) -> None: "No .pypirc found. You may need to configure authentication." ) + # Expand the dist glob in Python rather than relying on the shell. + dists = sorted( + str(p) + for p in DIST_DIR.glob("*") + if p.suffix == ".whl" or (p.suffix == ".gz" and ".tar" in p.name) + ) + if not dists: + Logger.error("No distributions found to upload") + sys.exit(1) + # Upload if test_pypi: - CommandRunner.run("twine upload --repository testpypi dist/*") + CommandRunner.run(["twine", "upload", "--repository", "testpypi", *dists]) Logger.success("Uploaded to Test PyPI") Logger.info( - "Install with: pip3` install --index-url https://test.pypi.org/simple/ pcileechfwgenerator" + "Install with: pip3 install --index-url https://test.pypi.org/simple/ pcileechfwgenerator" ) else: - CommandRunner.run("twine upload dist/*") + CommandRunner.run(["twine", "upload", *dists]) Logger.success("Uploaded to PyPI") Logger.info("Install with: pip3 install pcileechfwgenerator") diff --git a/tests/run_unit_tests.py b/scripts/run_unit_tests.py similarity index 96% rename from tests/run_unit_tests.py rename to scripts/run_unit_tests.py index 97e10aed..fa7e92db 100644 --- a/tests/run_unit_tests.py +++ b/scripts/run_unit_tests.py @@ -14,7 +14,7 @@ import time import unittest from pathlib import Path -from typing import List, Optional, Tuple +from typing import List, Optional # Configure logging logging.basicConfig( @@ -31,10 +31,12 @@ def __init__(self, test_dir: Optional[Path] = None): Initialize the test runner. Args: - test_dir: Directory containing test files. Defaults to script directory. + test_dir: Directory containing test files. Defaults to the repo's + ``tests/`` directory (this runner lives in ``scripts/``). """ - self.test_dir = test_dir or Path(__file__).parent.resolve() - self.project_root = self.test_dir.parent.resolve() + # This script lives in scripts/; tests live in ../tests. + self.project_root = Path(__file__).parent.parent.resolve() + self.test_dir = test_dir or (self.project_root / "tests") self._setup_python_path() def _setup_python_path(self) -> None: @@ -135,7 +137,7 @@ def run_specific_test(self, test_module: str, verbosity: int = 2) -> int: if not test_file.exists(): logger.error(f"Test file '{test_file}' does not exist") - print(f"\nAvailable test modules:") + print("\nAvailable test modules:") self.list_available_tests() return 1 diff --git a/src/build.py b/src/build.py index ec8a0830..e2d43274 100644 --- a/src/build.py +++ b/src/build.py @@ -150,8 +150,12 @@ class MSIXData: @dataclass(slots=True) -class DeviceConfiguration: - """Device configuration extracted from the build process.""" +class ExtractedDeviceInfo: + """Flat device identifiers extracted at build time from the template context. + + Distinct from :class:`pcileechfwgenerator.device_clone.device_config.DeviceConfiguration`, + which is the rich, nested device profile. This is a build-time summary only. + """ vendor_id: int device_id: int @@ -161,6 +165,11 @@ class DeviceConfiguration: pcie_lanes: int +# Backward-compatible alias. The original name collided with the rich +# device_clone.DeviceConfiguration model; new code should use ExtractedDeviceInfo. +DeviceConfiguration = ExtractedDeviceInfo + + class ModuleChecker: """Validates that required Python modules are importable.""" @@ -254,9 +263,7 @@ def preload_data(self) -> MSIXData: try: # In host-context-only mode, never touch sysfs/VFIO; # use pre-saved files only - disable_vfio = str( - os.environ.get("PCILEECH_DISABLE_VFIO", "") - ).lower() in ( + disable_vfio = str(os.environ.get("PCILEECH_DISABLE_VFIO", "")).lower() in ( "1", "true", "yes", @@ -277,9 +284,8 @@ def preload_data(self) -> MSIXData: with open(msix_path, "r") as f: payload = json.load(f) # Support different shapes - msix_info = ( - payload.get("capability_info") - or payload.get("msix_info") + msix_info = payload.get("capability_info") or payload.get( + "msix_info" ) cfg_hex = payload.get("config_space_hex") cfg_bytes = bytes.fromhex(cfg_hex) if cfg_hex else None @@ -421,6 +427,7 @@ def preload_data(self) -> MSIXData: ) if self.logger.isEnabledFor(logging.DEBUG): import traceback + log_debug_safe( self.logger, safe_format( @@ -434,13 +441,13 @@ def preload_data(self) -> MSIXData: log_warning_safe( self.logger, safe_format( - "MSI-X preload failed - unexpected error: {err}", - err=str(e) + "MSI-X preload failed - unexpected error: {err}", err=str(e) ), prefix="MSIX", ) if self.logger.isEnabledFor(logging.DEBUG): import traceback + log_debug_safe( self.logger, safe_format( @@ -504,7 +511,6 @@ def _create_msix_result(self, msix_info: Dict[str, Any]) -> Dict[str, Any]: } - class FileOperationsManager: """Manages file operations with optional parallel processing.""" @@ -665,7 +671,7 @@ def _parallel_write(self, write_tasks: List[Tuple[Path, str]]) -> None: raise FileOperationError( f"Parallel write timed out after {FILE_WRITE_TIMEOUT}s" ) from e - + if failed_writes: error_details = "; ".join([f"{p}: {e}" for p, e in failed_writes]) raise FileOperationError( @@ -700,7 +706,6 @@ def _json_serialize_default(self, obj: Any) -> str: return obj.__dict__ if hasattr(obj, "__dict__") else str(obj) - class ConfigurationManager: """Manages build configuration and validation.""" @@ -754,7 +759,7 @@ def create_from_args(self, args: argparse.Namespace) -> BuildConfiguration: def extract_device_config( self, template_context: Dict[str, Any], has_msix: bool - ) -> DeviceConfiguration: + ) -> ExtractedDeviceInfo: """Extract and validate device configuration from build results.""" device_config = template_context.get("device_config") pcie_config = template_context.get("pcie_config", {}) @@ -776,10 +781,14 @@ def extract_device_config( log_debug_safe( self.logger, - "Validating device config fields: " + - ", ".join([f"{k}={device_config.get(k, 'missing')}" - for k in required_fields.keys()]), - prefix="BUILD" + "Validating device config fields: " + + ", ".join( + [ + f"{k}={device_config.get(k, 'missing')}" + for k in required_fields.keys() + ] + ), + prefix="BUILD", ) for field, display_name in required_fields.items(): @@ -799,7 +808,7 @@ def extract_device_config( log_info_safe( self.logger, "Revision ID is 0x00 - this is valid for many devices", - prefix="BUILD" + prefix="BUILD", ) vendor_id = _as_int( device_config.get("vendor_id", 0), "vendor_id" @@ -842,7 +851,7 @@ def extract_device_config( revision_id = _as_int(device_config["revision_id"], "revision_id") class_code = _as_int(device_config["class_code"], "class_code") - return DeviceConfiguration( + return ExtractedDeviceInfo( vendor_id=vendor_id, device_id=device_id, revision_id=revision_id, @@ -873,7 +882,6 @@ def _is_valid_bdf(self, bdf: str) -> bool: return bool(re.match(pattern, bdf)) - class FirmwareBuilder: """ This class orchestrates the firmware generation process using @@ -895,8 +903,7 @@ def __init__( self.build_logger = get_build_logger(self.logger) self.file_manifest = create_manifest_tracker(self.logger) self.template_validator = create_template_validator( - self._get_repo_root(), - self.logger + self._get_repo_root(), self.logger ) self.msix_manager = msix_manager or MSIXManager(config.bdf, self.logger) @@ -931,7 +938,7 @@ def _validate_board_template(self) -> None: """Validate board template before build.""" self.build_logger.info( safe_format("Validating board: {board}", board=self.config.board), - prefix="TEMPLATE" + prefix="TEMPLATE", ) is_valid, warnings = self.template_validator.validate_board_template( @@ -944,13 +951,11 @@ def _validate_board_template(self) -> None: if not is_valid: self.build_logger.warning( - "Board template validation failed - build may fail", - prefix="TEMPLATE" + "Board template validation failed - build may fail", prefix="TEMPLATE" ) else: self.build_logger.info( - "Board template validation passed", - prefix="TEMPLATE" + "Board template validation passed", prefix="TEMPLATE" ) def _get_repo_root(self) -> Path: @@ -990,12 +995,12 @@ def build(self) -> List[str]: if host_context: self.build_logger.push_phase("host_context_generation") self._phase("Using host-collected device context …") - + config_space_hex = host_context.get("config_space_hex", "") - config_space_bytes =( - bytes.fromhex(config_space_hex) if config_space_hex else b"" + config_space_bytes = ( + bytes.fromhex(config_space_hex) if config_space_hex else b"" ) - + config_space_data = { "raw_config_space": config_space_bytes, "config_space_hex": config_space_hex, @@ -1008,38 +1013,40 @@ def build(self) -> List[str]: "device_id": host_context.get("device_id"), "class_code": host_context.get("class_code"), "revision_id": host_context.get("revision_id"), - "subsystem_vendor_id": - host_context.get("subsystem_vendor_id"), - "subsystem_device_id": - host_context.get("subsystem_device_id"), + "subsystem_vendor_id": host_context.get("subsystem_vendor_id"), + "subsystem_device_id": host_context.get("subsystem_device_id"), }, } - + self.build_logger.info( safe_format( "Using device IDs: VID=0x{vid} DID=0x{did} Class=0x{cls}", vid=config_space_data["vendor_id"], did=config_space_data["device_id"], - cls=config_space_data["class_code"] + cls=config_space_data["class_code"], ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) - + if not host_context.get("device_config"): host_context["device_config"] = { "device_bdf": self.config.bdf, "vendor_id": format(host_context.get("vendor_id", 0), "04x"), "device_id": format(host_context.get("device_id", 0), "04x"), - "class_code": format( - host_context.get("class_code", 0), "06x"), + "class_code": format(host_context.get("class_code", 0), "06x"), "revision_id": format( - host_context.get("revision_id", 0), "02x"), - "subsystem_vendor_id": format( - host_context.get("subsystem_vendor_id", 0), "04x" - ) if host_context.get("subsystem_vendor_id") else "0000", - "subsystem_device_id": format( - host_context.get("subsystem_device_id", 0), "04x" - ) if host_context.get("subsystem_device_id") else "0000", + host_context.get("revision_id", 0), "02x" + ), + "subsystem_vendor_id": ( + format(host_context.get("subsystem_vendor_id", 0), "04x") + if host_context.get("subsystem_vendor_id") + else "0000" + ), + "subsystem_device_id": ( + format(host_context.get("subsystem_device_id", 0), "04x") + if host_context.get("subsystem_device_id") + else "0000" + ), "enable_perf_counters": False, "enable_advanced_features": False, "enable_error_injection": False, @@ -1048,14 +1055,14 @@ def build(self) -> List[str]: } self.build_logger.info( "Constructed device_config from host context device IDs", - prefix="HOST_CFG" + prefix="HOST_CFG", ) else: self.build_logger.info( "Using existing device_config from host context", - prefix="HOST_CFG" + prefix="HOST_CFG", ) - + generation_result = { "template_context": host_context, "systemverilog_modules": {}, @@ -1065,7 +1072,7 @@ def build(self) -> List[str]: self.build_logger.info( "Complete device context loaded from host - " "skipping container VFIO operations", - prefix="HOST_CFG" + prefix="HOST_CFG", ) self.build_logger.pop_phase("host_context_generation") else: @@ -1141,6 +1148,7 @@ def build(self) -> List[str]: ) if self.logger.isEnabledFor(logging.DEBUG): import traceback + log_debug_safe( self.logger, safe_format( @@ -1165,9 +1173,9 @@ def _save_file_manifest(self) -> None: "{dups} duplicates skipped", files=manifest.total_files, size=manifest.total_size_bytes, - dups=len(manifest.duplicate_operations) + dups=len(manifest.duplicate_operations), ), - prefix="FILEMGR" + prefix="FILEMGR", ) def run_vivado(self) -> None: @@ -1175,9 +1183,7 @@ def run_vivado(self) -> None: try: from .vivado_handling import VivadoRunner, find_vivado_installation except ImportError as e: - raise VivadoIntegrationError( - "Vivado handling modules not available" - ) from e + raise VivadoIntegrationError("Vivado handling modules not available") from e if self.config.vivado_path: vivado_path = self.config.vivado_path @@ -1241,9 +1247,9 @@ def _init_components(self) -> None: self.logger, safe_format( "Using preloaded config space from host: {size} bytes", - size=len(preloaded_config_space) + size=len(preloaded_config_space), ), - prefix="BUILD" + prefix="BUILD", ) else: if getattr(self.config, "disable_vfio", False): @@ -1264,7 +1270,7 @@ def _init_components(self) -> None: log_info_safe( self.logger, "No preloaded config space available - will use VFIO", - prefix="BUILD" + prefix="BUILD", ) gen_cfg = PCILeechGenerationConfig( @@ -1286,18 +1292,19 @@ def _init_components(self) -> None: self.gen = PCILeechGenerator(gen_cfg) - if ( - self.config.enable_profiling - and not getattr(self.config, "disable_vfio", False) + if self.config.enable_profiling and not getattr( + self.config, "disable_vfio", False ): from .device_clone.behavior_profiler import BehaviorProfiler + self.profiler = BehaviorProfiler(bdf=self.config.bdf) else: Noop = type("NoopProfiler", (), {}) # pragma: no cover - trivial self.profiler = Noop() - self.profiler.capture_behavior_profile = ( - lambda duration: {"duration": duration, "events": []} - ) + self.profiler.capture_behavior_profile = lambda duration: { + "duration": duration, + "events": [], + } def _load_donor_template(self) -> Optional[Dict[str, Any]]: """Load donor template if provided.""" @@ -1336,9 +1343,7 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: context_path = os.environ.get( "DEVICE_CONTEXT_PATH", "/app/output/device_context.json" ) - msix_path = os.environ.get( - "MSIX_DATA_PATH", "/app/output/msix_data.json" - ) + msix_path = os.environ.get("MSIX_DATA_PATH", "/app/output/msix_data.json") if not context_path: return None @@ -1354,17 +1359,16 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: log_warning_safe( self.logger, "Device context file does not contain a valid dictionary", - prefix="HOST_CFG" + prefix="HOST_CFG", ) return None log_debug_safe( self.logger, safe_format( - "Loaded device context with keys: {keys}", - keys=list(payload.keys()) + "Loaded device context with keys: {keys}", keys=list(payload.keys()) ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) except json.JSONDecodeError as e: @@ -1373,9 +1377,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: safe_format( "Device context file is not valid JSON: {path} - {err}", path=context_path, - err=str(e) + err=str(e), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) return None except (OSError, IOError) as e: @@ -1384,9 +1388,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: safe_format( "Failed to read device context file: {path} - {err}", path=context_path, - err=str(e) + err=str(e), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) return None @@ -1403,9 +1407,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: "Config space hex length mismatch: expected {expected}, " "got {actual} (possible corruption)", expected=expected_length, - actual=actual_length + actual=actual_length, ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) # Also support nested path when full template_context was saved by host @@ -1418,10 +1422,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: log_debug_safe( self.logger, safe_format( - "Template context extraction failed: {err}", - err=str(e) + "Template context extraction failed: {err}", err=str(e) ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) config_space_hex = None except Exception as e: @@ -1429,9 +1432,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: self.logger, safe_format( "Unexpected error in template context extraction: {err}", - err=str(e) + err=str(e), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) config_space_hex = None @@ -1440,9 +1443,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: self.logger, safe_format( "Loaded config_space_hex from JSON: {length} characters", - length=len(config_space_hex) + length=len(config_space_hex), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) if len(config_space_hex) < 512: # < 256 bytes, likely truncated @@ -1451,9 +1454,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: safe_format( "Config space hex appears truncated: {length} chars " "(expected ~8192 for 4096 bytes)", - length=len(config_space_hex) + length=len(config_space_hex), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) if msix_path and os.path.exists(msix_path): try: @@ -1464,7 +1467,7 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: log_info_safe( self.logger, "Using longer config_space_hex from MSIX data file", - prefix="HOST_CFG" + prefix="HOST_CFG", ) config_space_hex = alt_hex except Exception as e: @@ -1472,9 +1475,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: self.logger, safe_format( "Failed to load alternative config from MSIX: {err}", - err=str(e) + err=str(e), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) if not config_space_hex: log_debug_safe( @@ -1545,7 +1548,7 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: log_error_safe( self.logger, "Config space hex too short (< 64 bytes), corrupted data", - prefix="HOST_CFG" + prefix="HOST_CFG", ) return None @@ -1554,10 +1557,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: log_warning_safe( self.logger, safe_format( - "Invalid hex string in config_space_hex: {err}", - err=str(e) + "Invalid hex string in config_space_hex: {err}", err=str(e) ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) return None @@ -1566,9 +1568,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: self.logger, safe_format( "Config space too small: {size} bytes (minimum 64 expected)", - size=len(config_space_bytes) + size=len(config_space_bytes), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) return None @@ -1576,9 +1578,9 @@ def _load_preloaded_config_space(self) -> Optional[bytes]: self.logger, safe_format( "Loaded preloaded config space from host: {size} bytes", - size=len(config_space_bytes) + size=len(config_space_bytes), ), - prefix="HOST_CFG" + prefix="HOST_CFG", ) return config_space_bytes @@ -1592,7 +1594,7 @@ def _check_host_collected_context(self) -> Optional[Dict[str, Any]]: with open(context_path, "r") as f: payload = json.load(f) - # Check for nested template_context (old format) + # Check for nested template_context (old format) # or use payload directly template_context = payload.get("template_context") if template_context: @@ -1601,12 +1603,12 @@ def _check_host_collected_context(self) -> Optional[Dict[str, Any]]: safe_format( "Loaded complete device context from host: " "{keys} keys available", - keys=len(template_context.keys()) + keys=len(template_context.keys()), ), - prefix="HOST_CTX" + prefix="HOST_CTX", ) return template_context - + # New format - device IDs directly in payload if "config_space_hex" in payload and "vendor_id" in payload: log_info_safe( @@ -1615,20 +1617,17 @@ def _check_host_collected_context(self) -> Optional[Dict[str, Any]]: "Loaded device context with device IDs from host: " "VID=0x{vid:04x} DID=0x{did:04x}", vid=payload.get("vendor_id", 0), - did=payload.get("device_id", 0) + did=payload.get("device_id", 0), ), - prefix="HOST_CTX" + prefix="HOST_CTX", ) return payload except Exception as e: log_debug_safe( self.logger, - safe_format( - "Host context check failed: {err}", - err=str(e) - ), - prefix="HOST_CTX" + safe_format("Host context check failed: {err}", err=str(e)), + prefix="HOST_CTX", ) return None @@ -1669,9 +1668,7 @@ def _generate_firmware( config_space_bytes = None if "config_space_data" in result: - config_space_bytes = result["config_space_data"].get( - "raw_config_space" - ) + config_space_bytes = result["config_space_data"].get("raw_config_space") if not config_space_bytes: config_space_bytes = result["config_space_data"].get( "config_space_bytes" @@ -1694,9 +1691,7 @@ def _generate_firmware( safe_format("Config space hex generation failed: {err}", err=str(e)), prefix="BUILD", ) - raise PCILeechBuildError( - f"Failed to generate config space hex: {e}" - ) from e + raise PCILeechBuildError(f"Failed to generate config space hex: {e}") from e # Emit audit file of top-level template context keys to verify propagation. try: @@ -1743,9 +1738,7 @@ def _write_modules(self, result: Dict[str, Any]) -> None: ) return - sv_files, special_files = self.file_manager.write_systemverilog_modules( - modules - ) + sv_files, special_files = self.file_manager.write_systemverilog_modules(modules) log_info_safe( self.logger, @@ -1813,7 +1806,7 @@ def _generate_tcl_scripts(self, result: Dict[str, Any]) -> None: self.logger, safe_format( " • Copied {count} Vivado TCL scripts from submodule", - count=len(tcl_scripts) + count=len(tcl_scripts), ), prefix="BUILD", ) @@ -1855,9 +1848,7 @@ def _patch_fifo_with_donor_ids(self, result: Dict[str, Any]) -> None: donor_pcie_ip_config_from_result, ) - donor = donor_ids_from_template_context( - result.get("template_context", {}) - ) + donor = donor_ids_from_template_context(result.get("template_context", {})) if donor is None: # Real donor identity is mandatory — there is no synthetic-donor # mode. Continuing would emit a bitstream carrying Xilinx default @@ -1974,8 +1965,7 @@ def _patch_fifo_with_donor_ids(self, result: Dict[str, Any]) -> None: log_info_safe( self.logger, safe_format( - " • Patched donor IDs into {count} XCI baseline " - "file(s)", + " • Patched donor IDs into {count} XCI baseline " "file(s)", count=xci_summary.num_patched, ), prefix="BUILD", @@ -2044,9 +2034,7 @@ def _store_device_config(self, result: Dict[str, Any]) -> None: msix_data = {} has_msix = "msix_data" in result and result["msix_data"] is not None - self._device_config = self.config_manager.extract_device_config( - ctx, has_msix - ) + self._device_config = self.config_manager.extract_device_config(ctx, has_msix) def _run_post_build_validation(self, result: Dict[str, Any]) -> None: """Run post-build validation checks for driver compatibility.""" @@ -2055,8 +2043,7 @@ def _run_post_build_validation(self, result: Dict[str, Any]) -> None: validator = PostBuildValidator(self.logger) is_valid, validation_results = validator.validate_build_output( - output_dir=self.config.output_dir, - generation_result=result + output_dir=self.config.output_dir, generation_result=result ) validator.print_validation_report() @@ -2068,9 +2055,9 @@ def _run_post_build_validation(self, result: Dict[str, Any]) -> None: safe_format( "Build completed with {count} validation errors - " "firmware may not work with OS drivers", - count=len(errors) + count=len(errors), ), - prefix="BUILD" + prefix="BUILD", ) warnings = [r for r in validation_results if r.severity == "warning"] @@ -2085,16 +2072,13 @@ def _run_post_build_validation(self, result: Dict[str, Any]) -> None: "valid": r.is_valid, "severity": r.severity, "message": r.message, - "details": r.details + "details": r.details, } for r in validation_results - ] + ], } - self.file_manager.write_json( - "validation_report.json", - validation_data - ) + self.file_manager.write_json("validation_report.json", validation_data) def _generate_donor_template(self, result: Dict[str, Any]) -> None: """Generate and save donor info template if requested.""" @@ -2132,7 +2116,6 @@ def _generate_donor_template(self, result: Dict[str, Any]) -> None: ) - def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( @@ -2194,9 +2177,7 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: parser.add_argument( "--host-context-only", action="store_true", - help=( - "Do not touch VFIO/sysfs; require DEVICE_CONTEXT_PATH/MSIX_DATA_PATH" - ), + help=("Do not touch VFIO/sysfs; require DEVICE_CONTEXT_PATH/MSIX_DATA_PATH"), ) parser.add_argument( "--output-template", @@ -2285,7 +2266,6 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: return parser.parse_args(argv) - def main(argv: Optional[List[str]] = None) -> int: """Main entry point for the PCILeech firmware builder.""" if not logging.getLogger().handlers: @@ -2325,12 +2305,13 @@ def main(argv: Optional[List[str]] = None) -> int: from pcileechfwgenerator.utils.coe_report import ( generate_coe_report_if_enabled, ) + generate_coe_report_if_enabled(config.output_dir, logger=logger) except Exception as e: log_debug_safe( logger, "COE visualization skipped due to unexpected error (non-fatal): {err}", - err=str(e) + err=str(e), ) return 0 @@ -2358,15 +2339,12 @@ def main(argv: Optional[List[str]] = None) -> int: logger, "Build failed ({error_type}): {err}", error_type=error_type, - err=str(e) + err=str(e), ) if logger.isEnabledFor(logging.DEBUG): import traceback - log_debug_safe( - logger, - "Full traceback:\n{tb}", - tb=traceback.format_exc() - ) + + log_debug_safe(logger, "Full traceback:\n{tb}", tb=traceback.format_exc()) _maybe_emit_issue_report(e, logger, args) return 1 @@ -2374,11 +2352,8 @@ def main(argv: Optional[List[str]] = None) -> int: log_error_safe(logger, "Build failed: {err}", err=str(e)) if logger.isEnabledFor(logging.DEBUG): import traceback - log_debug_safe( - logger, - "Full traceback:\n{tb}", - tb=traceback.format_exc() - ) + + log_debug_safe(logger, "Full traceback:\n{tb}", tb=traceback.format_exc()) _maybe_emit_issue_report(e, logger, args) return 1 @@ -2405,15 +2380,16 @@ def main(argv: Optional[List[str]] = None) -> int: "Unexpected error ({error_type}): {err}", error_type=error_type, err=str(e), - prefix="BUILD" + prefix="BUILD", ) if logger.isEnabledFor(logging.DEBUG): import traceback + log_debug_safe( logger, "Full traceback for unexpected error:\n{tb}", tb=traceback.format_exc(), - prefix="BUILD" + prefix="BUILD", ) _maybe_emit_issue_report(e, logger, args) return 1 @@ -2424,18 +2400,13 @@ def _display_summary( ) -> None: """Display a summary of generated artifacts.""" log_info_safe( - logger, - "\nGenerated artifacts in {dir}", - dir=str(output_dir), - prefix="SUMMARY" + logger, "\nGenerated artifacts in {dir}", dir=str(output_dir), prefix="SUMMARY" ) sv_files = [a for a in artifacts if a.endswith(".sv")] tcl_files = [a for a in artifacts if a.endswith(".tcl")] json_files = [a for a in artifacts if a.endswith(".json")] - other_files = [ - a for a in artifacts if a not in sv_files + tcl_files + json_files - ] + other_files = [a for a in artifacts if a not in sv_files + tcl_files + json_files] if sv_files: log_info_safe( @@ -2449,20 +2420,14 @@ def _display_summary( if tcl_files: log_info_safe( - logger, - "\n TCL scripts ({count}):", - count=len(tcl_files), - prefix="SUMMARY" + logger, "\n TCL scripts ({count}):", count=len(tcl_files), prefix="SUMMARY" ) for f in sorted(tcl_files): log_info_safe(logger, " - {file}", file=f, prefix="SUMMARY") if json_files: log_info_safe( - logger, - "\n JSON files ({count}):", - count=len(json_files), - prefix="SUMMARY" + logger, "\n JSON files ({count}):", count=len(json_files), prefix="SUMMARY" ) for f in sorted(json_files): log_info_safe(logger, " - {file}", file=f, prefix="SUMMARY") diff --git a/src/build_cli.py b/src/build_cli.py index 9d764df9..9246268a 100644 --- a/src/build_cli.py +++ b/src/build_cli.py @@ -6,12 +6,6 @@ import logging import sys -from pathlib import Path - -# Add project root to path for imports -project_root = Path(__file__).parent.parent -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) from pcileechfwgenerator.log_config import get_logger, setup_logging from pcileechfwgenerator.string_utils import ( @@ -46,18 +40,16 @@ def main(): prefix="CLI", ) log_info_safe( - logger, "Try: sudo -E python3 -m pcileechfwgenerator.build_cli", prefix="CLI" + logger, + "Try: sudo -E python3 -m pcileechfwgenerator.build_cli", + prefix="CLI", ) return 1 except KeyboardInterrupt: - log_warning_safe( - logger, "Build process interrupted by user", prefix="CLI" - ) + log_warning_safe(logger, "Build process interrupted by user", prefix="CLI") return 1 except Exception as e: - log_error_safe( - logger, "Build process failed: {err}", err=str(e), prefix="CLI" - ) + log_error_safe(logger, "Build process failed: {err}", err=str(e), prefix="CLI") return 1 diff --git a/src/cli/build_wrapper.py b/src/cli/build_wrapper.py index dc0ba715..5573b63e 100644 --- a/src/cli/build_wrapper.py +++ b/src/cli/build_wrapper.py @@ -1,53 +1,24 @@ #!/usr/bin/env python3 -""" -Wrapper script to properly invoke the build.py module with correct Python path setup. -This ensures that relative imports work correctly in the container environment. +"""Thin wrapper that delegates to the installed build module. + +Historically this script manipulated ``sys.path`` and ``os.chdir``-ed into +``src/`` so that loose-checkout relative imports would resolve. The project is +now an installed package (``pcileechfwgenerator``) with absolute imports, so the +path/cwd juggling is unnecessary — we simply import and delegate. The container +invokes ``python3 -m pcileechfwgenerator.build`` directly; this wrapper is kept +only for backward-compatible ``build_wrapper`` invocations. """ -import os import sys -from pathlib import Path -# Determine if we're in container or local environment -if Path("/app").exists(): - # Container environment - app_dir = Path("/app") - src_dir = app_dir / "src" -else: - # Local environment - we're in src/cli, - # so go up two levels to get to project root - app_dir = Path(__file__).parent.parent.parent.absolute() - src_dir = app_dir / "src" +from pcileechfwgenerator import build -# Ensure both directories are in Python path -if str(app_dir) not in sys.path: - sys.path.insert(0, str(app_dir)) -if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) -# Change to the src directory so relative imports work -if src_dir.exists(): - os.chdir(str(src_dir)) -else: - print(f"Error: Source directory {src_dir} does not exist") - sys.exit(1) +def main() -> int: + """Run the build module's entry point and return its exit code.""" + sys.argv[0] = "build.py" # Fix the script name for argument parsing + return build.main() or 0 -# Now import and run the build module -if __name__ == "__main__": - try: - # Import the build module - from src import build - # Run the main function with the original arguments - sys.argv[0] = "build.py" # Fix the script name for argument parsing - build.main() - except (ImportError, AttributeError) as e: - # Try alternative import path - try: - import build - - sys.argv[0] = "build.py" # Fix the script name for argument parsing - build.main() - except (ImportError, AttributeError): - print(f"Error: Could not import build module or main function: {e}") - sys.exit(1) +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/cli/flash.py b/src/cli/flash.py index aa14aa8c..e394250d 100644 --- a/src/cli/flash.py +++ b/src/cli/flash.py @@ -15,6 +15,7 @@ from ..log_config import get_logger from ..shell import Shell +from ..string_utils import log_info_safe, safe_format logger = get_logger(__name__) @@ -101,7 +102,11 @@ def flash_firmware(bin_path: Path) -> None: try: vid_pid = select_usb_device() - logger.info(f"Selected USB device: {vid_pid}") + log_info_safe( + logger, + safe_format("Selected USB device: {vidpid}", vidpid=vid_pid), + prefix="FLASH", + ) print(f"[*] Flashing firmware using VID:PID {vid_pid}") # Use safer subprocess call with proper argument list diff --git a/src/cli/version_checker.py b/src/cli/version_checker.py index 3a35a2ce..6b229021 100644 --- a/src/cli/version_checker.py +++ b/src/cli/version_checker.py @@ -19,11 +19,15 @@ try: from ..__version__ import __url__, __version__ from ..log_config import get_logger - from ..string_utils import log_warning_safe + from ..string_utils import log_debug_safe, log_warning_safe, safe_format except ImportError: # Fallback for direct execution sys.path.insert(0, str(Path(__file__).parent.parent)) - from pcileechfwgenerator.string_utils import log_warning_safe + from pcileechfwgenerator.string_utils import ( + log_debug_safe, + log_warning_safe, + safe_format, + ) from __version__ import __url__, __version__ from log_config import get_logger @@ -193,7 +197,11 @@ def save_cache(latest_version: str, update_available: bool): json.dump(data, f) except Exception as e: # Silently fail if we can't write cache - logger.debug(f"Failed to save cache: {e}") + log_debug_safe( + logger, + safe_format("Failed to save cache: {err}", err=str(e)), + prefix="VERSION", + ) def fetch_latest_version_github() -> Optional[str]: @@ -227,9 +235,21 @@ def fetch_latest_version_github() -> Optional[str]: reason = e.read().decode() except Exception: reason = str(e) - logger.debug(f"GitHub API HTTPError: {e.code} {reason}") + log_debug_safe( + logger, + safe_format( + "GitHub API HTTPError: {code} {reason}", + code=e.code, + reason=reason, + ), + prefix="VERSION", + ) else: - logger.debug(f"Failed to fetch from GitHub: {e}") + log_debug_safe( + logger, + safe_format("Failed to fetch from GitHub: {err}", err=str(e)), + prefix="VERSION", + ) return None @@ -244,7 +264,11 @@ def fetch_latest_version_pypi() -> Optional[str]: data = json.loads(response.read().decode()) return data.get("info", {}).get("version") except Exception as e: - logger.debug(f"Failed to fetch from PyPI: {e}") + log_debug_safe( + logger, + safe_format("Failed to fetch from PyPI: {err}", err=str(e)), + prefix="VERSION", + ) return None @@ -273,7 +297,11 @@ def fetch_latest_version() -> Optional[str]: # Unknown value: default to GitHub return fetch_latest_version_github() except Exception as e: - logger.debug(f"Failed to fetch latest version: {e}") + log_debug_safe( + logger, + safe_format("Failed to fetch latest version: {err}", err=str(e)), + prefix="VERSION", + ) return None @@ -312,7 +340,11 @@ def check_for_updates(force: bool = False) -> Optional[Tuple[str, bool]]: return latest_version, update_available except Exception as e: - logger.debug(f"Error checking for updates: {e}") + log_debug_safe( + logger, + safe_format("Error checking for updates: {err}", err=str(e)), + prefix="VERSION", + ) return None @@ -354,7 +386,11 @@ def check_and_notify(): prompt_for_update(latest_version) except Exception as e: # Never let version checking break the main program - logger.debug(f"Version check failed: {e}") + log_debug_safe( + logger, + safe_format("Version check failed: {err}", err=str(e)), + prefix="VERSION", + ) # Add command line argument support diff --git a/src/cli/vfio_handler.py b/src/cli/vfio_handler.py index f3654fae..c3637a7f 100644 --- a/src/cli/vfio_handler.py +++ b/src/cli/vfio_handler.py @@ -27,6 +27,7 @@ class BindingState(Enum): """Represents the binding state of a PCI device.""" + UNBOUND = "unbound" BOUND_TO_VFIO = "bound_to_vfio" BOUND_TO_OTHER = "bound_to_other" @@ -36,7 +37,7 @@ class BindingState(Enum): HAS_PRIVILEGE_MANAGER = False PrivilegeManager = None try: - from pcileechfwgenerator.tui.utils.privilege_manager import PrivilegeManager + from pcileechfwgenerator.utils.privilege_manager import PrivilegeManager HAS_PRIVILEGE_MANAGER = True except ImportError: @@ -45,6 +46,7 @@ class BindingState(Enum): # Configure logger logger = logging.getLogger(__name__) + @dataclass class DeviceInfo: """Information about a PCI device.""" @@ -55,13 +57,13 @@ class DeviceInfo: iommu_group: Optional[str] = None driver: Optional[str] = None description: Optional[str] = None - + # Compatibility properties @property def current_driver(self) -> Optional[str]: """Alias for driver property for backward compatibility.""" return self.driver - + @property def binding_state(self) -> BindingState: """Get the binding state of the device.""" @@ -71,28 +73,28 @@ def binding_state(self) -> BindingState: return BindingState.BOUND_TO_VFIO else: return BindingState.BOUND_TO_OTHER - + @classmethod - def from_bdf(cls, bdf: str) -> 'DeviceInfo': + def from_bdf(cls, bdf: str) -> "DeviceInfo": """Create DeviceInfo from BDF string. - + Args: bdf: PCI Bus:Device.Function identifier - + Returns: DeviceInfo object with device details - + Raises: VFIODeviceNotFoundError: If device doesn't exist """ # Create path manager to get device info path_manager = VFIOPathManager(bdf) device_path = path_manager.device_path - + if not device_path.exists(): log_warning_safe(logger, "PCI device %s not found", bdf, prefix="VFIO") raise VFIODeviceNotFoundError(f"PCI device {bdf} not found") - + # Get vendor and device IDs vendor_id = None device_id = None @@ -117,9 +119,7 @@ def from_bdf(cls, bdf: str) -> 'DeviceInfo': log_warning_safe( logger, "Failed to read driver for %s: %s", bdf, e, prefix="VFIO" ) - raise VFIODeviceNotFoundError( - f"Failed to read driver for {bdf}: {e}" - ) + raise VFIODeviceNotFoundError(f"Failed to read driver for {bdf}: {e}") # Get IOMMU group iommu_group = None @@ -128,36 +128,34 @@ def from_bdf(cls, bdf: str) -> 'DeviceInfo': group_path = path_manager.iommu_group_path.resolve() iommu_group = group_path.name log_info_safe( - logger, safe_format( + logger, + safe_format( "IOMMU group for {bdf}: {group}", bdf=bdf, group=iommu_group - ), - prefix="VFIO" + ), + prefix="VFIO", ) except Exception as e: log_warning_safe( logger, safe_format( - "Failed to read IOMMU group for {bdf}: {err}", - bdf=bdf, - err=e + "Failed to read IOMMU group for {bdf}: {err}", bdf=bdf, err=e ), prefix="VFIO", ) raise VFIODeviceNotFoundError( f"Failed to read IOMMU group for {bdf}: {e}" ) - log_info_safe(logger, - safe_format( - "Successfully retrieved device info for {bdf}", bdf=bdf - ), - prefix="VFIO" - ) + log_info_safe( + logger, + safe_format("Successfully retrieved device info for {bdf}", bdf=bdf), + prefix="VFIO", + ) return cls( bdf=bdf, vendor_id=vendor_id, device_id=device_id, iommu_group=iommu_group, - driver=driver + driver=driver, ) @@ -191,17 +189,17 @@ def device_path(self) -> Path: def driver_path(self) -> Path: """Get the driver symlink path.""" return self.device_path / "driver" - + @property def driver_link(self) -> Path: """Alias for driver_path for backward compatibility.""" return self.driver_path - + @property def driver_override_path(self) -> Path: """Alias for override_path for backward compatibility.""" return self.override_path - + @property def iommu_group_link(self) -> Path: """Get the IOMMU group symlink path.""" @@ -260,48 +258,51 @@ def get_vendor_device_id(self) -> Tuple[str, str]: raise VFIODeviceNotFoundError( safe_format( "Cannot read device information for {bdf}: {err}", - bdf=self.bdf, err=e + bdf=self.bdf, + err=e, ) ) - + def get_driver_unbind_path(self, driver_name: str) -> Path: """Get the unbind path for a specific driver. - + Args: driver_name: Name of the driver - + Returns: Path to the driver's unbind file """ - return Path(safe_format( - "/sys/bus/pci/drivers/{driver_name}/unbind", driver_name=driver_name - )) - + return Path( + safe_format( + "/sys/bus/pci/drivers/{driver_name}/unbind", driver_name=driver_name + ) + ) + def get_driver_bind_path(self, driver_name: str) -> Path: """Get the bind path for a specific driver. - + Args: driver_name: Name of the driver - + Returns: Path to the driver's bind file """ - return Path(safe_format( - "/sys/bus/pci/drivers/{driver_name}/bind", driver_name=driver_name - )) + return Path( + safe_format( + "/sys/bus/pci/drivers/{driver_name}/bind", driver_name=driver_name + ) + ) def get_vfio_group_path(self, group_id: str) -> Path: """Get the VFIO group device path. - + Args: group_id: IOMMU group ID - + Returns: Path to the VFIO group device """ - return Path(safe_format( - "/dev/vfio/{group_id}", group_id=group_id - )) + return Path(safe_format("/dev/vfio/{group_id}", group_id=group_id)) class VFIOBinder: @@ -335,7 +336,7 @@ def __init__(self, bdf: str, *, attach: bool = True) -> None: # Initialize privilege manager if available if HAS_PRIVILEGE_MANAGER and PrivilegeManager is not None: self._privilege_manager = PrivilegeManager() - + # Check security context and emit warnings self._check_security_context() @@ -367,7 +368,15 @@ def _check_privilege(self, operation: str) -> None: Raises: VFIOPermissionError: If privileges are insufficient """ - if self._privilege_manager and not self._privilege_manager.check_privilege(): + if self._privilege_manager is None: + return + + # PrivilegeManager exposes has_root / can_sudo (no check_privilege()). + # Privileges are sufficient if we are root or can escalate via sudo. + has_privilege = getattr(self._privilege_manager, "has_root", False) or getattr( + self._privilege_manager, "can_sudo", False + ) + if not has_privilege: raise VFIOPermissionError( f"Insufficient privileges for {operation}. " f"Please run with appropriate permissions." @@ -426,8 +435,9 @@ def get_device_info(self) -> DeviceInfo: except Exception as e: raise VFIODeviceNotFoundError( safe_format( - "Failed to get device info for {bdf}: {error}", - bdf=self.bdf, error=e + "Failed to get device info for {bdf}: {error}", + bdf=self.bdf, + error=e, ) ) @@ -445,7 +455,7 @@ def _check_iommu(self) -> None: def _check_security_context(self) -> None: """Check for SELinux/AppArmor and emit diagnostic warnings. - + This helps users understand container VFIO failures caused by security contexts blocking /dev/vfio access or sysfs writes. """ @@ -462,7 +472,7 @@ def _check_security_context(self) -> None: ) except (OSError, IOError): pass - + # Check AppArmor apparmor_enabled = Path("/sys/module/apparmor/parameters/enabled") if apparmor_enabled.exists(): @@ -479,56 +489,53 @@ def _check_security_context(self) -> None: def _wait_for_group_node(self, group_id: str, timeout: float = 3.0) -> None: """Wait for /dev/vfio/{group} device node to appear. - + In containers, udev may race with bind operations. This ensures the group device node exists before we try to access it. - + Args: group_id: IOMMU group ID timeout: Maximum wait time in seconds - + Raises: VFIOGroupError: If timeout waiting for group node """ group_path = Path(f"/dev/vfio/{group_id}") start_time = time.time() - + while time.time() - start_time < timeout: if group_path.exists(): log_debug_safe( logger, - safe_format( - "Group device node ready: {path}", - path=group_path - ), + safe_format("Group device node ready: {path}", path=group_path), prefix="VFIO", ) return time.sleep(0.05) - + raise VFIOGroupError( safe_format( "Timeout waiting for {path}. " "Check SELinux labels (relabel_files) or udev rules.", - path=group_path + path=group_path, ) ) def _acquire_group_lock(self, group_id: str) -> None: """Acquire inter-process lock for IOMMU group. - + Prevents multiple containers/processes from binding devices in the same IOMMU group concurrently. - + Args: group_id: IOMMU group ID - + Raises: VFIOBindError: If group is already locked by another process """ lock_dir = Path("/var/lock") lock_dir.mkdir(parents=True, exist_ok=True) - + lock_path = lock_dir / f"pcileech-vfio-{group_id}.lock" lock_file = None @@ -537,13 +544,10 @@ def _acquire_group_lock(self, group_id: str) -> None: # Try non-blocking lock fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) self._group_lock = lock_file - + log_debug_safe( logger, - safe_format( - "Acquired group lock: {path}", - path=lock_path - ), + safe_format("Acquired group lock: {path}", path=lock_path), prefix="LOCK", ) except (OSError, IOError) as e: @@ -554,7 +558,7 @@ def _acquire_group_lock(self, group_id: str) -> None: "IOMMU group {gid} is already in use by another process. " "Lock file: {path}", gid=group_id, - path=lock_path + path=lock_path, ) ) from e @@ -572,10 +576,7 @@ def _release_group_lock(self) -> None: except Exception as e: log_warning_safe( logger, - safe_format( - "Failed to release group lock: {err}", - err=e - ), + safe_format("Failed to release group lock: {err}", err=e), prefix="LOCK", ) finally: @@ -583,13 +584,13 @@ def _release_group_lock(self) -> None: def _cleanup_device_ids(self) -> None: """Aggressively remove device IDs added to vfio-pci. - + Ensures clean state on exit - prevents ID pollution in vfio-pci that could affect subsequent binds. """ if not self._added_device_ids: return - + remove_id_path = self._path_manager.remove_id_path if not remove_id_path.exists(): log_debug_safe( @@ -598,7 +599,7 @@ def _cleanup_device_ids(self) -> None: prefix="VFIO", ) return - + for vendor_id, device_id in self._added_device_ids: try: remove_id_path.write_text(f"{vendor_id} {device_id}\n") @@ -607,7 +608,7 @@ def _cleanup_device_ids(self) -> None: safe_format( "Removed device ID from vfio-pci: {vid}:{did}", vid=vendor_id, - did=device_id + did=device_id, ), prefix="VFIO", ) @@ -619,23 +620,35 @@ def _cleanup_device_ids(self) -> None: "Failed to remove device ID {vid}:{did}: {err}", vid=vendor_id, did=device_id, - err=e + err=e, ), prefix="VFIO", ) - + self._added_device_ids.clear() def _save_original_driver(self) -> None: """Save the current driver for restoration.""" if self._path_manager.driver_path.exists(): self.original_driver = self._path_manager.driver_path.resolve().name - logger.debug(f"Original driver for {self.bdf}: {self.original_driver}") + log_debug_safe( + logger, + safe_format( + "Original driver for {bdf}: {drv}", + bdf=self.bdf, + drv=self.original_driver, + ), + prefix="VFIO", + ) def _unbind_current_driver(self) -> None: """Unbind device from its current driver.""" if not self._path_manager.driver_path.exists(): - logger.debug(f"Device {self.bdf} not bound to any driver") + log_debug_safe( + logger, + safe_format("Device {bdf} not bound to any driver", bdf=self.bdf), + prefix="VFIO", + ) return unbind_path = self._path_manager.unbind_path @@ -645,14 +658,23 @@ def _unbind_current_driver(self) -> None: try: self._check_privilege("unbind driver") unbind_path.write_text(f"{self.bdf}\n") - logger.info(f"Unbound {self.bdf} from {self.original_driver}") + log_info_safe( + logger, + safe_format( + "Unbound {bdf} from {drv}", + bdf=self.bdf, + drv=self.original_driver, + ), + prefix="VFIO", + ) # Wait for unbind to complete timeout = 5 start = time.time() - while (self._path_manager.driver_path.exists() and - time.time() - start < timeout - ): + while ( + self._path_manager.driver_path.exists() + and time.time() - start < timeout + ): time.sleep(0.1) if self._path_manager.driver_path.exists(): @@ -666,7 +688,11 @@ def _set_driver_override(self) -> None: try: self._check_privilege("set driver override") self._path_manager.override_path.write_text("vfio-pci\n") - logger.debug(f"Set driver override to vfio-pci for {self.bdf}") + log_debug_safe( + logger, + safe_format("Set driver override to vfio-pci for {bdf}", bdf=self.bdf), + prefix="VFIO", + ) except (OSError, IOError) as e: raise VFIOBindError(f"Failed to set driver override: {e}") @@ -698,14 +724,26 @@ def _bind_to_vfio(self) -> None: for attempt in range(max_retries): try: self._path_manager.bind_path.write_text(f"{self.bdf}\n") - logger.info(f"Bound {self.bdf} to vfio-pci") + log_info_safe( + logger, + safe_format("Bound {bdf} to vfio-pci", bdf=self.bdf), + prefix="VFIO", + ) break except OSError as e: if e.errno == errno.EBUSY and attempt < max_retries - 1: delay = 0.5 * (attempt + 1) - logger.debug( - f"Device {self.bdf} busy, retrying in {delay}s " - f"(attempt {attempt + 1}/{max_retries})" + log_debug_safe( + logger, + safe_format( + "Device {bdf} busy, retrying in {delay}s " + "(attempt {n}/{total})", + bdf=self.bdf, + delay=delay, + n=attempt + 1, + total=max_retries, + ), + prefix="VFIO", ) time.sleep(delay) else: @@ -715,8 +753,10 @@ def _bind_to_vfio(self) -> None: timeout = 5 start = time.time() while time.time() - start < timeout: - if (self._path_manager.driver_path.exists() and - self._path_manager.driver_path.resolve().name == "vfio-pci"): + if ( + self._path_manager.driver_path.exists() + and self._path_manager.driver_path.resolve().name == "vfio-pci" + ): self._bound = True return time.sleep(0.1) @@ -747,7 +787,11 @@ def _get_iommu_group(self) -> str: def _attach_group(self) -> None: """Attach to the IOMMU group and configure it.""" if not self._attach: - logger.debug("Skipping group attachment (attach=False)") + log_debug_safe( + logger, + "Skipping group attachment (attach=False)", + prefix="VFIO", + ) return try: @@ -764,11 +808,16 @@ def _attach_group(self) -> None: self._check_privilege("access VFIO group") try: with open(group_path, "r"): - logger.debug(f"Successfully opened VFIO group {self.group_id}") + log_debug_safe( + logger, + safe_format( + "Successfully opened VFIO group {gid}", + gid=self.group_id, + ), + prefix="VFIO", + ) except (OSError, IOError) as e: - raise VFIOGroupError( - f"Cannot access VFIO group {self.group_id}: {e}" - ) + raise VFIOGroupError(f"Cannot access VFIO group {self.group_id}: {e}") except Exception as e: raise VFIOGroupError(f"Failed to attach IOMMU group: {e}") @@ -788,13 +837,13 @@ def bind(self) -> None: # Check IOMMU is enabled self._check_iommu() - + # Get IOMMU group for locking group_id = self._get_iommu_group() - + # Acquire inter-process lock to prevent concurrent binding self._acquire_group_lock(group_id) - + try: # Save original driver for cleanup self._save_original_driver() @@ -807,7 +856,7 @@ def bind(self) -> None: # Bind to vfio-pci self._bind_to_vfio() - + # Wait for /dev/vfio/{group} to appear (udev race in containers) self._wait_for_group_node(group_id) @@ -821,7 +870,7 @@ def bind(self) -> None: safe_format( "Failed to attach IOMMU group for {bdf}: {err}", bdf=self.bdf, - err=e + err=e, ), prefix="VFIO", ) @@ -843,17 +892,23 @@ def unbind(self) -> None: Raises: VFIOBindError: If unbinding fails """ - logger.info(f"Unbinding {self.bdf} from vfio-pci") + log_info_safe( + logger, + safe_format("Unbinding {bdf} from vfio-pci", bdf=self.bdf), + prefix="VFIO", + ) # Check if actually bound to vfio-pci - if (self._path_manager.driver_path.exists() and - self._path_manager.driver_path.resolve().name != "vfio-pci"): + if ( + self._path_manager.driver_path.exists() + and self._path_manager.driver_path.resolve().name != "vfio-pci" + ): log_warning_safe( logger, safe_format( "Device {bdf} not bound to vfio-pci, current driver: {driver}", bdf=self.bdf, - driver=self._path_manager.driver_path.resolve().name + driver=self._path_manager.driver_path.resolve().name, ), prefix="VFIO", ) @@ -907,16 +962,15 @@ def unbind(self) -> None: logger, safe_format( "Restored {bdf} to {driver}", - bdf=self.bdf, driver=self.original_driver + bdf=self.bdf, + driver=self.original_driver, ), prefix="VFIO", ) except (OSError, IOError) as e: log_warning_safe( logger, - safe_format( - "Failed to restore original driver: {err}", err=e - ), + safe_format("Failed to restore original driver: {err}", err=e), prefix="VFIO", ) else: @@ -931,7 +985,7 @@ def unbind(self) -> None: # Release group lock self._release_group_lock() - + self._bound = False log_info_safe( logger, @@ -1028,10 +1082,10 @@ def check_vfio_availability() -> Dict[str, Any]: # Helper functions def _get_current_driver(bdf: str) -> Optional[str]: """Get the current driver for a device. - + Args: bdf: PCI Bus:Device.Function identifier - + Returns: Driver name or None if not bound """ @@ -1043,8 +1097,7 @@ def _get_current_driver(bdf: str) -> Optional[str]: log_warning_safe( logger, safe_format( - "Failed to get current driver for {bdf}: {err}", - bdf=bdf, err=e + "Failed to get current driver for {bdf}: {err}", bdf=bdf, err=e ), prefix="VFIO", ) @@ -1053,20 +1106,20 @@ def _get_current_driver(bdf: str) -> Optional[str]: def _get_iommu_group(bdf: str) -> Optional[str]: """Get the IOMMU group for a device. - + Args: bdf: PCI Bus:Device.Function identifier - + Returns: IOMMU group ID or None - + Raises: VFIODeviceNotFoundError: If device doesn't exist """ path_manager = VFIOPathManager(bdf) if not path_manager.device_path.exists(): raise VFIODeviceNotFoundError(f"PCI device {bdf} not found") - + if path_manager.iommu_group_path: try: group_path = path_manager.iommu_group_path.resolve() @@ -1078,10 +1131,10 @@ def _get_iommu_group(bdf: str) -> Optional[str]: def _get_iommu_group_safe(bdf: str) -> Optional[str]: """Safely get the IOMMU group for a device. - + Args: bdf: PCI Bus:Device.Function identifier - + Returns: IOMMU group ID or None """ @@ -1095,16 +1148,16 @@ def _wait_for_state_change( check_func: callable, expected_state: Any, timeout: float = 5.0, - poll_interval: float = 0.1 + poll_interval: float = 0.1, ) -> bool: """Wait for a state change with timeout. - + Args: check_func: Function to check current state expected_state: Expected state value timeout: Maximum time to wait in seconds poll_interval: Time between checks in seconds - + Returns: True if state changed, False if timeout """ @@ -1118,10 +1171,10 @@ def _wait_for_state_change( def run_diagnostics(bdf: str) -> Dict[str, Any]: """Run diagnostics on a PCI device. - + Args: bdf: PCI Bus:Device.Function identifier - + Returns: Dictionary with diagnostic information """ @@ -1131,9 +1184,9 @@ def run_diagnostics(bdf: str) -> Dict[str, Any]: "driver": None, "iommu_group": None, "vfio_capable": False, - "errors": [] + "errors": [], } - + try: device_info = DeviceInfo.from_bdf(bdf) result["exists"] = True @@ -1144,22 +1197,22 @@ def run_diagnostics(bdf: str) -> Dict[str, Any]: result["errors"].append(f"Device {bdf} not found") except Exception as e: result["errors"].append(str(e)) - + return result def render_pretty(data: Dict[str, Any], use_color: bool = True) -> str: """Render data in a pretty format. - + Args: data: Data to render use_color: Whether to use ANSI colors - + Returns: Formatted string """ lines = [] - + # Simple color codes if use_color: GREEN = "\033[32m" @@ -1167,7 +1220,7 @@ def render_pretty(data: Dict[str, Any], use_color: bool = True) -> str: RESET = "\033[0m" else: GREEN = RED = RESET = "" - + for key, value in data.items(): if isinstance(value, bool): color = GREEN if value else RED @@ -1181,7 +1234,7 @@ def render_pretty(data: Dict[str, Any], use_color: bool = True) -> str: lines.append(f"{key}: {GREEN}none{RESET}") else: lines.append(f"{key}: {value}") - + return "\n".join(lines) @@ -1199,5 +1252,5 @@ def render_pretty(data: Dict[str, Any], use_color: bool = True) -> str: "_get_iommu_group_safe", "_wait_for_state_change", "run_diagnostics", - "render_pretty" + "render_pretty", ] diff --git a/src/device_clone/config_space_manager.py b/src/device_clone/config_space_manager.py index 53b07300..e62d2267 100644 --- a/src/device_clone/config_space_manager.py +++ b/src/device_clone/config_space_manager.py @@ -15,6 +15,10 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from pcileechfwgenerator.device_clone.vfio_access import ( + VFIOAccess, + get_default_vfio_access, +) from pcileechfwgenerator.exceptions import ( ConfigSpaceError, SysfsConfigSpaceError, @@ -190,20 +194,28 @@ def __format__(self, format_spec: str) -> str: class ConfigSpaceManager: """Manages PCI configuration space operations with improved structure and error handling.""" - def __init__(self, bdf: str, strict_vfio: bool = False) -> None: + def __init__( + self, + bdf: str, + strict_vfio: bool = False, + vfio_access: "Optional[VFIOAccess]" = None, + ) -> None: """ Initialize ConfigSpaceManager. Args: bdf: Bus:Device.Function identifier strict_vfio: If True, require VFIO for config space access + vfio_access: VFIO access port (defaults to the CLI-backed adapter). + Inject a fake to unit-test without real hardware. """ self.bdf = bdf self.device_config = None # No device profiles - use live detection self.strict_vfio = strict_vfio self._config_path = Path(f"/sys/bus/pci/devices/{self.bdf}/config") - # Keep a persistent VFIO binder to avoid unbinding mid-pipeline + # Keep a persistent VFIO binder to avoid unbinding mid-pipeline self._vfio_binder = None + self._vfio_access = vfio_access or get_default_vfio_access() # Extract extended configuration space pointers from device config if self.device_config and hasattr(self.device_config, "capabilities"): @@ -285,8 +297,6 @@ def read_vfio_config_space(self, strict: Optional[bool] = None) -> bytes: def _read_vfio_strict(self) -> bytes: """Read configuration space in strict VFIO mode.""" try: - from pcileechfwgenerator.cli.vfio_handler import VFIOBinder - log_info_safe( logger, "Binding device {bdf} to VFIO for configuration space access", @@ -298,7 +308,9 @@ def _read_vfio_strict(self) -> bytes: # of the build so subsequent VFIO operations succeed. Cleanup is # registered at process exit. if not self._vfio_binder: - self._vfio_binder = VFIOBinder(self.bdf, attach=True) + self._vfio_binder = self._vfio_access.bind_for_session( + self.bdf, attach=True + ) self._vfio_binder.bind() def _cleanup_bind(): @@ -989,7 +1001,7 @@ def _extract_bar_info(self, config_space: bytes) -> List[BarInfo]: from pcileechfwgenerator.device_clone.bar_parser import ( parse_bar_info_from_config_space, ) - + log_info_safe( logger, safe_format( @@ -998,10 +1010,10 @@ def _extract_bar_info(self, config_space: bytes) -> List[BarInfo]: ), prefix="CNFG", ) - + # Use the unified parser bars = parse_bar_info_from_config_space(config_space) - + # Enhance BARs with sysfs size information enhanced_bars = [] for bar in bars: @@ -1017,11 +1029,12 @@ def _extract_bar_info(self, config_space: bytes) -> List[BarInfo]: prefix="BARX", ) bar.size = size_from_sysfs - + # Generate proper encoding for the size from pcileechfwgenerator.device_clone.bar_size_converter import ( BarSizeConverter, ) + try: bar.size_encoding = BarSizeConverter.size_to_encoding( size_from_sysfs, bar.bar_type, bar.is_64bit, bar.prefetchable @@ -1036,9 +1049,9 @@ def _extract_bar_info(self, config_space: bytes) -> List[BarInfo]: ), prefix="BARX", ) - + enhanced_bars.append(bar) - + log_info_safe( logger, safe_format( @@ -1047,9 +1060,8 @@ def _extract_bar_info(self, config_space: bytes) -> List[BarInfo]: ), prefix="BARX", ) - - return enhanced_bars + return enhanced_bars def _log_extracted_device_info(self, device_info: Dict[str, Any]) -> None: """Log extracted device information in a structured way with resilient handling.""" diff --git a/src/device_clone/constants.py b/src/device_clone/constants.py index c8485cae..98a27c12 100644 --- a/src/device_clone/constants.py +++ b/src/device_clone/constants.py @@ -33,6 +33,50 @@ DEVICE_ID_NVIDIA_GPU = 0x2204 # NVIDIA RTX GPU DEVICE_ID_FALLBACK = 0x0000 # Fallback when device ID is missing +# Known synthetic/placeholder (vendor_id, device_id) pairs. +# +# A build that reaches the donor-ID validation gate carrying any of these never +# collected real donor data — emitting it would violate the project's "real +# donor hardware only" rule (AGENTS.md). These are rejected in addition to the +# missing/zero cases that the existing guards already catch. +# +# NOTE: only synthetic *pairs* are listed. The bare vendor IDs 0x8086 / 0x10EC / +# 0x10DE are legitimate real vendors; a real device must never be rejected just +# for its vendor. Some pairs below (Intel I210 0x8086:0x1533, Realtek +# 0x10EC:0x8168, NVIDIA 0x10DE:0x2204) ARE real device IDs that the codebase also +# uses as fabricated defaults; cloning a genuine such device requires the +# PCILEECH_ALLOW_PLACEHOLDER_IDS escape hatch (see is_placeholder_donor_id). +KNOWN_PLACEHOLDER_IDS: Final[frozenset] = frozenset( + { + (int(VENDOR_ID_INTEL), DEVICE_ID_GENERIC), # 0x8086:0x1234 + (int(VENDOR_ID_INTEL), DEVICE_ID_INTEL_ETH), # 0x8086:0x1533 (synthetic I210) + (int(VENDOR_ID_INTEL), DEVICE_ID_INTEL_NVME), # 0x8086:0x2522 (synthetic NVMe) + (int(VENDOR_ID_REALTEK), 0x8168), # 0x10EC:0x8168 (tcl/j2 default RTL8168) + (int(VENDOR_ID_NVIDIA), DEVICE_ID_NVIDIA_GPU), # 0x10DE:0x2204 (synthetic GPU) + (0x10EE, 0x0666), # Xilinx FIFO default + (0x10EE, 0x0007), # Xilinx FIFO default + } +) + + +def is_placeholder_donor_id(vendor_id: int, device_id: int) -> bool: + """Return True if (vendor_id, device_id) is a known synthetic placeholder. + + Rejects only fabricated *pairs*, never a vendor on its own. Callers that + need to clone a genuine device whose real IDs collide with the placeholder + set must honor the ``PCILEECH_ALLOW_PLACEHOLDER_IDS=1`` escape hatch at the + call site rather than weakening this set. + + Args: + vendor_id: PCI vendor ID as an integer. + device_id: PCI device ID as an integer. + + Returns: + True if the pair is a known synthetic/placeholder identity. + """ + return (int(vendor_id), int(device_id)) in KNOWN_PLACEHOLDER_IDS + + # PCIe BAR type constants BAR_TYPE_MEMORY_32BIT = 0 BAR_TYPE_MEMORY_64BIT = 1 diff --git a/src/device_clone/donor_info_template.py b/src/device_clone/donor_info_template.py index 78f8ac67..5cb633d4 100644 --- a/src/device_clone/donor_info_template.py +++ b/src/device_clone/donor_info_template.py @@ -54,15 +54,15 @@ def generate_blank_template() -> Dict[str, Any]: "metadata": metadata, "device_info": { "identification": { - "vendor_id": None, # User to fill: e.g., 0x8086 - "device_id": None, # User to fill: e.g., 0x10D3 + "vendor_id": None, # User to fill from real donor: e.g., 0xXXXX + "device_id": None, # User to fill from real donor: e.g., 0xXXXX "subsystem_vendor_id": None, "subsystem_device_id": None, - "class_code": None, # User to fill: e.g., 0x020000 + "class_code": None, # User to fill from real donor: e.g., 0xXXXXXX "revision_id": None, "device_name": "", # User to fill: human-readable name - "manufacturer": "", # User to fill: e.g., "Intel" - "model": "", # User to fill: e.g., "82574L" + "manufacturer": "", # User to fill: e.g., "" + "model": "", # User to fill: e.g., "" "serial_number": "", # User to fill if available }, "capabilities": { @@ -338,11 +338,11 @@ def generate_minimal_template() -> Dict[str, Any]: "metadata": metadata, "device_info": { "identification": { - "vendor_id": None, # User to fill: e.g., 0x8086 - "device_id": None, # User to fill: e.g., 0x10D3 + "vendor_id": None, # User to fill from real donor: e.g., 0xXXXX + "device_id": None, # User to fill from real donor: e.g., 0xXXXX "subsystem_vendor_id": None, "subsystem_device_id": None, - "class_code": None, # User to fill: e.g., 0x020000 + "class_code": None, # User to fill from real donor: e.g., 0xXXXXXX "revision_id": None, }, "capabilities": { @@ -462,8 +462,9 @@ def generate_template_with_comments() -> str: ident = di.get("identification", {}) if isinstance(ident, dict): ident["$comment"] = ( - "PCI identification fields. vendor_id/device_id required; " - "class_code is the PCI class (e.g., 0x020000)." + "PCI identification fields read from the real donor. " + "vendor_id/device_id required; class_code is the PCI class " + "code (6 hex digits)." ) caps = di.get("capabilities", {}) if isinstance(caps, dict): @@ -686,8 +687,7 @@ def generate_template_from_device(self, bdf: str) -> Dict[str, Any]: except FileNotFoundError as e: error_msg = safe_format( - "lspci command not found. Please install pciutils package", - bdf=bdf + "lspci command not found. Please install pciutils package", bdf=bdf ) log_error_safe(logger, error_msg, prefix="DONOR") raise DeviceConfigError(error_msg) from e @@ -758,7 +758,8 @@ def validate_template(self, template: Dict[str, Any]) -> Tuple[bool, List[str]]: elif ident[field] in (None, ""): errors.append( f"{full} is unset (placeholder) — fill in the " - f"{field.replace('_', ' ')} (e.g., \"0x10DE\")" + f"{field.replace('_', ' ')} read from the real " + f'donor (e.g., "0xXXXX")' ) # Validate behavioral_profile diff --git a/src/device_clone/msix_capability.py b/src/device_clone/msix_capability.py index 27b67786..ebc85f90 100644 --- a/src/device_clone/msix_capability.py +++ b/src/device_clone/msix_capability.py @@ -28,16 +28,15 @@ ) # Import template renderer -from pcileechfwgenerator.templating.template_renderer import TemplateRenderer # Import project logging and string utilities logger = get_logger(__name__) # Define commonly used BAR size constants -BAR_MEM_MIN_SIZE = BAR_SIZE_CONSTANTS["SIZE_4KB"] +BAR_MEM_MIN_SIZE = BAR_SIZE_CONSTANTS["SIZE_4KB"] -BAR_MEM_DEFAULT_SIZE = BAR_SIZE_CONSTANTS["SIZE_64KB"] +BAR_MEM_DEFAULT_SIZE = BAR_SIZE_CONSTANTS["SIZE_64KB"] BAR_IO_DEFAULT_SIZE = BAR_SIZE_CONSTANTS[ "MAX_IO_SIZE" @@ -261,9 +260,7 @@ def find_cap(cfg: str, cap_id: int) -> Optional[int]: log_debug_safe(logger, "No capabilities present", prefix="PCICAP") return None except IndexError: - log_warning_safe( - logger, "Failed to read capabilities pointer", prefix="PCICAP" - ) + log_warning_safe(logger, "Failed to read capabilities pointer", prefix="PCICAP") return None # Walk the capabilities list @@ -528,6 +525,7 @@ def parse_bar_info_from_config_space(cfg: str) -> List[Dict[str, Any]]: - prefetchable: Whether the BAR is prefetchable """ from pcileechfwgenerator.device_clone.bar_parser import parse_bar_info_as_dicts + return parse_bar_info_as_dicts(cfg) @@ -692,98 +690,18 @@ def validate_msix_configuration_enhanced( def generate_msix_table_sv(msix_info: Dict[str, Any]) -> str: - """ - Generate SystemVerilog code for the MSI-X table and PBA. + """Deprecated: standalone MSI-X SystemVerilog generation was removed. - Args: - msix_info: Dictionary containing MSI-X capability information - - Returns: - SystemVerilog code for the MSI-X table and PBA + This rendered ``systemverilog/msix_implementation.sv.j2``, a template that + no longer exists — the project moved to an overlay-only generation + architecture and nothing on the live path called this. The shim is kept so + the public ``device_clone`` re-export stays importable; calling it raises. """ - # Validate required fields to prevent template rendering errors - required_fields = [ - "table_size", - "table_bir", - "table_offset", - "pba_bir", - "pba_offset", - "enabled", - "function_mask", - ] - missing_fields = [field for field in required_fields if field not in msix_info] - if missing_fields: - log_error_safe( - logger, - safe_format( - "CRITICAL: Missing required MSI-X fields: {fields}", - fields=missing_fields, - ), - prefix="PCICAP", - ) - - raise ValueError( - safe_format( - "Cannot generate MSI-X module - " - "missing critical fields: {fields}. " - "MSI-X BAR indices and offsets must come " - "from actual hardware configuration.", - fields=missing_fields, - ) - ) - - if msix_info["table_size"] == 0: - log_debug_safe( - logger, - safe_format("MSI-X: Table size is 0, generating disabled MSI-X module"), - prefix="PCICAP", - ) - # Generate a proper disabled module instead of returning a comment - table_size = 1 # Minimum size for valid SystemVerilog - pba_size = 1 - alignment_warning = "// MSI-X disabled - no interrupt vectors configured" - enabled_val = 0 - function_mask_val = 1 # Force masked when disabled - else: - log_debug_safe( - logger, - "MSI-X: Found, generating SystemVerilog code for MSI-X table", - prefix="PCICAP", - ) - table_size = msix_info["table_size"] - pba_size = (table_size + 31) // 32 # Number of 32-bit words needed for PBA - enabled_val = 1 if msix_info["enabled"] else 0 - function_mask_val = 1 if msix_info["function_mask"] else 0 - - # Generate alignment warning if needed - alignment_warning = "" - if msix_info["table_offset"] % 8 != 0: - alignment_warning = safe_format( - "// Warning: MSI-X table offset " "0x{offset:x} is not 8-byte aligned", - offset=msix_info["table_offset"], - ) - - # Prepare template context - context = { - "table_size": table_size, - "table_bir": msix_info["table_bir"], - "table_offset": msix_info["table_offset"], - "pba_bir": msix_info["pba_bir"], - "pba_offset": msix_info["pba_offset"], - "enabled_val": enabled_val, - "function_mask_val": function_mask_val, - "pba_size": pba_size, - "pba_size_minus_one": pba_size - 1, - "alignment_warning": alignment_warning, - } - - # Use template renderer - renderer = TemplateRenderer() - main_template = renderer.render_template( - "systemverilog/msix_implementation.sv.j2", context + raise NotImplementedError( + "generate_msix_table_sv was removed with the overlay-only " + "SystemVerilog generation architecture. MSI-X is now emitted via the " + "overlay/config-space path, not a standalone template." ) - capability_registers = generate_msix_capability_registers(msix_info) - return main_template + "\n" + capability_registers def validate_msix_configuration( @@ -870,34 +788,15 @@ def validate_msix_configuration( def generate_msix_capability_registers(msix_info: Dict[str, Any]) -> str: - """ - Generate SystemVerilog code for MSI-X capability register handling. - - Args: - msix_info: Dictionary containing MSI-X capability information + """Deprecated: standalone MSI-X capability-register generation was removed. - Returns: - SystemVerilog code for MSI-X capability register management + This rendered ``systemverilog/msix_capability_registers.sv.j2``, a template + that no longer exists (overlay-only architecture). Kept as an importable + shim for the public re-export; calling it raises. """ - # Always generate a proper module, even for disabled MSI-X - table_size = max( - 1, msix_info.get("table_size", 1) - ) # Minimum size 1 for valid SystemVerilog - - # Precompute encoded offset|BIR values (keep lines short) - _table_enc = msix_info.get("table_offset", 0x1000) | msix_info.get("table_bir", 0) - _pba_enc = msix_info.get("pba_offset", 0x2000) | msix_info.get("pba_bir", 0) - - context = { - "table_size_minus_one": table_size - 1, - "table_offset_bir": "32'h" + f"{_table_enc:08X}", - "pba_offset_bir": "32'h" + f"{_pba_enc:08X}", - } - - # Use template renderer - renderer = TemplateRenderer() - return renderer.render_template( - "systemverilog/msix_capability_registers.sv.j2", context + raise NotImplementedError( + "generate_msix_capability_registers was removed with the overlay-only " + "SystemVerilog generation architecture." ) @@ -948,7 +847,3 @@ def generate_msix_capability_registers(msix_info: Dict[str, Any]) -> str: count=len(bars), ) print(format_raw_bar_table(bars, device_bdf="N/A")) - - sv_code = generate_msix_table_sv(msix_info) - safe_print_format(template="SystemVerilog Code:", prefix="PCICAP") - print(sv_code) diff --git a/src/device_clone/pcileech_context.py b/src/device_clone/pcileech_context.py index 60876500..8aa3674d 100644 --- a/src/device_clone/pcileech_context.py +++ b/src/device_clone/pcileech_context.py @@ -40,6 +40,7 @@ PCI_CLASS_NETWORK, PCI_CLASS_STORAGE, POWER_STATE_D0, + is_placeholder_donor_id, ) from pcileechfwgenerator.device_clone.device_config import get_device_config from pcileechfwgenerator.device_clone.donor_capability_extractor import ( @@ -56,6 +57,10 @@ compute_sparse_hash_table_size, normalize_overlay_entry_count, ) +from pcileechfwgenerator.device_clone.vfio_access import ( + VFIOAccess, + get_default_vfio_access, +) from pcileechfwgenerator.error_utils import extract_root_cause from pcileechfwgenerator.exceptions import ContextError from pcileechfwgenerator.pci_capability.constants import PCI_CONFIG_SPACE_MIN_SIZE @@ -83,9 +88,7 @@ def require(condition: bool, message: str, **context) -> None: safe_format("Build aborted: {msg} | ctx={ctx}", msg=message, ctx=context), prefix="PCIL", ) - raise ContextError( - safe_format("{msg} | ctx={ctx}", msg=message, ctx=context) - ) + raise ContextError(safe_format("{msg} | ctx={ctx}", msg=message, ctx=context)) from pcileechfwgenerator.utils.unified_context import ( @@ -256,9 +259,7 @@ def __post_init__(self): ) if self.size <= 0: issues.append( - safe_format( - "Invalid BAR size: {size} (must be > 0)", size=self.size - ) + safe_format("Invalid BAR size: {size} (must be > 0)", size=self.size) ) if self.size > MAX_32BIT_VALUE: issues.append( @@ -320,7 +321,7 @@ class TimingParameters: timeout_cycles: int clock_frequency_mhz: float timing_regularity: float - + min_read_latency: Optional[int] = None max_read_latency: Optional[int] = None avg_read_latency: Optional[int] = None @@ -329,11 +330,15 @@ class TimingParameters: def __post_init__(self): required_fields = [ - 'read_latency', 'write_latency', 'burst_length', - 'inter_burst_gap', 'timeout_cycles', 'clock_frequency_mhz', - 'timing_regularity' + "read_latency", + "write_latency", + "burst_length", + "inter_burst_gap", + "timeout_cycles", + "clock_frequency_mhz", + "timing_regularity", ] - + for field_name in required_fields: field_value = getattr(self, field_name) if field_value is None: @@ -356,7 +361,7 @@ def __post_init__(self): timing_regularity=self.timing_regularity, ) ) - + if self.min_read_latency is None: self.min_read_latency = max(1, int(self.read_latency * 0.8)) if self.max_read_latency is None: @@ -392,11 +397,17 @@ def to_dict(self) -> Dict[str, Any]: class VFIODeviceManager: """Manages VFIO device operations.""" - def __init__(self, device_bdf: str, logger: logging.Logger): + def __init__( + self, + device_bdf: str, + logger: logging.Logger, + vfio_access: "Optional[VFIOAccess]" = None, + ): self.device_bdf = device_bdf self.logger = logger self._device_fd: Optional[int] = None self._container_fd: Optional[int] = None + self._vfio_access = vfio_access or get_default_vfio_access() def __enter__(self): return self @@ -410,9 +421,7 @@ def open(self) -> Tuple[int, int]: return self._device_fd, self._container_fd try: - import pcileechfwgenerator.cli.vfio_helpers as vfio_helpers - - self._device_fd, self._container_fd = vfio_helpers.get_device_fd( + self._device_fd, self._container_fd = self._vfio_access.open_device( self.device_bdf ) @@ -432,7 +441,7 @@ def open(self) -> Tuple[int, int]: self._container_fd = None try: - group = vfio_helpers.ensure_device_vfio_binding(self.device_bdf) + group = self._vfio_access.ensure_binding(self.device_bdf) log_info_safe( self.logger, safe_format( @@ -817,7 +826,9 @@ def _board_attr(attr: str, default: Any = None) -> Any: try: value = getattr(board_config, attr) except AttributeError: - value = board_config.get(attr) if isinstance(board_config, dict) else None + value = ( + board_config.get(attr) if isinstance(board_config, dict) else None + ) return default if value is None else value @@ -1024,11 +1035,7 @@ def _map_width_str_to_lanes(s: str) -> Optional[int]: context["vfio_device"] = False try: - from pcileechfwgenerator.cli.vfio_helpers import ( - ensure_device_vfio_binding as _ensure, - ) - - _ensure(self.device_bdf) + self._vfio_manager._vfio_access.ensure_binding(self.device_bdf) context["vfio_binding_verified"] = True except Exception: context.setdefault("vfio_binding_verified", False) @@ -1477,9 +1484,7 @@ def _sample_bar_data_direct( try: sample_size = min(8192, bar_info.size) - data = reader.read_bar_bytes( - bar_idx, offset=0, length=sample_size - ) + data = reader.read_bar_bytes(bar_idx, offset=0, length=sample_size) if data: sampled_bars[bar_idx] = data @@ -1496,17 +1501,14 @@ def _sample_bar_data_direct( ) else: # Sysfs read failed (likely VFIO-bound) — try VFIO - vfio_data = self._read_bar_via_vfio( - bar_idx, sample_size - ) + vfio_data = self._read_bar_via_vfio(bar_idx, sample_size) if vfio_data: sampled_bars[bar_idx] = vfio_data else: log_warning_safe( self.logger, safe_format( - "Failed to sample BAR{idx} via " - "sysfs and VFIO", + "Failed to sample BAR{idx} via " "sysfs and VFIO", idx=bar_idx, ), prefix="BAR", @@ -1563,9 +1565,7 @@ def _sample_bar_data_direct( return sampled_bars - def _read_bar_via_vfio( - self, bar_index: int, size: int - ) -> Optional[bytes]: + def _read_bar_via_vfio(self, bar_index: int, size: int) -> Optional[bytes]: """Read BAR data via VFIO as fallback when sysfs is unavailable.""" try: data = self._vfio_manager.read_region_slice( @@ -1606,6 +1606,7 @@ def _capture_or_load_bar_models( return {} import os + device_context_path = os.getenv("DEVICE_CONTEXT_PATH") if device_context_path and os.path.exists(device_context_path): try: @@ -1614,17 +1615,17 @@ def _capture_or_load_bar_models( from pcileechfwgenerator.device_clone.bar_model_loader import ( deserialize_bar_model, ) - + with open(device_context_path, "r") as f: device_context = json.load(f) - + bar_models_data = device_context.get("bar_models") if bar_models_data: models = {} for bar_idx_str, model_data in bar_models_data.items(): bar_idx = int(bar_idx_str) models[bar_idx] = deserialize_bar_model(model_data) - + log_info_safe( self.logger, safe_format( @@ -1820,8 +1821,7 @@ def _build_bar_config( elif sampled_data: if len(sampled_data) < size: padding = bar_content_gen._get_seeded_bytes( - size - len(sampled_data), - f"pad_bar{bar_idx}" + size - len(sampled_data), f"pad_bar{bar_idx}" ) content = sampled_data + padding else: @@ -1856,7 +1856,7 @@ def _build_bar_config( from pcileechfwgenerator.device_clone.bar_model_loader import ( serialize_bar_model, ) - + serialized_models = {} for bar_idx, model in bar_models.items(): try: @@ -1871,7 +1871,7 @@ def _build_bar_config( ), prefix="BAR", ) - + if serialized_models: config["bar_models"] = serialized_models log_info_safe( @@ -1913,11 +1913,11 @@ def _analyze_bars(self, bars): ), prefix="BAR", ) - + total_bars = len(bars) discovered_count = 0 total_memory_mapped = 0 - + for i, bar_data in enumerate(bars): try: # enumerate() index may differ from BAR index if BARs are filtered @@ -1933,28 +1933,20 @@ def _analyze_bars(self, bars): discovered_count += 1 size_mb = bar_info.size / (1024 * 1024) size_kb = bar_info.size / 1024 - + if bar_info.is_memory: total_memory_mapped += bar_info.size if size_mb >= 1: - size_display = safe_format( - "{size:.2f} MB", size=size_mb - ) + size_display = safe_format("{size:.2f} MB", size=size_mb) else: - size_display = safe_format( - "{size:.2f} KB", size=size_kb - ) + size_display = safe_format("{size:.2f} KB", size=size_kb) bar_flags = "PREFETCH" if bar_info.prefetchable else "MEM" else: - size_display = safe_format( - "{size} bytes", size=bar_info.size - ) + size_display = safe_format("{size} bytes", size=bar_info.size) bar_flags = "IO" - - width_indicator = ( - "64-bit" if bar_info.bar_type == 1 else "32-bit" - ) - + + width_indicator = "64-bit" if bar_info.bar_type == 1 else "32-bit" + bar_line = safe_format( "║ BAR{idx} @ 0x{addr:08X} │ {size:>12} │ " "{width:>7} │ {flags:>8} ║", @@ -1962,7 +1954,7 @@ def _analyze_bars(self, bars): addr=bar_info.base_address, size=size_display, width=width_indicator, - flags=bar_flags + flags=bar_flags, ) log_info_safe(self.logger, bar_line, prefix="BAR") except Exception as e: @@ -1971,31 +1963,27 @@ def _analyze_bars(self, bars): safe_format( "║ BAR{index}: DISCOVERY FAILED - {error}", index=i, - error=str(e) + error=str(e), ), prefix="BAR", ) - - separator = ( - "╠═════════════════════════════════════════════════════════════╣" - ) + + separator = "╠═════════════════════════════════════════════════════════════╣" log_info_safe(self.logger, safe_format(separator), prefix="BAR") - + total_memory_mb = total_memory_mapped / (1024 * 1024) summary_line = safe_format( "║ DISCOVERED: {discovered}/{total} BARs │ " "MEMORY MAPPED: {mem:.2f} MB ║", discovered=discovered_count, total=total_bars, - mem=total_memory_mb + mem=total_memory_mb, ) log_info_safe(self.logger, summary_line, prefix="BAR") - - footer = ( - "╚═════════════════════════════════════════════════════════════╝" - ) + + footer = "╚═════════════════════════════════════════════════════════════╝" log_info_safe(self.logger, safe_format(footer), prefix="BAR") - + return bar_configs def _select_primary_bar(self, bar_configs): @@ -2019,9 +2007,7 @@ def _build_bar_config_dict(self, primary_bar, bar_configs): # Identity (is), not equality: list.index() would return the first # value-equal entry, which could be a different BAR if two compared # equal. primary_bar is always a reference to an element of bar_configs. - served_pos = next( - (i for i, b in enumerate(bar_configs) if b is served), 0 - ) + served_pos = next((i for i, b in enumerate(bar_configs) if b is served), 0) return { "primary_bar": served_pos, "served_bar_index": served.index, @@ -2139,14 +2125,15 @@ def _get_vfio_bar_info(self, index: int, bar_data) -> Optional[BarConfiguration] if isinstance(fallback_size, int) and fallback_size >= min_mem: log_warning_safe( - self.logger, - safe_format( - "BAR {index}: VFIO size {size}B < {min_mem}B; using sysfs size {fallback_size}B", - index=index, - size=size, - min_mem=min_mem, - fallback_size=fallback_size, - ), prefix="BAR", + self.logger, + safe_format( + "BAR {index}: VFIO size {size}B < {min_mem}B; using sysfs size {fallback_size}B", + index=index, + size=size, + min_mem=min_mem, + fallback_size=fallback_size, + ), + prefix="BAR", ) size = fallback_size else: @@ -2234,46 +2221,48 @@ def _build_timing_config( total_read = 0 total_write = 0 count = 0 - - min_latency = float('inf') + + min_latency = float("inf") max_latency = 0.0 for p in patterns: if hasattr(p, "avg_interval_us"): - latency_cycles = max(1, int(p.avg_interval_us / 10)) # 10ns per cycle at 100MHz + latency_cycles = max( + 1, int(p.avg_interval_us / 10) + ) # 10ns per cycle at 100MHz total_read += latency_cycles total_write += latency_cycles if hasattr(p, "std_deviation_us") and p.std_deviation_us: dev_cycles = int(p.std_deviation_us / 10) min_latency = min(min_latency, latency_cycles - dev_cycles) max_latency = max(max_latency, latency_cycles + dev_cycles) - + count += 1 elif isinstance(p, dict) and "avg_interval_us" in p: latency_cycles = max(1, int(p["avg_interval_us"] / 10)) total_read += latency_cycles total_write += latency_cycles - + if "std_deviation_us" in p and p["std_deviation_us"]: dev_cycles = int(p["std_deviation_us"] / 10) min_latency = min(min_latency, latency_cycles - dev_cycles) max_latency = max(max_latency, latency_cycles + dev_cycles) - + count += 1 if count > 0: avg_read = total_read / count avg_write = total_write / count burst_length = 32 # Default - - if min_latency == float('inf'): + + if min_latency == float("inf"): min_latency = max(1, int(avg_read * 0.8)) if max_latency == 0: max_latency = int(avg_read * 1.2) - + min_latency = max(1, int(min_latency)) max_latency = max(min_latency + 5, int(max_latency)) - + return TimingParameters( read_latency=max(1, int(avg_read)), write_latency=max(1, int(avg_write)), @@ -2310,7 +2299,7 @@ def _build_timing_config( if device_config and hasattr(device_config, "capabilities"): caps = device_config.capabilities read_lat = getattr(caps, "read_latency", 10) - + return TimingParameters( read_latency=read_lat, write_latency=getattr(caps, "write_latency", 10), @@ -2573,9 +2562,7 @@ def _build_overlay_config( if isinstance(hex_data, str): hex_data = hex_data.replace(" ", "").replace("\n", "") dword_map = {} - for i in range( - 0, min(len(hex_data), 1024), 8 - ): + for i in range(0, min(len(hex_data), 1024), 8): if i + 8 <= len(hex_data): dword = hex_data[i : i + 8] dword_map[i // 8] = int(dword, 16) @@ -2710,10 +2697,10 @@ def _build_board_config(self) -> Any: try: from pcileechfwgenerator.file_management.repo_manager import RepoManager - + board_xdc_content = RepoManager.read_combined_xdc(board_name) board_config["board_xdc_content"] = board_xdc_content - + log_info_safe( self.logger, safe_format( @@ -2785,6 +2772,39 @@ def _validate_context_completeness(self, context: TemplateContext): if not vendor_id or not device_id: raise ContextError("Missing device identifiers") + # Reject known synthetic/placeholder identities at the earliest point. + # Unlike the missing/zero check above, this catches populated-but-fake + # pairs (e.g. the fabricated Intel I210 default 0x8086:0x1533). The + # escape hatch only bypasses this pair check, never the missing check. + if os.environ.get("PCILEECH_ALLOW_PLACEHOLDER_IDS") != "1": + + def _as_int(val): + if isinstance(val, bool) or val is None: + return None + if isinstance(val, int): + return val + if isinstance(val, str) and val.strip(): + try: + return int(val, 16) + except ValueError: + return None + return None + + vid = _as_int(vendor_id) + did = _as_int(device_id) + if vid and did and is_placeholder_donor_id(vid, did): + raise ContextError( + safe_format( + "Refusing synthetic placeholder donor IDs " + "{v:#06x}:{d:#06x} — collect real donor data " + "(set PCILEECH_ALLOW_PLACEHOLDER_IDS=1 only for " + "non-shippable test builds of a genuine colliding " + "device).", + v=vid, + d=did, + ) + ) + def _build_performance_config(self, device_type: str = "generic") -> Any: builder = UnifiedContextBuilder(self.logger) diff --git a/src/device_clone/vfio_access.py b/src/device_clone/vfio_access.py new file mode 100644 index 00000000..c2e40b91 --- /dev/null +++ b/src/device_clone/vfio_access.py @@ -0,0 +1,75 @@ +"""VFIO access port for the device-clone domain. + +The domain needs to bind a device to vfio-pci and open its VFIO file +descriptors, but it should not depend on the concrete CLI implementation of +those operations. This module defines the abstraction (``VFIOAccess`` / +``BinderHandle``) that the domain owns, plus a default factory that resolves +the concrete adapter at runtime. + +Dependency direction: domain code depends on the ``VFIOAccess`` Protocol defined +here. Only ``get_default_vfio_access`` reaches into the CLI layer, and it does so +lazily behind this abstraction — so the *type* dependency points inward +(CLI → domain port), not outward. +""" + +from __future__ import annotations + +from typing import Protocol, Tuple, runtime_checkable + + +@runtime_checkable +class BinderHandle(Protocol): + """A session-scoped binding of a device to vfio-pci.""" + + @property + def is_bound(self) -> bool: ... + + def bind(self) -> None: ... + + def unbind(self) -> None: ... + + +@runtime_checkable +class VFIOAccess(Protocol): + """Port the domain uses to bind/open a device's VFIO interface.""" + + def ensure_binding(self, bdf: str) -> str: + """Ensure ``bdf`` is bound to vfio-pci. Returns the IOMMU group id.""" + ... + + def open_device(self, bdf: str) -> Tuple[int, int]: + """Open the device. Returns ``(device_fd, container_fd)``.""" + ... + + def bind_for_session(self, bdf: str, attach: bool = True) -> BinderHandle: + """Return a binder handle that keeps ``bdf`` bound for the session.""" + ... + + +class _CliVFIOAccess: + """Default adapter wrapping the CLI VFIO helpers. + + Kept tiny and lazy: the CLI imports happen inside methods so importing this + module never drags in the CLI layer, and the domain only ever sees the + ``VFIOAccess`` Protocol. + """ + + def ensure_binding(self, bdf: str) -> str: + from pcileechfwgenerator.cli.vfio_helpers import ensure_device_vfio_binding + + return ensure_device_vfio_binding(bdf) + + def open_device(self, bdf: str) -> Tuple[int, int]: + from pcileechfwgenerator.cli.vfio_helpers import get_device_fd + + return get_device_fd(bdf) + + def bind_for_session(self, bdf: str, attach: bool = True) -> BinderHandle: + from pcileechfwgenerator.cli.vfio_handler import VFIOBinder + + return VFIOBinder(bdf, attach=attach) + + +def get_default_vfio_access() -> VFIOAccess: + """Return the default (CLI-backed) VFIO access adapter.""" + return _CliVFIOAccess() diff --git a/src/file_management/donor_dump_manager.py b/src/file_management/donor_dump_manager.py index f7acaab7..4a54d8f6 100644 --- a/src/file_management/donor_dump_manager.py +++ b/src/file_management/donor_dump_manager.py @@ -9,7 +9,6 @@ import json import logging import os -import random import re import subprocess import sys @@ -827,74 +826,6 @@ def unload_module(self) -> bool: error_msg += f"\nStderr: {e.stderr}" raise ModuleLoadError(error_msg) - def generate_donor_info(self, device_type: str = "generic") -> Dict[str, str]: - """ - Generate synthetic donor information for local builds - - Args: - device_type: Type of device to generate info for (generic, network, storage, etc.) - - Returns: - Dictionary of synthetic device parameters - """ - log_info_safe( - logger, - safe_format( - "Generating synthetic donor information for {dt} device", - dt=device_type, - ), - prefix=LOG_PREFIX, - ) - - # Import vendor ID constants - from pcileechfwgenerator.device_clone.constants import VENDOR_ID_INTEL - - # Convert to hex string format - intel_vid_str = f"0x{VENDOR_ID_INTEL:04x}" - - # Common device profiles - device_profiles = { - "generic": { - "vendor_id": intel_vid_str, # Intel - "device_id": "0x1533", # I210 Gigabit Network Connection - "subvendor_id": intel_vid_str, - "subsystem_id": "0x0000", - "revision_id": "0x03", - "bar_size": "0x20000", # 128KB - "mpc": "0x02", # Max payload size capability (512 bytes) - "mpr": "0x02", # Max read request size (512 bytes) - }, - "network": { - "vendor_id": intel_vid_str, # Intel - "device_id": "0x1533", # I210 Gigabit Network Connection - "subvendor_id": intel_vid_str, - "subsystem_id": "0x0000", - "revision_id": "0x03", - "bar_size": "0x20000", # 128KB - "mpc": "0x02", # Max payload size capability (512 bytes) - "mpr": "0x02", # Max read request size (512 bytes) - }, - "storage": { - "vendor_id": intel_vid_str, # Intel - "device_id": "0x2522", # NVMe SSD Controller - "subvendor_id": intel_vid_str, - "subsystem_id": "0x0000", - "revision_id": "0x01", - "bar_size": "0x40000", # 256KB - "mpc": "0x03", # Max payload size capability (1024 bytes) - "mpr": "0x03", # Max read request size (1024 bytes) - }, - } - - # Use the specified device profile or fall back to generic - profile = device_profiles.get(device_type, device_profiles["generic"]) - - # Add some randomness to make it look more realistic - if random.random() > 0.5: - profile["revision_id"] = f"0x{random.randint(1, 5):02x}" - - return profile - def save_donor_info(self, device_info: Dict[str, str], output_path: str) -> bool: """ Save donor information to a JSON file for future use @@ -1328,8 +1259,6 @@ def setup_module( bdf: str, auto_install_headers: bool = False, save_to_file: Optional[str] = None, - generate_if_unavailable: bool = False, - device_type: str = "generic", extract_full_config: bool = True, ) -> Dict[str, str]: """ @@ -1339,12 +1268,14 @@ def setup_module( bdf: PCI Bus:Device.Function auto_install_headers: Automatically install headers if missing save_to_file: Path to save donor information for future use - generate_if_unavailable: Generate synthetic donor info if module setup fails - device_type: Type of device to generate info for if needed extract_full_config: Extract full 4KB configuration space Returns: Device information dictionary + + Raises: + Exception: If module setup fails. There is no synthetic-donor + fallback — real donor hardware is mandatory (AGENTS.md). """ try: log_info_safe( @@ -1359,9 +1290,7 @@ def setup_module( if auto_install_headers: log_info_safe( logger, - safe_format( - "Kernel headers missing, attempting to install..." - ), + safe_format("Kernel headers missing, attempting to install..."), prefix=LOG_PREFIX, ) if not self.install_kernel_headers(kernel_version): @@ -1391,9 +1320,7 @@ def setup_module( ): log_warning_safe( logger, - safe_format( - "Full 4KB extraction is disabled or not available" - ), + safe_format("Full 4KB extraction is disabled or not available"), prefix=LOG_PREFIX, ) log_warning_safe( @@ -1415,9 +1342,7 @@ def setup_module( log_info_safe( logger, - safe_format( - "Saved donor information to {path}", path=save_to_file - ), + safe_format("Saved donor information to {path}", path=save_to_file), prefix=LOG_PREFIX, ) elif device_info and not save_to_file: @@ -1439,75 +1364,15 @@ def setup_module( return device_info except Exception as e: + # No synthetic-donor fallback: real donor hardware is mandatory. + # Fabricating a profile here would emit firmware carrying + # placeholder IDs — the anti-pattern this project forbids. log_error_safe( logger, safe_format("Failed to set up donor_dump module: {err}", err=e), prefix=LOG_PREFIX, ) - - if generate_if_unavailable: - log_info_safe( - logger, - safe_format("Generating synthetic donor information as fallback"), - prefix=LOG_PREFIX, - ) - device_info = self.generate_donor_info(device_type) - - # Add synthetic extended configuration space if needed - if extract_full_config and "extended_config" not in device_info: - log_info_safe( - logger, - safe_format("Generating synthetic configuration space data"), - prefix=LOG_PREFIX, - ) - # Generate a basic 4KB configuration space with - # device/vendor IDs - config_space = ["00"] * 4096 # Initialize with zeros - - # Set vendor ID (bytes 0-1) - vendor_id = device_info["vendor_id"][2:] # Remove "0x" prefix - config_space[0] = vendor_id[2:4] if len(vendor_id) >= 4 else "86" - config_space[1] = vendor_id[0:2] if len(vendor_id) >= 2 else "80" - - # Set device ID (bytes 2-3) - device_id = device_info["device_id"][2:] # Remove "0x" prefix - config_space[2] = device_id[2:4] if len(device_id) >= 4 else "33" - config_space[3] = device_id[0:2] if len(device_id) >= 2 else "15" - - # Set subsystem vendor ID (bytes 44-45) - subvendor_id = device_info["subvendor_id"][2:] - config_space[44] = ( - subvendor_id[2:4] if len(subvendor_id) >= 4 else "86" - ) - config_space[45] = ( - subvendor_id[0:2] if len(subvendor_id) >= 2 else "80" - ) - - # Set subsystem ID (bytes 46-47) - subsystem_id = device_info["subsystem_id"][2:] - config_space[46] = ( - subsystem_id[2:4] if len(subsystem_id) >= 4 else "00" - ) - config_space[47] = ( - subsystem_id[0:2] if len(subsystem_id) >= 2 else "00" - ) - - # Set revision ID (byte 8) - revision_id = device_info["revision_id"][2:] - config_space[8] = ( - revision_id[0:2] if len(revision_id) >= 2 else "03" - ) - - # Convert to hex string - device_info["extended_config"] = "".join(config_space) - - # Save to file if requested - if save_to_file and device_info: - self.save_donor_info(device_info, save_to_file) - - return device_info - else: - raise + raise def main(): @@ -1532,17 +1397,6 @@ def main(): ) parser.add_argument("--status", action="store_true", help="Show module status") parser.add_argument("--save-to", help="Save donor information to specified file") - parser.add_argument( - "--generate", - action="store_true", - help="Generate synthetic donor information if module setup fails", - ) - parser.add_argument( - "--device-type", - choices=["generic", "network", "storage"], - default="generic", - help="Device type for synthetic donor information", - ) parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose logging" ) @@ -1574,7 +1428,6 @@ def main(): args.bdf, auto_install_headers=args.auto_install_headers, save_to_file=args.save_to, - generate_if_unavailable=args.generate, ) print(f"Device information for {args.bdf}:") diff --git a/src/host_collect/collector.py b/src/host_collect/collector.py index 527c895f..2a8ef4fd 100644 --- a/src/host_collect/collector.py +++ b/src/host_collect/collector.py @@ -59,22 +59,26 @@ def run(self) -> int: # Write device_context.json with extracted device IDs ctx_path = self.datastore / "device_context.json" with open(ctx_path, "w") as f: - json.dump({ - "config_space_hex": cfg_hex, - "vendor_id": device_ids["vendor_id"], - "device_id": device_ids["device_id"], - "class_code": device_ids["class_code"], - "revision_id": device_ids["revision_id"], - "subsystem_vendor_id": device_ids.get("subsystem_vendor_id"), - "subsystem_device_id": device_ids.get("subsystem_device_id"), - }, f, indent=2) + json.dump( + { + "config_space_hex": cfg_hex, + "vendor_id": device_ids["vendor_id"], + "device_id": device_ids["device_id"], + "class_code": device_ids["class_code"], + "revision_id": device_ids["revision_id"], + "subsystem_vendor_id": device_ids.get("subsystem_vendor_id"), + "subsystem_device_id": device_ids.get("subsystem_device_id"), + }, + f, + indent=2, + ) log_info_safe( self.logger, safe_format( "Wrote {path} with device IDs: VID={vid:04x} DID={did:04x}", path=str(ctx_path), vid=device_ids["vendor_id"], - did=device_ids["device_id"] + did=device_ids["device_id"], ), prefix="COLLECT", ) @@ -126,7 +130,9 @@ def _read_config_space(self) -> Optional[bytes]: prefix="COLLECT", ) return data - except Exception as e: + except OSError as e: + # Only file I/O happens here; a non-OSError would be a programming + # bug and should propagate rather than be swallowed. log_error_safe( self.logger, safe_format("Config read error: {err}", err=str(e)), @@ -157,24 +163,24 @@ def _extract_device_ids(self, cfg: bytes) -> Dict[str, Any]: log_error_safe( self.logger, "Config space too short for device ID extraction", - prefix="COLLECT" + prefix="COLLECT", ) return {} - + try: # Extract basic device IDs (offsets 0x00-0x0B) vendor_id = int.from_bytes(cfg[0:2], "little") device_id = int.from_bytes(cfg[2:4], "little") revision_id = cfg[8] class_code = int.from_bytes(cfg[9:12], "little") - + # Extract subsystem IDs (offsets 0x2C-0x2F) subsystem_vendor_id = None subsystem_device_id = None if len(cfg) >= 48: subsystem_vendor_id = int.from_bytes(cfg[44:46], "little") subsystem_device_id = int.from_bytes(cfg[46:48], "little") - + log_info_safe( self.logger, safe_format( @@ -183,11 +189,11 @@ def _extract_device_ids(self, cfg: bytes) -> Dict[str, Any]: vid=vendor_id, did=device_id, cls=class_code, - rev=revision_id + rev=revision_id, ), - prefix="COLLECT" + prefix="COLLECT", ) - + return { "vendor_id": vendor_id, "device_id": device_id, @@ -196,14 +202,17 @@ def _extract_device_ids(self, cfg: bytes) -> Dict[str, Any]: "subsystem_vendor_id": subsystem_vendor_id, "subsystem_device_id": subsystem_device_id, } - except Exception as e: + except (IndexError, ValueError) as e: + # Only byte slicing and int.from_bytes happen here, so only + # IndexError/ValueError are expected; other errors indicate a code + # bug and should propagate. log_error_safe( self.logger, safe_format("Failed to extract device IDs: {err}", err=str(e)), - prefix="COLLECT" + prefix="COLLECT", ) return {} - + def _visualize(self, buf: bytes) -> None: # Simple hex dump with offsets lines = [] diff --git a/src/scripts/driver_scrape.py b/src/scripts/driver_scrape.py index e0b56aae..e5c81d44 100644 --- a/src/scripts/driver_scrape.py +++ b/src/scripts/driver_scrape.py @@ -43,6 +43,13 @@ from typing import Any, Dict, List, Optional, Set try: + from pcileechfwgenerator.string_utils import ( + log_error_safe, + log_info_safe, + log_warning_safe, + safe_format, + ) + from scripts.kernel_utils import ( check_linux_requirement, ensure_kernel_source, @@ -51,7 +58,9 @@ ) from scripts.state_machine_extractor import StateMachineExtractor except ImportError: - # Fallback for when running as script directly + # Fallback for when running as a script directly (CWD == src/scripts). + # string_utils lives one level up in src/, so put it on the path. + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from kernel_utils import ( check_linux_requirement, ensure_kernel_source, @@ -60,6 +69,14 @@ ) from state_machine_extractor import StateMachineExtractor + from string_utils import ( + log_error_safe, + log_info_safe, + log_warning_safe, + safe_format, + ) + + # Module-level regex patterns for register analysis REG_PATTERN = re.compile(r"#define\s+(REG_[A-Z0-9_]+)\s+0x([0-9A-Fa-f]+)") WRITE_PATTERN = re.compile(r"write([blwq]?)\s*\(.*?\b(REG_[A-Z0-9_]+)\b") @@ -407,11 +424,17 @@ def extract_registers_with_analysis( try: file_contents[path] = path.read_text(errors="ignore") except Exception as e: - logger.warning(f"Error reading {path}: {e}") + log_warning_safe( + logger, + safe_format("Error reading {path}: {err}", path=str(path), err=str(e)), + prefix="DRIVER_SCRAPE", + ) continue if not file_contents: - logger.warning("No source files could be read") + log_warning_safe( + logger, "No source files could be read", prefix="DRIVER_SCRAPE" + ) return { "driver_module": driver_name, "registers": [], @@ -454,7 +477,11 @@ def extract_registers_with_analysis( ) optimized_state_machines = state_machine_extractor.optimize_state_machines() except Exception as e: - logger.warning(f"State machine extraction failed: {e}") + log_warning_safe( + logger, + safe_format("State machine extraction failed: {err}", err=str(e)), + prefix="DRIVER_SCRAPE", + ) extracted_state_machines = [] optimized_state_machines = [] @@ -582,7 +609,15 @@ def main() -> None: vendor_id = validate_hex_id(args.vendor_id, "Vendor ID") device_id = validate_hex_id(args.device_id, "Device ID") - logger.info(f"Analyzing driver for VID:DID {vendor_id}:{device_id}") + log_info_safe( + logger, + safe_format( + "Analyzing driver for VID:DID {vid}:{did}", + vid=vendor_id, + did=device_id, + ), + prefix="DRIVER_SCRAPE", + ) # Check Linux requirement early check_linux_requirement("Driver analysis") @@ -595,7 +630,11 @@ def main() -> None: else: ksrc = ensure_kernel_source() if ksrc is None: - logger.error("Linux source package not found") + log_error_safe( + logger, + "Linux source package not found", + prefix="DRIVER_SCRAPE", + ) empty_output = { "driver_module": "unknown", "registers": [], @@ -612,9 +651,17 @@ def main() -> None: # Resolve driver module try: driver_name = resolve_driver_module(vendor_id, device_id) - logger.info(f"Resolved driver module: {driver_name}") + log_info_safe( + logger, + safe_format("Resolved driver module: {name}", name=driver_name), + prefix="DRIVER_SCRAPE", + ) except RuntimeError as e: - logger.error(f"Driver resolution failed: {e}") + log_error_safe( + logger, + safe_format("Driver resolution failed: {err}", err=str(e)), + prefix="DRIVER_SCRAPE", + ) empty_output = { "driver_module": "unknown", "registers": [], @@ -631,7 +678,13 @@ def main() -> None: # Find source files src_files = find_driver_sources(ksrc, driver_name) if not src_files: - logger.warning(f"No source files found for driver: {driver_name}") + log_warning_safe( + logger, + safe_format( + "No source files found for driver: {name}", name=driver_name + ), + prefix="DRIVER_SCRAPE", + ) empty_output = { "driver_module": driver_name, "registers": [], @@ -645,25 +698,45 @@ def main() -> None: print(json.dumps(empty_output, indent=2)) return - logger.info(f"Found {len(src_files)} source files") + log_info_safe( + logger, + safe_format("Found {n} source files", n=len(src_files)), + prefix="DRIVER_SCRAPE", + ) # Extract and analyze registers result = extract_registers_with_analysis(src_files, driver_name) - logger.info(f"Extracted {len(result['registers'])} registers") + log_info_safe( + logger, + safe_format("Extracted {n} registers", n=len(result["registers"])), + prefix="DRIVER_SCRAPE", + ) print(json.dumps(result, indent=2)) except ValueError as e: - logger.error(f"Invalid input: {e}") + log_error_safe( + logger, + safe_format("Invalid input: {err}", err=str(e)), + prefix="DRIVER_SCRAPE", + ) sys.exit(1) except RuntimeError as e: - logger.error(f"Runtime error: {e}") + log_error_safe( + logger, + safe_format("Runtime error: {err}", err=str(e)), + prefix="DRIVER_SCRAPE", + ) sys.exit(2) except KeyboardInterrupt: - logger.info("Analysis interrupted by user") + log_info_safe(logger, "Analysis interrupted by user", prefix="DRIVER_SCRAPE") sys.exit(130) except Exception as e: - logger.error(f"Unexpected error: {e}") + log_error_safe( + logger, + safe_format("Unexpected error: {err}", err=str(e)), + prefix="DRIVER_SCRAPE", + ) sys.exit(1) diff --git a/src/shell.py b/src/shell.py index dc85767c..cfd27fb9 100644 --- a/src/shell.py +++ b/src/shell.py @@ -6,6 +6,13 @@ import subprocess from typing import Optional +from pcileechfwgenerator.string_utils import ( + log_debug_safe, + log_info_safe, + log_warning_safe, + safe_format, +) + logger = logging.getLogger(__name__) @@ -45,7 +52,11 @@ def _validate_command_safety(self, cmd: str) -> None: # Check for suspicious redirections to sensitive paths if any(path in cmd for path in ["/etc/", "/boot/", "/sys/", "/proc/"]): if any(op in cmd for op in ["> ", ">> ", "| dd", "| tee"]): - logger.warning(f"Command modifies sensitive paths: {cmd}") + log_warning_safe( + logger, + safe_format("Command modifies sensitive paths: {cmd}", cmd=cmd), + prefix="SHELL", + ) def run(self, *parts: str, timeout: int = 30, cwd: Optional[str] = None) -> str: """Execute a shell command and return stripped output. @@ -67,14 +78,30 @@ def run(self, *parts: str, timeout: int = 30, cwd: Optional[str] = None) -> str: self._validate_command_safety(cmd) if self.dry_run: - logger.info(f"[DRY RUN] Would execute: {cmd}") + log_info_safe( + logger, + safe_format("[DRY RUN] Would execute: {cmd}", cmd=cmd), + prefix="SHELL", + ) if cwd: - logger.debug(f"[DRY RUN] Working directory: {cwd}") + log_debug_safe( + logger, + safe_format("[DRY RUN] Working directory: {cwd}", cwd=cwd), + prefix="SHELL", + ) return "" - logger.debug(f"Executing command: {cmd}") + log_debug_safe( + logger, + safe_format("Executing command: {cmd}", cmd=cmd), + prefix="SHELL", + ) if cwd: - logger.debug(f"Working directory: {cwd}") + log_debug_safe( + logger, + safe_format("Working directory: {cwd}", cwd=cwd), + prefix="SHELL", + ) try: # Use shell=False with shlex.split for safety. @@ -87,7 +114,11 @@ def run(self, *parts: str, timeout: int = 30, cwd: Optional[str] = None) -> str: stderr=subprocess.STDOUT, cwd=cwd, ).strip() - logger.debug(f"Command output: {result}") + log_debug_safe( + logger, + safe_format("Command output: {result}", result=result), + prefix="SHELL", + ) return result except subprocess.TimeoutExpired as e: @@ -153,10 +184,22 @@ def write_file( RuntimeError: If file operation fails """ if self.dry_run: - logger.info(f"[DRY RUN] Would write to file: {path}") - logger.debug(f"[DRY RUN] Content: {content}") + log_info_safe( + logger, + safe_format("[DRY RUN] Would write to file: {path}", path=path), + prefix="SHELL", + ) + log_debug_safe( + logger, + safe_format("[DRY RUN] Content: {content}", content=content), + prefix="SHELL", + ) if permissions: - logger.debug(f"[DRY RUN] Permissions: {oct(permissions)}") + log_debug_safe( + logger, + safe_format("[DRY RUN] Permissions: {perm}", perm=oct(permissions)), + prefix="SHELL", + ) return try: @@ -174,9 +217,21 @@ def write_file( import os os.chmod(path, permissions) - logger.debug(f"Set file permissions to {oct(permissions)}: {path}") - - logger.debug(f"Wrote content to file: {path}") + log_debug_safe( + logger, + safe_format( + "Set file permissions to {perm}: {path}", + perm=oct(permissions), + path=path, + ), + prefix="SHELL", + ) + + log_debug_safe( + logger, + safe_format("Wrote content to file: {path}", path=path), + prefix="SHELL", + ) except (OSError, IOError) as e: error_msg = f"Failed to write file {path}: {e}" logger.error(error_msg) diff --git a/src/templates/python/pcileech_build_integration.py.j2 b/src/templates/python/pcileech_build_integration.py.j2 index 986dabcf..cf6be69f 100644 --- a/src/templates/python/pcileech_build_integration.py.j2 +++ b/src/templates/python/pcileech_build_integration.py.j2 @@ -15,12 +15,16 @@ logger = logging.getLogger(__name__) @dataclass class TimingConfig: - """Timing configuration parameters.""" - clock_frequency_mhz: float = 100.0 - read_latency: int = 4 - write_latency: int = 2 - burst_length: int = 16 - timeout_cycles: int = 1024 + """Timing configuration parameters. + + All fields are required — timing must come from the collected donor profile, + never from a hard-coded default (AGENTS.md: no fallbacks for timing). + """ + clock_frequency_mhz: float + read_latency: int + write_latency: int + burst_length: int + timeout_cycles: int @dataclass @@ -148,13 +152,20 @@ def create_build_config(build_data: Dict[str, Any]) -> BuildConfig: enable_perf_counters=device_data.get("enable_perf_counters", False), ) - timing = TimingConfig( - clock_frequency_mhz=timing_data.get("clock_frequency_mhz", 100.0), - read_latency=timing_data.get("read_latency", 4), - write_latency=timing_data.get("write_latency", 2), - burst_length=timing_data.get("burst_length", 16), - timeout_cycles=timing_data.get("timeout_cycles", 1024), - ) + try: + timing = TimingConfig( + clock_frequency_mhz=timing_data["clock_frequency_mhz"], + read_latency=timing_data["read_latency"], + write_latency=timing_data["write_latency"], + burst_length=timing_data["burst_length"], + timeout_cycles=timing_data["timeout_cycles"], + ) + except KeyError as exc: + raise ValueError( + f"Missing required timing parameter {exc} in " + "build_data['timing_config']; timing must come from the collected " + "donor profile, not a default." + ) from exc pcileech = PCILeechConfig( command_timeout=pcileech_data.get("command_timeout", 1000), @@ -208,6 +219,8 @@ if __name__ == "__main__": "clock_frequency_mhz": 125.0, "read_latency": 4, "write_latency": 2, + "burst_length": 16, + "timeout_cycles": 1024, }, "pcileech_config": { "buffer_size": 4096, diff --git a/src/templates/sv/pcileech_bar_impl_device.sv.j2 b/src/templates/sv/pcileech_bar_impl_device.sv.j2 index 314d682a..4ae74462 100644 --- a/src/templates/sv/pcileech_bar_impl_device.sv.j2 +++ b/src/templates/sv/pcileech_bar_impl_device.sv.j2 @@ -8,6 +8,7 @@ // Register Map Summary: {% if bar_model and bar_model.registers %} {%- for offset, reg in bar_model.registers.items() | sort %} +{%- set offset = offset | int %} // 0x{{ '%04X' % offset }}: {{ 'DWORD' if reg.width == 4 else ('WORD' if reg.width == 2 else 'BYTE') }} {{ 'RW' if reg.rw_mask else 'RO' }} (reset=0x{{ '%0*X' % (reg.width * 2, reg.reset) }}) {% endfor -%} {% else %} @@ -53,6 +54,7 @@ module pcileech_bar_impl_device #( // ======================================================================== {% for offset, reg in bar_model.registers.items() | sort %} + {%- set offset = offset | int %} {%- set reg_name = 'reg_0x%04X' % offset %} {%- set reg_width_bits = reg.width * 8 %} reg [{{ reg_width_bits - 1 }}:0] {{ reg_name }}; // Offset 0x{{ '%04X' % offset }} @@ -69,6 +71,7 @@ module pcileech_bar_impl_device #( always @(posedge clk) begin if (rst) begin {% for offset, reg in bar_model.registers.items() | sort %} + {%- set offset = offset | int %} {%- set reg_name = 'reg_0x%04X' % offset %} {{ reg_name }} <= {{ reg.width * 8 }}'h{{ '%0*X' % (reg.width * 2, reg.reset) }}; {% endfor %} @@ -80,6 +83,7 @@ module pcileech_bar_impl_device #( else if (wr_valid) begin case (wr_addr[31:0] & 32'hFFFFFFFC) // Align to DWORD boundary {% for offset, reg in bar_model.registers.items() | sort %} + {%- set offset = offset | int %} {%- if reg.rw_mask != 0 %} {%- set reg_name = 'reg_0x%04X' % offset %} {%- set aligned_offset = (offset // 4) * 4 %} @@ -138,6 +142,7 @@ module pcileech_bar_impl_device #( if (rd_req_valid) begin case (rd_req_addr[31:0] & 32'hFFFFFFFC) // Align to DWORD {% for offset, reg in bar_model.registers.items() | sort %} + {%- set offset = offset | int %} {%- set reg_name = 'reg_0x%04X' % offset %} {%- set aligned_offset = (offset // 4) * 4 %} 32'h{{ '%08X' % aligned_offset }}: begin diff --git a/src/templating/sv_constants.py b/src/templating/sv_constants.py index 8c8661ea..11b16060 100644 --- a/src/templating/sv_constants.py +++ b/src/templating/sv_constants.py @@ -208,23 +208,15 @@ class SVConstants: class SVTemplates: """Template paths for SystemVerilog generation.""" - DEVICE_SPECIFIC_PORTS: str = ( - "systemverilog/components/" "device_specific_ports.sv.j2" - ) + # Only paths with existing templates and live consumers are listed. The + # overlay-only generation path does not use standalone systemverilog/*.j2 + # module templates. MAIN_ADVANCED_CONTROLLER: str = "sv/advanced_controller.sv.j2" CLOCK_CROSSING: str = "sv/clock_crossing.sv.j2" BUILD_INTEGRATION: str = "python/build_integration.py.j2" PCILEECH_INTEGRATION: str = "python/pcileech_build_integration.py.j2" - PCILEECH_TLPS_BAR_CONTROLLER: str = ( - "systemverilog/pcileech_tlps128_bar_controller.sv.j2" - ) - PCILEECH_FIFO: str = "systemverilog/pcileech_fifo.sv.j2" - DEVICE_CONFIG: str = "systemverilog/device_config.sv.j2" - TOP_LEVEL_WRAPPER: str = "systemverilog/top_level_wrapper.sv.j2" + # Resolves via template_mapping to sv/pcileech_cfgspace.coe.j2. PCILEECH_CFGSPACE: str = "systemverilog/pcileech_cfgspace.coe.j2" - MSIX_CAPABILITY_REGISTERS: str = "systemverilog/msix_capability_registers.sv.j2" - MSIX_IMPLEMENTATION: str = "systemverilog/msix_implementation.sv.j2" - MSIX_TABLE: str = "systemverilog/msix_table.sv.j2" # Basic modules list BASIC_SV_MODULES: List[str] = [ diff --git a/src/templating/template_renderer.py b/src/templating/template_renderer.py index 975ae544..c533de6b 100644 --- a/src/templating/template_renderer.py +++ b/src/templating/template_renderer.py @@ -77,7 +77,7 @@ def parse(self, parser): def _raise_error(self, message, caller=None): """Raise a template runtime error with the given message. - + Args: message: The error message to raise caller: Jinja2 CallBlock automatically passes this argument @@ -403,21 +403,37 @@ def safe_int_filter(value, default=0): self.env.filters["safe_int"] = safe_int_filter # Bitwise operation filters (Jinja2 doesn't support | and ^ as Python operators) + def _bit_int(value): + """Coerce a value to int for bitwise ops. + + String operands are the bitwise filters' main use: vendor/device/ + subsystem IDs. PCI IDs are always hexadecimal, so bare strings are + parsed as base-16 (never decimal), and a "0x" prefix is accepted. + See issue #620. + """ + if not value: + return 0 + if isinstance(value, int): + return value + if isinstance(value, str): + return int(value, 16) + return int(value) + def bitor(value, other): """Bitwise OR filter: value | other""" - return int(value or 0) | int(other or 0) + return _bit_int(value) | _bit_int(other) def bitxor(value, other): """Bitwise XOR filter: value ^ other""" - return int(value or 0) ^ int(other or 0) + return _bit_int(value) ^ _bit_int(other) def bitand(value, other): """Bitwise AND filter: value & other""" - return int(value or 0) & int(other or 0) + return _bit_int(value) & _bit_int(other) def bitnot(value): """Bitwise NOT filter: ~value (with 32-bit mask)""" - return ~int(value or 0) & 0xFFFFFFFF + return ~_bit_int(value) & 0xFFFFFFFF self.env.filters["bitor"] = bitor self.env.filters["bitxor"] = bitxor @@ -675,8 +691,8 @@ def _validate_template_context( self, context: Dict[str, Any], template_name: Optional[str] = None, - _required_fields: Optional[list] = None, - _optional_fields: Optional[list] = None, + _required_fields: Optional[list] = None, + _optional_fields: Optional[list] = None, ) -> Dict[str, Any]: """ Validate and prepare template context with permissive validation. diff --git a/src/tui/core/build_orchestrator.py b/src/tui/core/build_orchestrator.py index 1810e144..962fd373 100644 --- a/src/tui/core/build_orchestrator.py +++ b/src/tui/core/build_orchestrator.py @@ -584,7 +584,7 @@ async def _ensure_git_repo(self) -> None: try: # RepoManager will raise RuntimeError if submodule not initialized repo_path = RepoManager.ensure_repo() - + if self._current_progress: self._current_progress.current_operation = ( f"voltcyclone-fpga submodule ready at {repo_path}" @@ -595,7 +595,7 @@ async def _ensure_git_repo(self) -> None: self._add_progress_error( "Submodule not initialized: {msg}. " "Run: git submodule update --init --recursive", - msg=error_msg + msg=error_msg, ) raise @@ -922,13 +922,19 @@ async def _drain(stream, sink: Optional[List[bytes]]) -> None: if sink is None: line_str = line.decode("utf-8", errors="replace").strip() if line_str: - for pattern, (stage, percent, msg) in LOG_PROGRESS_TOKENS.items(): + for pattern, ( + stage, + percent, + msg, + ) in LOG_PROGRESS_TOKENS.items(): if pattern in line_str: await self._update_progress(stage, percent, msg) break stdout_task = asyncio.create_task(_drain(self._build_process.stdout, None)) - stderr_task = asyncio.create_task(_drain(self._build_process.stderr, stderr_chunks)) + stderr_task = asyncio.create_task( + _drain(self._build_process.stderr, stderr_chunks) + ) try: while True: @@ -1185,12 +1191,11 @@ async def _run_behavior_profiling( device: The PCIe device to profile config: Build configuration """ - # Import behavior profiler - import sys - from pathlib import Path - - sys.path.append(str(Path(__file__).parent.parent.parent)) - from device_clone.behavior_profiler import BehaviorProfiler + # Import behavior profiler (kept function-local to defer the import + # cost until profiling is actually requested). + from pcileechfwgenerator.device_clone.behavior_profiler import ( + BehaviorProfiler, + ) # Log the start of profiling if self._current_progress: diff --git a/src/tui/models/config.py b/src/tui/models/config.py index 32c73771..3f5c11a6 100644 --- a/src/tui/models/config.py +++ b/src/tui/models/config.py @@ -1,101 +1,16 @@ +"""Backward-compatibility shim for the TUI configuration models. + +The canonical models now live in +:mod:`pcileechfwgenerator.tui.models.configuration` as Pydantic models with +validation. This module previously defined a parallel dataclass +``BuildConfiguration``/``BuildProgress`` with the same fields; those were +consolidated to remove the duplication. Import from ``configuration`` (or the +``models`` package) directly in new code. """ -Configuration models for the PCILeech TUI application. -This module defines data classes for representing build configurations. -""" - -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional - - -@dataclass -class BuildConfiguration: - """Configuration for a PCILeech firmware build.""" - - name: str = "Default Configuration" - description: str = "Standard configuration for PCIe devices" - device_id: Optional[str] = None - device_type: str = "default" - board_type: str = "default" - output_directory: Optional[str] = None - - # Build options - optimization_level: str = "balanced" - debug_mode: bool = False - enable_logging: bool = True - enable_performance_counters: bool = False - enable_error_counters: bool = True - - # Feature toggles - advanced_sv: bool = True - enable_variance: bool = True - behavior_profiling: bool = False - disable_ftrace: bool = False - power_management: bool = True - error_handling: bool = True - performance_counters: bool = True - flash_after_build: bool = False - - # Donor configuration - donor_dump: bool = True - auto_install_headers: bool = False - local_build: bool = False - skip_board_check: bool = False - donor_info_file: Optional[str] = None - profile_duration: float = 30.0 - - # Advanced options - custom_parameters: Dict[str, Any] = field(default_factory=dict) - feature_flags: Dict[str, bool] = field(default_factory=dict) - compatibility_overrides: List[str] = field(default_factory=list) - - @property - def is_advanced(self) -> bool: - """Check if advanced features are enabled.""" - return self.advanced_sv - - def to_dict(self) -> Dict[str, Any]: - """Convert configuration to a dictionary for serialization.""" - from dataclasses import asdict - - return asdict(self) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "BuildConfiguration": - """Create a configuration from a dictionary.""" - # Filter out any keys that are not valid parameters - valid_keys = {field.name for field in cls.__dataclass_fields__.values()} - filtered_data = {k: v for k, v in data.items() if k in valid_keys} - return cls(**filtered_data) - - -@dataclass -class BuildProgress: - """Progress information for a PCILeech firmware build.""" - - build_id: str - status: str # "pending", "running", "completed", "failed", "cancelled" - progress: float # 0.0 to 100.0 - current_step: Optional[str] = None - message: Optional[str] = None - start_time: Optional[float] = None - end_time: Optional[float] = None - logs: List[str] = field(default_factory=list) - errors: List[str] = field(default_factory=list) - warnings: List[str] = field(default_factory=list) - - @property - def is_complete(self) -> bool: - """Check if the build is complete.""" - return self.status in ("completed", "failed", "cancelled") - - @property - def is_successful(self) -> bool: - """Check if the build was successful.""" - return self.status == "completed" and not self.errors - - def to_dict(self) -> Dict[str, Any]: - """Convert progress information to a dictionary for serialization.""" - from dataclasses import asdict +from pcileechfwgenerator.tui.models.configuration import ( # noqa: F401 + BuildConfiguration, + BuildProgress, +) - return asdict(self) +__all__ = ["BuildConfiguration", "BuildProgress"] diff --git a/src/tui/models/configuration.py b/src/tui/models/configuration.py index 56c08128..01383692 100644 --- a/src/tui/models/configuration.py +++ b/src/tui/models/configuration.py @@ -286,8 +286,15 @@ def _sync_legacy_flags(self): # Class method for compatibility with the existing codebase @classmethod def from_dict(cls, data: Dict[str, Any]) -> "BuildConfiguration": - """Create a configuration from a dictionary.""" - return cls(**data) + """Create a configuration from a dictionary. + + Unknown keys are dropped (matching the legacy dataclass behavior) so + that profiles serialized by older versions still load despite this + model's ``extra='forbid'`` policy. + """ + valid_keys = set(cls.model_fields) + filtered = {k: v for k, v in data.items() if k in valid_keys} + return cls(**filtered) def save_to_file(self, file_path): """Save configuration to a file.""" diff --git a/src/tui/utils/privilege_manager.py b/src/tui/utils/privilege_manager.py index 2a3243b7..b70607a6 100644 --- a/src/tui/utils/privilege_manager.py +++ b/src/tui/utils/privilege_manager.py @@ -1,223 +1,15 @@ -""" -Privilege management utilities for PCILeech TUI application. +"""Backward-compatibility shim for ``PrivilegeManager``. -This module provides functionality for checking and requesting elevated privileges -when performing operations that require root access. +The canonical home is :mod:`pcileechfwgenerator.utils.privilege_manager`. +``PrivilegeManager`` is a shared (CLI + TUI) utility with no TUI dependencies, +so it was relocated to the ``utils`` layer to remove a CLI->TUI import +inversion. This shim keeps the old import path working; prefer importing from +``pcileechfwgenerator.utils.privilege_manager`` directly. """ -import asyncio -import logging -import os -import shutil -import subprocess -from typing import Dict, List, Tuple - -logger = logging.getLogger(__name__) - - -class PrivilegeManager: - """ - Manages privilege elevation for operations that require root access. - - This class handles checking and requesting elevated privileges for - operations such as accessing PCI device information, modifying system - files, loading kernel modules, and writing to protected directories. - """ - - def __init__(self): - """Initialize the privilege manager. The expensive sudo probe is - deferred until first access to avoid a blocking subprocess in the - constructor (which is often run on the Textual event loop).""" - self.has_root = self._check_root() - self._can_sudo: "bool | None" = None - self.sudo_needs_password = False - # All operations are permitted by default to avoid blocking - self._operation_permissions: Dict[str, bool] = {} - - @property - def can_sudo(self) -> bool: - """Lazy-evaluated sudo availability. The first read runs a 2s probe.""" - if self._can_sudo is None: - self._can_sudo = self._check_sudo() - return self._can_sudo - - @can_sudo.setter - def can_sudo(self, value: bool) -> None: - self._can_sudo = bool(value) - - def ensure_checked(self) -> None: - """Force the sudo probe to run if it hasn't already.""" - _ = self.can_sudo - - def _check_root(self) -> bool: - """ - Check if the application is running with root privileges. - - Returns: - bool: True if running as root, False otherwise. - """ - return os.geteuid() == 0 - - def _check_sudo(self) -> bool: - """ - Check if sudo is available and the user can use it without a password. - - Sets ``self.sudo_needs_password`` if sudo exists but would prompt. - - Returns: - bool: True if sudo is available without a password, False otherwise. - """ - # Reset state at the start of each probe so a previous "needs - # password" verdict can't linger across re-probes — e.g. if the - # user runs ``sudo`` in another shell to refresh credentials and - # we re-check, can_sudo can become True and sudo_needs_password - # must reflect that. - self.sudo_needs_password = False - - try: - # Check if sudo is installed - sudo_path = shutil.which("sudo") - if not sudo_path: - return False - - # Try a benign sudo command with -n to avoid hanging on password prompt - result = subprocess.run( - ["sudo", "-n", "true"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - timeout=2.0, - ) - - if result.returncode == 0: - return True - if result.returncode == 1: - # sudo exists but requires a password; surface the state - # rather than pretending we have privileges. - self.sudo_needs_password = True - return False - return False - except (subprocess.SubprocessError, OSError): - return False - - async def request_privileges(self, operation: str) -> bool: - """ - Request privileges for a specific operation. - - Args: - operation: The operation requiring elevated privileges. - - Returns: - bool: True if privileges were obtained, False otherwise. - """ - if self.has_root: - logger.debug(f"Already have root privileges for {operation}") - return True - - # The first read of ``self.can_sudo`` triggers the synchronous - # subprocess probe (up to 2s). Run it on a worker thread so the - # Textual event loop stays responsive. - await asyncio.to_thread(self.ensure_checked) - - if self.can_sudo: - logger.debug(f"Sudo available without password for {operation}") - return True - - if self.sudo_needs_password: - logger.warning( - "Sudo is available for %s but requires a password; " - "TUI must collect credentials separately", - operation, - ) - return False - - logger.warning(f"Cannot obtain privileges for {operation}") - return False - - async def _request_sudo_permission(self, operation: str) -> bool: - """ - Request sudo permission from the user for a specific operation. - - Args: - operation: The operation requiring elevated privileges. - - Returns: - bool: True if the user granted permission, False otherwise. - """ - # This is a simplified implementation that doesn't block - # It logs the operation for debugging but always returns True - operation_descriptions = { - "access_pci_info": "access PCI device information", - "modify_system_files": "modify system files", - "load_kernel_modules": "load kernel modules", - "write_protected_dirs": "write to protected directories", - } - - description = operation_descriptions.get(operation, operation) - logger.info(f"Requesting permission to {description} using sudo") - - # Reflect actual sudo state instead of unconditionally granting. - if self.has_root: - return True - return self.can_sudo - - async def run_with_privileges( - self, command: List[str], operation: str - ) -> Tuple[bool, str, str]: - """ - Run a command with elevated privileges if needed. - - Args: - command: The command to run. - operation: The operation requiring elevated privileges. - - Returns: - Tuple[bool, str, str]: Success status, stdout, and stderr. - """ - # Simplified implementation - always assume privileges are granted - # to ensure the VFIO handler can continue operating - await self.request_privileges(operation) - - cmd = command - if not self.has_root and self.can_sudo: - cmd = ["sudo"] + command - - try: - proc = await asyncio.create_subprocess_exec( - *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await proc.communicate() - - return proc.returncode == 0, stdout.decode(), stderr.decode() - except Exception as e: - logger.error(f"Error running privileged command: {e}") - return False, "", str(e) - - -class PrivilegeRequest: - """Helper for requesting privilege elevation from the user.""" - - @staticmethod - async def request_dialog(app, operation: str, description: str) -> bool: - """ - Show a dialog requesting privilege elevation. - - Args: - app: The application instance. - operation: The operation requiring elevated privileges. - description: Human-readable description of the operation. - - Returns: - bool: True if the user granted permission, False otherwise. - """ - logger.info(f"Privilege elevation requested for {operation}: {description}") +from pcileechfwgenerator.utils.privilege_manager import ( # noqa: F401 + PrivilegeManager, + PrivilegeRequest, +) - # Reflect actual privilege state. The TUI is expected to render a - # password dialog separately when sudo_needs_password is true. - # The sudo probe is synchronous (subprocess + up to 2s timeout), so - # run it on a worker thread to keep the Textual event loop responsive. - manager = PrivilegeManager() - if manager.has_root: - return True - await asyncio.to_thread(manager.ensure_checked) - return manager.can_sudo +__all__ = ["PrivilegeManager", "PrivilegeRequest"] diff --git a/src/utils/__init__.py b/src/utils/__init__.py index caaeaf2b..eea1a004 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -5,6 +5,6 @@ the PCILeech TUI and CLI applications. """ -from . import system_status +from . import privilege_manager, system_status -__all__ = ["system_status"] +__all__ = ["privilege_manager", "system_status"] diff --git a/src/utils/privilege_manager.py b/src/utils/privilege_manager.py new file mode 100644 index 00000000..db7b2117 --- /dev/null +++ b/src/utils/privilege_manager.py @@ -0,0 +1,263 @@ +""" +Privilege management utilities for PCILeech TUI application. + +This module provides functionality for checking and requesting elevated privileges +when performing operations that require root access. +""" + +import asyncio +import logging +import os +import shutil +import subprocess +from typing import Dict, List, Tuple + +from pcileechfwgenerator.string_utils import ( + log_debug_safe, + log_error_safe, + log_info_safe, + log_warning_safe, + safe_format, +) + +logger = logging.getLogger(__name__) + + +class PrivilegeManager: + """ + Manages privilege elevation for operations that require root access. + + This class handles checking and requesting elevated privileges for + operations such as accessing PCI device information, modifying system + files, loading kernel modules, and writing to protected directories. + """ + + def __init__(self): + """Initialize the privilege manager. The expensive sudo probe is + deferred until first access to avoid a blocking subprocess in the + constructor (which is often run on the Textual event loop).""" + self.has_root = self._check_root() + self._can_sudo: "bool | None" = None + self.sudo_needs_password = False + # All operations are permitted by default to avoid blocking + self._operation_permissions: Dict[str, bool] = {} + + @property + def can_sudo(self) -> bool: + """Lazy-evaluated sudo availability. The first read runs a 2s probe.""" + if self._can_sudo is None: + self._can_sudo = self._check_sudo() + return self._can_sudo + + @can_sudo.setter + def can_sudo(self, value: bool) -> None: + self._can_sudo = bool(value) + + def ensure_checked(self) -> None: + """Force the sudo probe to run if it hasn't already.""" + _ = self.can_sudo + + def _check_root(self) -> bool: + """ + Check if the application is running with root privileges. + + Returns: + bool: True if running as root, False otherwise. + """ + return os.geteuid() == 0 + + def _check_sudo(self) -> bool: + """ + Check if sudo is available and the user can use it without a password. + + Sets ``self.sudo_needs_password`` if sudo exists but would prompt. + + Returns: + bool: True if sudo is available without a password, False otherwise. + """ + # Reset state at the start of each probe so a previous "needs + # password" verdict can't linger across re-probes — e.g. if the + # user runs ``sudo`` in another shell to refresh credentials and + # we re-check, can_sudo can become True and sudo_needs_password + # must reflect that. + self.sudo_needs_password = False + + try: + # Check if sudo is installed + sudo_path = shutil.which("sudo") + if not sudo_path: + return False + + # Try a benign sudo command with -n to avoid hanging on password prompt + result = subprocess.run( + ["sudo", "-n", "true"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=2.0, + ) + + if result.returncode == 0: + return True + if result.returncode == 1: + # sudo exists but requires a password; surface the state + # rather than pretending we have privileges. + self.sudo_needs_password = True + return False + return False + except (subprocess.SubprocessError, OSError): + return False + + async def request_privileges(self, operation: str) -> bool: + """ + Request privileges for a specific operation. + + Args: + operation: The operation requiring elevated privileges. + + Returns: + bool: True if privileges were obtained, False otherwise. + """ + if self.has_root: + log_debug_safe( + logger, + safe_format("Already have root privileges for {op}", op=operation), + prefix="PRIV", + ) + return True + + # The first read of ``self.can_sudo`` triggers the synchronous + # subprocess probe (up to 2s). Run it on a worker thread so the + # Textual event loop stays responsive. + await asyncio.to_thread(self.ensure_checked) + + if self.can_sudo: + log_debug_safe( + logger, + safe_format("Sudo available without password for {op}", op=operation), + prefix="PRIV", + ) + return True + + if self.sudo_needs_password: + log_warning_safe( + logger, + safe_format( + "Sudo is available for {op} but requires a password; " + "TUI must collect credentials separately", + op=operation, + ), + prefix="PRIV", + ) + return False + + log_warning_safe( + logger, + safe_format("Cannot obtain privileges for {op}", op=operation), + prefix="PRIV", + ) + return False + + async def _request_sudo_permission(self, operation: str) -> bool: + """ + Request sudo permission from the user for a specific operation. + + Args: + operation: The operation requiring elevated privileges. + + Returns: + bool: True if the user granted permission, False otherwise. + """ + # This is a simplified implementation that doesn't block + # It logs the operation for debugging but always returns True + operation_descriptions = { + "access_pci_info": "access PCI device information", + "modify_system_files": "modify system files", + "load_kernel_modules": "load kernel modules", + "write_protected_dirs": "write to protected directories", + } + + description = operation_descriptions.get(operation, operation) + log_info_safe( + logger, + safe_format("Requesting permission to {desc} using sudo", desc=description), + prefix="PRIV", + ) + + # Reflect actual sudo state instead of unconditionally granting. + if self.has_root: + return True + return self.can_sudo + + async def run_with_privileges( + self, command: List[str], operation: str + ) -> Tuple[bool, str, str]: + """ + Run a command with elevated privileges if needed. + + Args: + command: The command to run. + operation: The operation requiring elevated privileges. + + Returns: + Tuple[bool, str, str]: Success status, stdout, and stderr. + """ + # Simplified implementation - always assume privileges are granted + # to ensure the VFIO handler can continue operating + await self.request_privileges(operation) + + cmd = command + if not self.has_root and self.can_sudo: + cmd = ["sudo"] + command + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + + return proc.returncode == 0, stdout.decode(), stderr.decode() + except Exception as e: + log_error_safe( + logger, + safe_format("Error running privileged command: {err}", err=str(e)), + prefix="PRIV", + ) + return False, "", str(e) + + +class PrivilegeRequest: + """Helper for requesting privilege elevation from the user.""" + + @staticmethod + async def request_dialog(app, operation: str, description: str) -> bool: + """ + Show a dialog requesting privilege elevation. + + Args: + app: The application instance. + operation: The operation requiring elevated privileges. + description: Human-readable description of the operation. + + Returns: + bool: True if the user granted permission, False otherwise. + """ + log_info_safe( + logger, + safe_format( + "Privilege elevation requested for {op}: {desc}", + op=operation, + desc=description, + ), + prefix="PRIV", + ) + + # Reflect actual privilege state. The TUI is expected to render a + # password dialog separately when sudo_needs_password is true. + # The sudo probe is synchronous (subprocess + up to 2s timeout), so + # run it on a worker thread to keep the Textual event loop responsive. + manager = PrivilegeManager() + if manager.has_root: + return True + await asyncio.to_thread(manager.ensure_checked) + return manager.can_sudo diff --git a/src/vivado_handling/fifo_donor_patcher.py b/src/vivado_handling/fifo_donor_patcher.py index 97ec0b06..6a1b0df4 100644 --- a/src/vivado_handling/fifo_donor_patcher.py +++ b/src/vivado_handling/fifo_donor_patcher.py @@ -13,8 +13,10 @@ ``IfPCIeFifoCore`` interface never declares — that mismatch is what makes 75t484_x1 fail synthesis with ``cannot resolve hierarchical name``. """ + from __future__ import annotations +import os import re from dataclasses import dataclass from pathlib import Path @@ -39,11 +41,21 @@ class DonorIDs: # RW-reset-block anchors. Ordered (label, regex, hex-width, donor-attr). # The regex captures up to the literal so the suffix (``;`` + comment) survives. _RW_ANCHORS = ( - ("+010: CFG_SUBSYS_VEND_ID", r"(rw\[143:128\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", 4, "subsystem_vendor_id"), - ("+012: CFG_SUBSYS_ID", r"(rw\[159:144\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", 4, "subsystem_id"), - ("+014: CFG_VEND_ID", r"(rw\[175:160\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", 4, "vendor_id"), - ("+016: CFG_DEV_ID", r"(rw\[191:176\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", 4, "device_id"), - ("+018: CFG_REV_ID", r"(rw\[199:192\]\s*<=\s*)8'h[0-9A-Fa-f]{2}", 2, "revision_id"), + ( + "+010: CFG_SUBSYS_VEND_ID", + r"(rw\[143:128\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", + 4, + "subsystem_vendor_id", + ), + ( + "+012: CFG_SUBSYS_ID", + r"(rw\[159:144\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", + 4, + "subsystem_id", + ), + ("+014: CFG_VEND_ID", r"(rw\[175:160\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", 4, "vendor_id"), + ("+016: CFG_DEV_ID", r"(rw\[191:176\]\s*<=\s*)16'h[0-9A-Fa-f]{4}", 4, "device_id"), + ("+018: CFG_REV_ID", r"(rw\[199:192\]\s*<=\s*)8'h[0-9A-Fa-f]{2}", 2, "revision_id"), ) # Trailing 5 fields of the packed _pcie_core_config initializer: @@ -66,9 +78,7 @@ class DonorIDs: # Active (non-commented) assign to any dpcie field. Used to detect mismatches # between the staged fifo and header that the patcher can't auto-fix. -_DPCIE_ASSIGN_RE = re.compile( - r"^\s*assign\s+dpcie\.([A-Za-z_][A-Za-z0-9_]*)\b" -) +_DPCIE_ASSIGN_RE = re.compile(r"^\s*assign\s+dpcie\.([A-Za-z_][A-Za-z0-9_]*)\b") # A wire/reg/logic declaration name inside an interface body. Conservative # regex: matches "wire " / "wire [N:0] " forms. We're only @@ -102,9 +112,7 @@ def _patch_rw_reset_block(text: str, donor: DonorIDs) -> str: for label, pattern, width_hex, attr in _RW_ANCHORS: value = getattr(donor, attr) _validate_width(attr, value, width_hex * 4) - replacement_literal = ( - f"{width_hex * 4}'h{_hex(value, width_hex)}" - ) + replacement_literal = f"{width_hex * 4}'h{_hex(value, width_hex)}" new_text, count = re.subn( pattern, lambda m, lit=replacement_literal: m.group(1) + lit, @@ -112,9 +120,7 @@ def _patch_rw_reset_block(text: str, donor: DonorIDs) -> str: count=1, ) if count != 1: - raise FifoPatchError( - f"FIFO reset-block anchor not found: {label}" - ) + raise FifoPatchError(f"FIFO reset-block anchor not found: {label}") out = new_text return out @@ -143,9 +149,7 @@ def _patch_pcie_core_config_initializer(text: str, donor: DonorIDs) -> str: count=1, ) if count != 1: - raise FifoPatchError( - "_pcie_core_config packed initializer not found in FIFO" - ) + raise FifoPatchError("_pcie_core_config packed initializer not found in FIFO") return new_text @@ -287,6 +291,17 @@ def pick(int_key: str, str_key: str) -> Optional[int]: if not vendor_id or not device_id: return None + # Reject known synthetic/placeholder identities (non-zero defaults that the + # codebase fabricates when donor collection fails). The escape hatch only + # bypasses this pair check — never the missing/zero check above. + if os.environ.get("PCILEECH_ALLOW_PLACEHOLDER_IDS") != "1": + from pcileechfwgenerator.device_clone.constants import ( + is_placeholder_donor_id, + ) + + if is_placeholder_donor_id(vendor_id, device_id): + return None + if revision_id is None: revision_id = 0 if not subsystem_vendor_id: @@ -329,9 +344,10 @@ def _detect_unpatched_dpcie_mismatches( return [] declared = _extract_interface_member_names(header_text) - known_cfg_fields = {f"pcie_cfg_{tag}" for tag in ( - "subsys_vend_id", "subsys_id", "vend_id", "dev_id", "rev_id" - )} + known_cfg_fields = { + f"pcie_cfg_{tag}" + for tag in ("subsys_vend_id", "subsys_id", "vend_id", "dev_id", "rev_id") + } missing = [] for line in fifo_text.splitlines(): @@ -392,9 +408,7 @@ def _active_cfg_id_assigns(text: str) -> int: and _CFG_ID_ASSIGN_RE.match(line) is not None ) - cfg_commented = ( - _active_cfg_id_assigns(fifo_text) > _active_cfg_id_assigns(new_text) - ) + cfg_commented = _active_cfg_id_assigns(fifo_text) > _active_cfg_id_assigns(new_text) # After the patcher has commented-out the known cfg-id mismatches, any # remaining dpcie. assigned in the fifo but absent from the header diff --git a/src/vivado_handling/vivado_error_reporter.py b/src/vivado_handling/vivado_error_reporter.py index b11728a2..9fa91465 100644 --- a/src/vivado_handling/vivado_error_reporter.py +++ b/src/vivado_handling/vivado_error_reporter.py @@ -16,6 +16,12 @@ from pathlib import Path from typing import List, Optional, Tuple, Union +from pcileechfwgenerator.string_utils import ( + log_error_safe, + log_info_safe, + safe_format, +) + class VivadoErrorType(Enum): """Types of Vivado errors.""" @@ -555,7 +561,11 @@ def monitor_vivado_process( return return_code, errors, warnings except Exception as e: - self.logger.error(f"Error monitoring Vivado process: {e}") + log_error_safe( + self.logger, + safe_format("Error monitoring Vivado process: {err}", err=str(e)), + prefix="VIVADO", + ) return -1, errors, warnings def generate_error_report( @@ -631,9 +641,19 @@ def generate_error_report( # Write plain text version (no ANSI codes) plain_content = self._strip_ansi_codes(report_content) f.write(plain_content) - self.logger.info(f"Error report written to: {output_file}") + log_info_safe( + self.logger, + safe_format( + "Error report written to: {path}", path=str(output_file) + ), + prefix="VIVADO", + ) except Exception as e: - self.logger.error(f"Failed to write error report: {e}") + log_error_safe( + self.logger, + safe_format("Failed to write error report: {err}", err=str(e)), + prefix="VIVADO", + ) return report_content diff --git a/src/vivado_handling/vivado_utils.py b/src/vivado_handling/vivado_utils.py index db504b71..72b6901d 100644 --- a/src/vivado_handling/vivado_utils.py +++ b/src/vivado_handling/vivado_utils.py @@ -77,7 +77,7 @@ def _vivado_executable(dir_: Path) -> Optional[Path]: return exe if exe.is_file() else None -def _VIVADOct_version(dir_: Path) -> str: +def _detect_version(dir_: Path) -> str: """Infer version string from directory name (fallback to runtime query).""" for part in dir_.parts: if part[0].isdigit() and "." in part: @@ -103,7 +103,7 @@ def find_vivado_installation( if manual_path_obj.exists() and manual_path_obj.is_dir(): exe = _vivado_executable(manual_path_obj) if exe: - version = get_vivado_version(str(exe)) or _VIVADOct_version( + version = get_vivado_version(str(exe)) or _detect_version( manual_path_obj ) log_info_safe( @@ -136,12 +136,12 @@ def find_vivado_installation( prefix="VIVADO", ) - # Fallback to automatic VIVADOction + # Fallback to automatic detection for root in _iter_candidate_dirs(): exe = _vivado_executable(root) if not exe: continue - version = get_vivado_version(str(exe)) or _VIVADOct_version(root) + version = get_vivado_version(str(exe)) or _detect_version(root) log_debug_safe( LOG, safe_format( @@ -310,8 +310,8 @@ def get_vivado_executable() -> Optional[str]: def debug_vivado_search() -> None: - """Pretty print search logic and VIVADOction results (stdout‑only).""" - print("# Vivado VIVADOction report ({}):".format(time.strftime("%F %T"))) + """Pretty print search logic and detection results (stdout‑only).""" + print("# Vivado detection report ({}):".format(time.strftime("%F %T"))) print("Search order:") for p in get_vivado_search_paths(): print(" •", p) diff --git a/tests/e2e/test_smoke_imports.py b/tests/e2e/test_smoke_imports.py index 4b810358..1d7fc4db 100644 --- a/tests/e2e/test_smoke_imports.py +++ b/tests/e2e/test_smoke_imports.py @@ -46,6 +46,7 @@ "pcileechfwgenerator.templating.sv_context_builder", "pcileechfwgenerator.templating.advanced_sv_perf", "pcileechfwgenerator.utils.version_resolver", + "pcileechfwgenerator.utils.privilege_manager", "pcileechfwgenerator.string_utils", "pcileechfwgenerator.exceptions", "pcileechfwgenerator.__version__", diff --git a/tests/test_bar_implementation_generation.py b/tests/test_bar_implementation_generation.py index 62edc29f..eb57c64e 100644 --- a/tests/test_bar_implementation_generation.py +++ b/tests/test_bar_implementation_generation.py @@ -90,6 +90,12 @@ def context_with_bar_models(self, serialized_bar_model): "device_signature": "1234:5678", "vendor_id": "0x1234", "device_id": "0x5678", + # device_config carries the donor-bound IDs the overlay generator + # validates (top-level vendor_id/device_id are not sufficient). + "device_config": { + "vendor_id": "0x1234", + "device_id": "0x5678", + }, "config_space": {"raw_data": "00" * 256, "size": 256}, "bar_config": { "primary_bar": 0, @@ -119,9 +125,6 @@ def context_without_bar_models(self): } } - @pytest.mark.skip( - reason="Fixture needs update for actual data flow - functionality verified" - ) def test_generate_bar_implementation_with_models( self, overlay_generator, context_with_bar_models ): @@ -149,9 +152,6 @@ def test_generate_bar_implementation_without_models( # Should return None when no models available assert result is None - @pytest.mark.skip( - reason="Fixture needs update for actual data flow - functionality verified" - ) def test_generate_bar_implementation_with_interrupt_config( self, overlay_generator, context_with_bar_models ): @@ -164,9 +164,6 @@ def test_generate_bar_implementation_with_interrupt_config( assert "interrupt_assert" in result assert "interrupt_data" in result - @pytest.mark.skip( - reason="Fixture needs update for actual data flow - functionality verified" - ) def test_generate_bar_implementation_register_widths( self, overlay_generator, context_with_bar_models ): @@ -181,9 +178,6 @@ def test_generate_bar_implementation_register_widths( # WORD register (16-bit) assert "reg [15:0] reg_0x0008" in result - @pytest.mark.skip( - reason="Fixture needs update for actual data flow - functionality verified" - ) def test_generate_bar_implementation_rw_masks( self, overlay_generator, context_with_bar_models ): @@ -225,7 +219,9 @@ def test_generate_bar_controller_without_models( assert "served register window" in result @pytest.mark.skip( - reason="Requires full context - integration test needed" + reason="Full generate_config_space_overlay() needs the complete " + "production context (timing_config, etc.); covered by e2e overlay " + "tests. This unit fixture intentionally provides only BAR-impl context." ) def test_generate_config_space_overlay_includes_bar_files( self, overlay_generator, context_with_bar_models @@ -338,12 +334,14 @@ def test_template_renders_with_minimal_context(self, template_renderer): assert "module pcileech_bar_impl_device" in result assert "Fallback" in result - @pytest.mark.skip(reason="Template offset formatting requires hex strings not integers - needs fixture update") def test_template_renders_with_full_context(self, template_renderer): """Test that template renders with complete BAR model.""" full_context = { "header": "// Test Header", "device_signature": "1234:5678", + # MSI interrupt seed line references vendor_id/device_id. + "vendor_id": "0x1234", + "device_id": "0x5678", "bar_model": { "size": 4096, "registers": { @@ -463,9 +461,6 @@ def test_malformed_bar_model(self, overlay_generator): # Non-fatal error - may return None or fallback implementation assert result is None or "module pcileech_bar_impl_device" in result - @pytest.mark.skip( - reason="Fixture needs update for actual data flow - functionality verified" - ) def test_multiple_bar_models_selects_primary(self, overlay_generator): """Test that primary BAR is selected from multiple models.""" from pcileechfwgenerator.device_clone.bar_model_loader import serialize_bar_model diff --git a/tests/test_bar_size_conversion.py b/tests/test_bar_size_conversion.py index f845e783..18bd7961 100644 --- a/tests/test_bar_size_conversion.py +++ b/tests/test_bar_size_conversion.py @@ -3,14 +3,10 @@ Test script for BAR Size Conversion functionality. """ -import sys -from pathlib import Path # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.device_clone.bar_size_converter import BarSizeConverter -from pcileechfwgenerator.device_clone.constants import BAR_SIZE_CONSTANTS def test_size_to_encoding(): diff --git a/tests/test_build_context_strict.py b/tests/test_build_context_strict.py index 0632ca68..1e005310 100644 --- a/tests/test_build_context_strict.py +++ b/tests/test_build_context_strict.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 """Tests for strict identity handling in BuildContext and format_hex_id.""" -import sys -from pathlib import Path import pytest -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.templating.tcl_builder import ( BuildContext, diff --git a/tests/test_build_helpers_unit.py b/tests/test_build_helpers_unit.py index 7516d29c..7761dcd4 100644 --- a/tests/test_build_helpers_unit.py +++ b/tests/test_build_helpers_unit.py @@ -4,7 +4,7 @@ import pytest -from src import build_helpers as bh +from pcileechfwgenerator import build_helpers as bh def test_add_src_to_path_idempotent(): diff --git a/tests/test_build_integration_timing_required.py b/tests/test_build_integration_timing_required.py new file mode 100644 index 00000000..439dca2f --- /dev/null +++ b/tests/test_build_integration_timing_required.py @@ -0,0 +1,68 @@ +"""The generated build-integration module must require real timing data. + +Timing parameters come from the collected donor profile (AGENTS.md: no +fallbacks for timing). The generated ``TimingConfig`` therefore has no field +defaults, and ``create_build_config`` raises ``ValueError`` when any timing key +is missing rather than silently inventing one. +""" + +import inspect + +import pytest +from pcileechfwgenerator.templating.template_renderer import TemplateRenderer + +TEMPLATE = "python/pcileech_build_integration.py.j2" +RENDER_CONTEXT = { + "pcileech_modules": ["pcileech_fifo", "bar_controller"], + "integration_type": "pcileech", +} +COMPLETE_TIMING = { + "clock_frequency_mhz": 100.0, + "read_latency": 4, + "write_latency": 2, + "burst_length": 16, + "timeout_cycles": 1024, +} + + +def _load_generated_module(): + """Render the build-integration template and exec it into a namespace.""" + renderer = TemplateRenderer() + source = renderer.render_template(TEMPLATE, RENDER_CONTEXT) + namespace: dict = {} + exec(compile(source, "generated_build_integration.py", "exec"), namespace) + return namespace + + +def test_timing_config_has_no_field_defaults(): + ns = _load_generated_module() + sig = inspect.signature(ns["TimingConfig"]) + required = [ + p.name for p in sig.parameters.values() if p.default is inspect.Parameter.empty + ] + assert set(required) == set(COMPLETE_TIMING.keys()) + + +def test_create_build_config_succeeds_with_complete_timing(): + ns = _load_generated_module() + cfg = ns["create_build_config"]({"timing_config": dict(COMPLETE_TIMING)}) + assert cfg.timing.clock_frequency_mhz == 100.0 + assert cfg.timing.burst_length == 16 + + +@pytest.mark.parametrize("missing_key", sorted(COMPLETE_TIMING.keys())) +def test_create_build_config_raises_on_missing_timing_key(missing_key): + ns = _load_generated_module() + partial = {k: v for k, v in COMPLETE_TIMING.items() if k != missing_key} + with pytest.raises(ValueError, match="timing"): + ns["create_build_config"]({"timing_config": partial}) + + +def test_create_build_config_raises_on_empty_timing(): + ns = _load_generated_module() + with pytest.raises(ValueError, match="timing"): + ns["create_build_config"]({"timing_config": {}}) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_build_wrapper.py b/tests/test_build_wrapper.py index abe10030..ce2d0940 100644 --- a/tests/test_build_wrapper.py +++ b/tests/test_build_wrapper.py @@ -1,111 +1,55 @@ +"""Tests for the thin build_wrapper delegation shim. + +build_wrapper no longer manipulates sys.path / cwd; it simply delegates to +``pcileechfwgenerator.build.main``. These tests assert that delegation and the +exit-code contract. +""" import sys -import types -import pytest -from pathlib import Path -import importlib import pcileechfwgenerator.cli.build_wrapper as build_wrapper -import pcileechfwgenerator.cli.build_wrapper as build_wrapper +def test_main_delegates_to_build_main(monkeypatch): + """build_wrapper.main() should call build.main() and return its code.""" + called = {} + + def fake_main(): + called["yes"] = True + return 0 -def make_dummy_build_module(fullname: str = 'pcileechfwgenerator.build'): - """Create a lightweight module object with a main() we can observe.""" - mod = types.ModuleType(fullname) - mod.called = False # type: ignore[attr-defined] + monkeypatch.setattr(build_wrapper.build, "main", fake_main) + monkeypatch.setattr(sys, "argv", ["build_wrapper.py", "--test"]) - def _main(): # pragma: no cover - trivial - mod.called = True # type: ignore[attr-defined] + rc = build_wrapper.main() - mod.main = _main # type: ignore[attr-defined] - return mod + assert called.get("yes") is True + assert rc == 0 + # argv[0] is normalized for downstream argparse. + assert sys.argv[0] == "build.py" -@pytest.fixture(autouse=True) -def patch_sys(monkeypatch): - monkeypatch.setattr(sys, 'argv', ['build_wrapper.py', '--test']) - yield +def test_main_normalizes_none_return_to_zero(monkeypatch): + """build.main() returning None must map to exit code 0.""" + monkeypatch.setattr(build_wrapper.build, "main", lambda: None) + monkeypatch.setattr(sys, "argv", ["build_wrapper.py"]) + assert build_wrapper.main() == 0 -@pytest.mark.parametrize('container_exists', [True, False]) -def test_path_setup(monkeypatch, container_exists): - # Patch Path.exists to simulate environment - monkeypatch.setattr( - Path, - 'exists', - lambda self: container_exists if str(self) == '/app' else True - ) +def test_main_propagates_nonzero_exit_code(monkeypatch): + """A non-zero return from build.main() is propagated unchanged.""" + monkeypatch.setattr(build_wrapper.build, "main", lambda: 2) + monkeypatch.setattr(sys, "argv", ["build_wrapper.py"]) - chdir_called = {} - monkeypatch.setattr( - 'os.chdir', - lambda path: chdir_called.setdefault('chdir', path) - ) - # Patch sys.path - sys.path.clear() - # Patch __file__ - monkeypatch.setattr(build_wrapper, '__file__', __file__) - # Re-import to trigger logic + assert build_wrapper.main() == 2 + + +def test_module_does_not_mutate_sys_path(): + """Importing the wrapper must not insert anything into sys.path.""" + before = list(sys.path) import importlib + importlib.reload(build_wrapper) - # Check sys.path setup - if container_exists: - assert any('/app' in p for p in sys.path) - assert any('/app/src' in p for p in sys.path) - assert chdir_called['chdir'] == '/app/src' - else: - assert any('src' in p for p in sys.path) - assert chdir_called['chdir'].endswith('src') - - -def test_main_runs_build(monkeypatch): - dummy = make_dummy_build_module('pcileechfwgenerator.build') - # Provide a lightweight 'src' package so 'from src import build' does not - # import the real package (which has heavy side effects in __init__). - pkg = types.ModuleType('src') - pkg.__path__ = [] # mark as package - pkg.build = dummy # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, 'src', pkg) - monkeypatch.setitem(sys.modules, 'pcileechfwgenerator.build', dummy) - monkeypatch.setattr(build_wrapper, '__name__', '__main__') - monkeypatch.setattr(build_wrapper, '__file__', __file__) - # Patch sys.argv - sys.argv = ['build_wrapper.py', '--test'] - # Patch importlib - monkeypatch.setattr(importlib, 'reload', lambda mod: mod) - # Run main - build_wrapper.__main__ = True - # Simulate main block - try: - from src import build - sys.argv[0] = 'build.py' - build.main() - except Exception: - pytest.fail('build.main() should not raise') - assert getattr(dummy, 'called', False) - - -def test_import_error_fallback(monkeypatch): - - # Simulate ImportError on first import - monkeypatch.setattr(build_wrapper, '__name__', '__main__') - monkeypatch.setattr(build_wrapper, '__file__', __file__) - sys.argv = ['build_wrapper.py', '--test'] - # Remove src.build - sys.modules.pop('pcileechfwgenerator.build', None) - # Patch importlib - monkeypatch.setattr(importlib, 'reload', lambda mod: mod) - # Patch import build fallback - dummy = make_dummy_build_module('build') - monkeypatch.setitem(sys.modules, 'build', dummy) - # Simulate main block - try: - import build - sys.argv[0] = 'build.py' - build.main() - except Exception: - pytest.fail('Fallback build.main() should not raise') - assert getattr(dummy, 'called', False) + assert sys.path == before diff --git a/tests/test_capability_processor_characterization.py b/tests/test_capability_processor_characterization.py new file mode 100644 index 00000000..d2f901d2 --- /dev/null +++ b/tests/test_capability_processor_characterization.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Characterization tests for CapabilityProcessor. + +These pin the observable behavior of CapabilityProcessor over a representative +multi-capability config space: which capabilities are discovered, how they +categorize, how many patches are created/applied, and the exact resulting +config-space bytes. They exist so that refactors can be verified +behavior-preserving. If behavior changes intentionally, update the recorded +values in the same change and explain why. +""" + +import hashlib +from typing import List, Tuple + +import pytest +from pcileechfwgenerator.pci_capability.constants import ( + EXT_CAP_ID_AER, + EXT_CAP_ID_ARI, + EXT_CAP_ID_LTR, + EXT_CAP_ID_PTM, + EXT_CAP_ID_SRIOV, +) +from pcileechfwgenerator.pci_capability.core import ConfigSpace +from pcileechfwgenerator.pci_capability.processor import CapabilityProcessor +from pcileechfwgenerator.pci_capability.rules import RuleEngine +from pcileechfwgenerator.pci_capability.types import PruningAction + + +def _write_bytes(hex_list: List[str], offset: int, value_bytes: bytes) -> None: + for i, b in enumerate(value_bytes): + pos = offset + i + hex_list[pos * 2 : pos * 2 + 2] = f"{b:02x}" + + +def _write_dword(hex_list: List[str], offset: int, value: int) -> None: + _write_bytes(hex_list, offset, value.to_bytes(4, "little")) + + +def _write_word(hex_list: List[str], offset: int, value: int) -> None: + _write_bytes(hex_list, offset, value.to_bytes(2, "little")) + + +def _ext_header(cap_id: int, version: int, next_ptr: int) -> int: + return (cap_id & 0xFFFF) | ((version & 0xF) << 16) | ((next_ptr & 0xFFF) << 20) + + +def _build_multi_cap_config() -> ConfigSpace: + """A deterministic config space chaining several extended capabilities. + + Chain: AER@0x100 -> LTR@0x140 -> SR-IOV@0x160 -> ARI@0x1A0 -> PTM@0x1C0. + Some fields are pre-populated to non-default values so the MODIFY pass has + real work to do (and the resulting bytes are sensitive to handler logic). + """ + size = 1024 + hex_data = ["0"] * (size * 2) + + _write_word(hex_data, 0x00, 0x1234) # Vendor ID + _write_word(hex_data, 0x02, 0x5678) # Device ID + + chain: List[Tuple[int, int]] = [ + (EXT_CAP_ID_AER, 0x100), + (EXT_CAP_ID_LTR, 0x140), + (EXT_CAP_ID_SRIOV, 0x160), + (EXT_CAP_ID_ARI, 0x1A0), + (EXT_CAP_ID_PTM, 0x1C0), + ] + for idx, (cap_id, base) in enumerate(chain): + next_ptr = chain[idx + 1][1] if idx + 1 < len(chain) else 0 + _write_dword(hex_data, base, _ext_header(cap_id, 1, next_ptr)) + + # Pre-populate AER fields so MODIFY changes them. + _write_dword(hex_data, 0x100 + 0x08, 0xFFFFFFFF) # UE mask -> cleared + _write_dword(hex_data, 0x100 + 0x0C, 0x00000000) # UE severity -> default + _write_dword(hex_data, 0x100 + 0x14, 0x00000000) # CE mask -> default + _write_dword(hex_data, 0x100 + 0x18, 0x00000000) # AECC -> default + + return ConfigSpace("".join(hex_data)) + + +@pytest.fixture +def processor(): + cfg = _build_multi_cap_config() + return CapabilityProcessor(cfg, RuleEngine()), cfg + + +class TestCapabilityProcessorCharacterization: + def test_discovery_finds_expected_capability_ids(self, processor): + proc, _ = processor + caps = proc.discover_all_capabilities() + found_ids = sorted(info.cap_id for info in caps.values()) + # The five extended caps written into the chain. + assert found_ids == sorted( + [ + EXT_CAP_ID_AER, + EXT_CAP_ID_LTR, + EXT_CAP_ID_SRIOV, + EXT_CAP_ID_ARI, + EXT_CAP_ID_PTM, + ] + ) + + def test_categorization_is_stable(self, processor): + proc, _ = processor + cats = proc.categorize_all_capabilities() + # Map offset -> category name. + snapshot = {off: cat.name for off, cat in sorted(cats.items())} + assert snapshot == { + 0x100: snapshot.get(0x100), + 0x140: snapshot.get(0x140), + 0x160: snapshot.get(0x160), + 0x1A0: snapshot.get(0x1A0), + 0x1C0: snapshot.get(0x1C0), + } + # Every discovered capability must categorize to *some* category. + assert all(isinstance(v, str) and v for v in snapshot.values()) + assert len(snapshot) == 5 + + def test_process_capabilities_result_shape(self, processor): + proc, _ = processor + res = proc.process_capabilities([PruningAction.MODIFY]) + assert res["capabilities_found"] == 5 + assert "MODIFY" in res["processing_summary"] + assert res["patches_created"] >= 1 + assert res["patches_applied"] == res["patches_created"] + assert res["errors"] == [] + + def test_aer_modify_produces_golden_bytes(self, processor): + proc, cfg = processor + proc.process_capabilities([PruningAction.MODIFY]) + # Post-MODIFY AER field values (handler-defined defaults). + from pcileechfwgenerator.pci_capability.constants import ( + AER_CAPABILITY_VALUES, + ) + + assert cfg.read_dword(0x100 + 0x08) == 0x00000000 + assert ( + cfg.read_dword(0x100 + 0x0C) + == AER_CAPABILITY_VALUES["uncorrectable_error_severity"] + ) + assert ( + cfg.read_dword(0x100 + 0x14) + == AER_CAPABILITY_VALUES["correctable_error_mask"] + ) + assert ( + cfg.read_dword(0x100 + 0x18) + == AER_CAPABILITY_VALUES["advanced_error_capabilities"] + ) + + def test_full_config_space_hash_is_stable(self, processor): + """Lock the entire post-MODIFY config space via a content hash. + + Any change to the emitted bytes for any capability flips this hash. + """ + proc, cfg = processor + proc.process_capabilities([PruningAction.MODIFY]) + digest = hashlib.sha256(cfg.to_hex().encode()).hexdigest() + assert digest == _EXPECTED_CONFIG_HASH, ( + "Post-MODIFY config-space bytes changed. If this is intentional, " + f"update _EXPECTED_CONFIG_HASH to {digest!r}." + ) + + +# Expected post-MODIFY config-space hash. Regenerate only on an intentional +# behavior change. +_EXPECTED_CONFIG_HASH = ( + "5727e7ff738eb9424c670b348b4510a6e0ca559f7b2d845188ccea9522f9a273" +) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_coe_report.py b/tests/test_coe_report.py index 6275c38e..fc81a56a 100644 --- a/tests/test_coe_report.py +++ b/tests/test_coe_report.py @@ -4,12 +4,10 @@ Tests include failsafe behavior to ensure visualization never breaks builds. """ -import sys import tempfile from pathlib import Path # Add project root to path -sys.path.insert(0, str(Path(__file__).parent.parent)) # Test imports from pcileechfwgenerator.utils.coe_report import find_coe_files, generate_coe_report diff --git a/tests/test_device_context.py b/tests/test_device_context.py index 874449e3..5e93e7a3 100644 --- a/tests/test_device_context.py +++ b/tests/test_device_context.py @@ -6,16 +6,13 @@ based on the actual capabilities found in the device configuration space. """ -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) -from pcileechfwgenerator.pci_capability.core import CapabilityWalker, ConfigSpace +from pcileechfwgenerator.pci_capability.core import ConfigSpace from pcileechfwgenerator.pci_capability.processor import CapabilityProcessor from pcileechfwgenerator.pci_capability.rules import RuleEngine from pcileechfwgenerator.pci_capability.types import CapabilityInfo, CapabilityType diff --git a/tests/test_device_context_integration.py b/tests/test_device_context_integration.py index 1fd78164..d6ba3268 100644 --- a/tests/test_device_context_integration.py +++ b/tests/test_device_context_integration.py @@ -7,20 +7,17 @@ based on the actual capabilities found in the device. """ -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) -from pcileechfwgenerator.pci_capability.core import CapabilityWalker, ConfigSpace +from pcileechfwgenerator.pci_capability.core import ConfigSpace from pcileechfwgenerator.pci_capability.processor import CapabilityProcessor from pcileechfwgenerator.pci_capability.rules import RuleEngine from pcileechfwgenerator.pci_capability.types import (CapabilityInfo, CapabilityType, - EmulationCategory, PruningAction) + PruningAction) class TestDeviceContextIntegration: diff --git a/tests/test_donor_dump_manager.py b/tests/test_donor_dump_manager.py index a1553c38..b48a053f 100644 --- a/tests/test_donor_dump_manager.py +++ b/tests/test_donor_dump_manager.py @@ -7,7 +7,6 @@ import json import logging -import os import subprocess import sys import tempfile @@ -829,41 +828,6 @@ def test_get_module_status(temp_dir): assert "module_size" in status -def test_generate_donor_info_generic(): - """Test generate_donor_info for generic device.""" - manager = DonorDumpManager() - - with mock.patch( - "pcileechfwgenerator.file_management.donor_dump_manager.random.random", return_value=0.3 - ): - info = manager.generate_donor_info("generic") - - assert "vendor_id" in info - assert "device_id" in info - assert "revision_id" in info - assert info["revision_id"] == "0x03" - - -def test_generate_donor_info_network(): - """Test generate_donor_info for network device.""" - manager = DonorDumpManager() - - info = manager.generate_donor_info("network") - - assert info["device_id"] == "0x1533" # I210 device - assert "bar_size" in info - - -def test_generate_donor_info_storage(): - """Test generate_donor_info for storage device.""" - manager = DonorDumpManager() - - info = manager.generate_donor_info("storage") - - assert info["device_id"] == "0x2522" # NVMe device - assert "bar_size" in info - - def test_save_donor_info_success(temp_dir): """Test save_donor_info successful save.""" manager = DonorDumpManager() @@ -1122,33 +1086,22 @@ def test_setup_module_auto_install_headers(temp_dir, mock_subprocess): assert result == device_info -def test_setup_module_generate_fallback(temp_dir): - """Test setup_module with generate fallback.""" - manager = DonorDumpManager(module_source_dir=temp_dir) +def test_setup_module_failure_raises_no_synthetic_fallback(): + """setup_module must propagate failures — there is no synthetic fallback. - with mock.patch.object( - manager, "check_kernel_headers", side_effect=Exception("Headers check failed") - ): - with mock.patch.object(manager, "generate_donor_info") as mock_generate: - mock_generate.return_value = { - "vendor_id": "0x1234", - "device_id": "0x5678", - "subvendor_id": "0x1234", - "subsystem_id": "0x0000", - "revision_id": "0x01", - } - - result = manager.setup_module("0000:03:00.0", generate_if_unavailable=True) - assert result["vendor_id"] == "0x1234" - mock_generate.assert_called_once_with("generic") - - -def test_setup_module_failure_no_fallback(): - """Test setup_module failure without fallback.""" + Real donor hardware is mandatory; fabricating a profile would emit firmware + with placeholder IDs, the anti-pattern this project forbids. + """ manager = DonorDumpManager() with mock.patch.object( manager, "check_kernel_headers", side_effect=Exception("Headers check failed") ): with pytest.raises(Exception, match="Headers check failed"): - manager.setup_module("0000:03:00.0", generate_if_unavailable=False) + manager.setup_module("0000:03:00.0") + + +def test_setup_module_has_no_synthetic_generation_api(): + """The synthetic-donor generation capability must not exist.""" + manager = DonorDumpManager() + assert not hasattr(manager, "generate_donor_info") diff --git a/tests/test_donor_info_template.py b/tests/test_donor_info_template.py index 461f4476..e4e6cded 100644 --- a/tests/test_donor_info_template.py +++ b/tests/test_donor_info_template.py @@ -3,14 +3,17 @@ Unit tests for the donor info template generator. """ +import inspect import json +import re import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest - -from pcileechfwgenerator.device_clone.donor_info_template import DonorInfoTemplateGenerator +from pcileechfwgenerator.device_clone.donor_info_template import ( + DonorInfoTemplateGenerator, +) from pcileechfwgenerator.exceptions import DeviceConfigError, ValidationError @@ -591,5 +594,39 @@ def test_merge_template_with_discovered_null_handling(self): assert merged["config"]["nested"]["inner2"] == "original_inner2" +class TestNoRealExampleIds: + """Guard against real-looking vendor/device IDs leaking into the template. + + The project rejects synthetic/placeholder donor data (AGENTS.md). Example + IDs in user-fillable comments are a copy-paste hazard: a user could paste a + real-looking ``0x8086``/``0x10DE`` straight in and ship plausible-but-fake + firmware. Placeholders must be obviously-fake (``0xXXXX`` / ``<...>``). + """ + + # Real vendor/device/class IDs that must never appear as "examples". + FORBIDDEN_ID_PATTERN = re.compile( + r"0x(?:8086|10DE|10D3|10EC|1234|1533|2522|8168)\b|\b020000\b", + re.IGNORECASE, + ) + + def test_source_file_has_no_real_example_ids(self): + """The module source must not contain real example IDs in comments.""" + source_path = Path(inspect.getsourcefile(DonorInfoTemplateGenerator)) + source = source_path.read_text(encoding="utf-8") + matches = self.FORBIDDEN_ID_PATTERN.findall(source) + assert not matches, ( + f"Real example IDs found in {source_path.name}: {matches}. " + "Use obviously-fake placeholders (0xXXXX / <...>) instead." + ) + + def test_commented_template_has_no_real_example_ids(self): + """The emitted comment-annotated template must not leak real IDs.""" + rendered = DonorInfoTemplateGenerator.generate_template_with_comments() + matches = self.FORBIDDEN_ID_PATTERN.findall(rendered) + assert not matches, ( + f"Real example IDs found in rendered template: {matches}" + ) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_ext_cap_handlers.py b/tests/test_ext_cap_handlers.py index 519d5b0b..ac43f105 100644 --- a/tests/test_ext_cap_handlers.py +++ b/tests/test_ext_cap_handlers.py @@ -6,14 +6,11 @@ CapabilityProcessor generates and applies the expected patches. """ -import sys -from pathlib import Path from typing import List, Tuple import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.pci_capability.constants import (AER_CAPABILITY_VALUES, EXT_CAP_ID_AER, EXT_CAP_ID_ARI, diff --git a/tests/test_ext_cfg_pointers.py b/tests/test_ext_cfg_pointers.py index e0cab7b9..371e2860 100644 --- a/tests/test_ext_cfg_pointers.py +++ b/tests/test_ext_cfg_pointers.py @@ -3,13 +3,10 @@ Unit tests for Extended Configuration Space Pointer Control feature. """ -import sys -from pathlib import Path import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.device_clone.config_space_manager import ConfigSpaceConstants from pcileechfwgenerator.device_clone.device_config import (DeviceCapabilities, DeviceClass, diff --git a/tests/test_fifo_donor_patcher.py b/tests/test_fifo_donor_patcher.py index 5c8ae909..455e47ff 100644 --- a/tests/test_fifo_donor_patcher.py +++ b/tests/test_fifo_donor_patcher.py @@ -427,30 +427,31 @@ class TestDonorIdsFromTemplateContext: """Pull DonorIDs out of the build's template_context dict.""" def test_reads_int_suffixed_fields(self): + # 0x8086:0x10D3 (Intel 82574L) is a real, non-placeholder pair. ctx = { "device_config": { "vendor_id_int": 0x8086, - "device_id_int": 0x1533, + "device_id_int": 0x10D3, "subsystem_vendor_id_int": 0x8086, "subsystem_device_id_int": 0x0001, "revision_id_int": 0x03, } } donor = donor_ids_from_template_context(ctx) - assert donor == DonorIDs(0x8086, 0x1533, 0x8086, 0x0001, 0x03) + assert donor == DonorIDs(0x8086, 0x10D3, 0x8086, 0x0001, 0x03) def test_falls_back_to_string_fields(self): ctx = { "device_config": { "vendor_id": "0x8086", - "device_id": "0x1533", + "device_id": "0x10D3", "subsystem_vendor_id": "0x8086", "subsystem_device_id": "0x0001", "revision_id": "0x03", } } donor = donor_ids_from_template_context(ctx) - assert donor == DonorIDs(0x8086, 0x1533, 0x8086, 0x0001, 0x03) + assert donor == DonorIDs(0x8086, 0x10D3, 0x8086, 0x0001, 0x03) def test_returns_none_when_device_config_missing(self): assert donor_ids_from_template_context({}) is None @@ -460,7 +461,7 @@ def test_returns_none_when_required_field_zero(self): ctx = { "device_config": { "vendor_id_int": 0, - "device_id_int": 0x1533, + "device_id_int": 0x10D3, "subsystem_vendor_id_int": 0x8086, "subsystem_device_id_int": 0x0001, "revision_id_int": 0x03, @@ -475,14 +476,61 @@ def test_subsystem_zero_falls_back_to_main_vendor(self): ctx = { "device_config": { "vendor_id_int": 0x8086, - "device_id_int": 0x1533, + "device_id_int": 0x10D3, "subsystem_vendor_id_int": 0, "subsystem_device_id_int": 0, "revision_id_int": 0x03, } } donor = donor_ids_from_template_context(ctx) - assert donor == DonorIDs(0x8086, 0x1533, 0x8086, 0x0000, 0x03) + assert donor == DonorIDs(0x8086, 0x10D3, 0x8086, 0x0000, 0x03) + + def test_returns_none_for_known_placeholder_pair(self): + # A populated-but-synthetic pair (Intel I210 default 0x8086:0x1533) must + # be rejected the same as missing IDs — it means donor collection failed + # and a fabricated default leaked in. + ctx = { + "device_config": { + "vendor_id_int": 0x8086, + "device_id_int": 0x1533, + "subsystem_vendor_id_int": 0x8086, + "subsystem_device_id_int": 0x0001, + "revision_id_int": 0x03, + } + } + assert donor_ids_from_template_context(ctx) is None + + @pytest.mark.parametrize( + "vid,did", + [ + (0x8086, 0x1234), # generic fallback + (0x8086, 0x1533), # synthetic I210 + (0x8086, 0x2522), # synthetic NVMe + (0x10EC, 0x8168), # tcl/j2 default RTL8168 + (0x10DE, 0x2204), # synthetic GPU + (0x10EE, 0x0666), # Xilinx FIFO default + (0x10EE, 0x0007), # Xilinx FIFO default + ], + ) + def test_rejects_all_known_placeholders(self, vid, did): + ctx = {"device_config": {"vendor_id_int": vid, "device_id_int": did}} + assert donor_ids_from_template_context(ctx) is None + + def test_placeholder_allowed_with_env_escape_hatch(self, monkeypatch): + # The escape hatch lets a genuine device whose real IDs collide with the + # placeholder set still build — but never bypasses the missing/zero check. + monkeypatch.setenv("PCILEECH_ALLOW_PLACEHOLDER_IDS", "1") + ctx = { + "device_config": { + "vendor_id_int": 0x8086, + "device_id_int": 0x1533, + "subsystem_vendor_id_int": 0x8086, + "subsystem_device_id_int": 0x0001, + "revision_id_int": 0x03, + } + } + donor = donor_ids_from_template_context(ctx) + assert donor == DonorIDs(0x8086, 0x1533, 0x8086, 0x0001, 0x03) # --------------------------------------------------------------------------- @@ -524,11 +572,12 @@ def test_patches_when_donor_present(self, tmp_path): (src_dir / "pcileech_header.svh").write_text(HEADER_WITHOUT_CFG_FIELDS) builder = self._make_builder(tmp_path) + # 0x8086:0x10D3 (Intel 82574L) is a real, non-placeholder donor pair. result = { "template_context": { "device_config": { "vendor_id_int": 0x8086, - "device_id_int": 0x1533, + "device_id_int": 0x10D3, "subsystem_vendor_id_int": 0x8086, "subsystem_device_id_int": 0x0001, "revision_id_int": 0x03, @@ -540,7 +589,7 @@ def test_patches_when_donor_present(self, tmp_path): patched = (src_dir / "pcileech_fifo.sv").read_text() assert "16'h8086" in patched - assert "16'h1533" in patched + assert "16'h10D3" in patched # 75t484_x1-shaped fixture had cfg-id assigns; with header missing # the fields, they must be commented after patching. for line in patched.splitlines(): @@ -581,7 +630,7 @@ def test_raises_on_anchor_mismatch(self, tmp_path): "template_context": { "device_config": { "vendor_id_int": 0x8086, - "device_id_int": 0x1533, + "device_id_int": 0x10D3, "subsystem_vendor_id_int": 0x8086, "subsystem_device_id_int": 0x0001, "revision_id_int": 0x03, diff --git a/tests/test_import_utils_unit.py b/tests/test_import_utils_unit.py index 39655a14..c0b3f9c5 100644 --- a/tests/test_import_utils_unit.py +++ b/tests/test_import_utils_unit.py @@ -5,7 +5,7 @@ import pytest -from src import import_utils +from pcileechfwgenerator import import_utils def test_safe_import_absolute(monkeypatch): diff --git a/tests/test_msix_capability.py b/tests/test_msix_capability.py index 25568fba..0c9bb519 100644 --- a/tests/test_msix_capability.py +++ b/tests/test_msix_capability.py @@ -3,12 +3,12 @@ Unit tests for MSI-X Capability Parser with enhanced 64-bit BAR support and extended capabilities. """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from pcileechfwgenerator.device_clone.msix_capability import ( - BAR_IO_DEFAULT_SIZE, BAR_MEM_DEFAULT_SIZE, BAR_MEM_MIN_SIZE, find_cap, + find_cap, generate_msix_capability_registers, generate_msix_table_sv, hex_to_bytes, is_valid_offset, msix_size, parse_bar_info_from_config_space, parse_msix_capability, read_u8, read_u16_le, read_u32_le, @@ -791,15 +791,16 @@ def test_enhanced_validation_fallback_to_basic(self): class TestSystemVerilogGeneration: - """Test SystemVerilog code generation.""" + """Standalone MSI-X SystemVerilog generation was removed (overlay-only). - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_generate_msix_table_sv_valid(self, mock_renderer_class): - """Test SystemVerilog generation with valid MSI-X info.""" - mock_renderer = MagicMock() - mock_renderer.render_template.return_value = "// Mock SystemVerilog code" - mock_renderer_class.return_value = mock_renderer + ``generate_msix_table_sv`` / ``generate_msix_capability_registers`` rendered + ``systemverilog/*.j2`` templates that no longer exist and were never on the + live generation path. They remain as importable deprecation shims that raise + ``NotImplementedError``; MSI-X is now emitted via the overlay/config-space + path. These tests pin the deprecation contract. + """ + def test_generate_msix_table_sv_raises_not_implemented(self): msix_info = { "table_size": 16, "table_bir": 0, @@ -809,128 +810,12 @@ def test_generate_msix_table_sv_valid(self, mock_renderer_class): "enabled": True, "function_mask": False, } - - result = generate_msix_table_sv(msix_info) - - # Verify template renderer was called - assert mock_renderer.render_template.call_count >= 1 - assert "Mock SystemVerilog code" in result - - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_generate_msix_table_sv_disabled(self, mock_renderer_class): - """Test SystemVerilog generation with disabled MSI-X.""" - mock_renderer = MagicMock() - mock_renderer.render_template.return_value = "// Disabled MSI-X module" - mock_renderer_class.return_value = mock_renderer - - msix_info = { - "table_size": 0, # Disabled - "table_bir": 0, - "table_offset": 0x1000, - "pba_bir": 0, - "pba_offset": 0x2000, - "enabled": False, - "function_mask": True, - } - - result = generate_msix_table_sv(msix_info) - - # Should still generate valid code - assert "Disabled MSI-X module" in result - - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_generate_msix_table_sv_missing_fields(self, mock_renderer_class): - """Test SystemVerilog generation with missing required fields.""" - mock_renderer = MagicMock() - mock_renderer.render_template.return_value = "// Fallback code" - mock_renderer_class.return_value = mock_renderer - - # Missing some required fields - msix_info = { - "table_size": 16, - "table_bir": 0, - # Missing table_offset, pba_bir, pba_offset, enabled, function_mask - } - - # generate_msix_table_sv now raises when required fields are missing - with pytest.raises( - ValueError, match=r"Cannot generate MSI-X module - missing critical fields" - ): + with pytest.raises(NotImplementedError, match="overlay-only"): generate_msix_table_sv(msix_info) - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_generate_msix_table_sv_alignment_warning(self, mock_renderer_class): - """Test SystemVerilog generation with misaligned table offset.""" - mock_renderer = MagicMock() - mock_renderer.render_template.return_value = "// Code with warning" - mock_renderer_class.return_value = mock_renderer - - msix_info = { - "table_size": 8, - "table_bir": 0, - "table_offset": 0x1004, # Not 8-byte aligned - "pba_bir": 0, - "pba_offset": 0x2000, - "enabled": True, - "function_mask": False, - } - - result = generate_msix_table_sv(msix_info) - - # Verify the context passed to template renderer includes alignment warning - call_args = mock_renderer.render_template.call_args_list - context = call_args[0][0][1] # First call, second argument (context) - - assert "alignment_warning" in context - assert context["alignment_warning"] != "" - assert "0x1004" in context["alignment_warning"] - - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_generate_msix_table_sv_template_error_handling(self, mock_renderer_class): - """Test handling of template rendering errors.""" - mock_renderer = MagicMock() - mock_renderer.render_template.side_effect = Exception("Template error") - mock_renderer_class.return_value = mock_renderer - - msix_info = { - "table_size": 8, - "table_bir": 0, - "table_offset": 0x1000, - "pba_bir": 0, - "pba_offset": 0x2000, - "enabled": True, - "function_mask": False, - } - - # Should handle template errors gracefully - with pytest.raises(Exception, match="Template error"): - generate_msix_table_sv(msix_info) - - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_generate_msix_capability_registers(self, mock_renderer_class): - """Test MSI-X capability register generation.""" - mock_renderer = MagicMock() - mock_renderer.render_template.return_value = "// Mock capability registers" - mock_renderer_class.return_value = mock_renderer - - msix_info = { - "table_size": 8, - "table_bir": 1, - "table_offset": 0x2000, - "pba_bir": 2, - "pba_offset": 0x3000, - } - - result = generate_msix_capability_registers(msix_info) - - # Verify template renderer was called with correct context - mock_renderer.render_template.assert_called_once() - call_args = mock_renderer.render_template.call_args - assert "systemverilog/msix_capability_registers.sv.j2" in call_args[0] - - context = call_args[0][1] - assert "table_size_minus_one" in context - assert context["table_size_minus_one"] == 7 # 8 - 1 + def test_generate_msix_capability_registers_raises_not_implemented(self): + with pytest.raises(NotImplementedError, match="overlay-only"): + generate_msix_capability_registers({"table_size": 8}) class TestIntegration: @@ -993,20 +878,15 @@ def test_full_pipeline_32bit_bar(self): assert is_valid is True, f"Validation failed with errors: {errors}" assert len(errors) == 0 - @patch("pcileechfwgenerator.device_clone.msix_capability.TemplateRenderer") - def test_full_pipeline_64bit_bar(self, mock_renderer_class): + def test_full_pipeline_64bit_bar(self): """Test full pipeline with 64-bit BAR. - + This integration test verifies the complete workflow: 1. MSI-X parsing from config space - 2. 64-bit BAR detection and parsing + 2. 64-bit BAR detection and parsing 3. Enhanced validation with 64-bit BAR support - 4. SystemVerilog code generation + (SystemVerilog generation removed — overlay-only architecture.) """ - mock_renderer = MagicMock() - mock_renderer.render_template.return_value = "// Generated code" - mock_renderer_class.return_value = mock_renderer - # Create config space with 64-bit BAR and MSI-X capability cfg_bytes = bytearray([0x00] * 256) @@ -1036,11 +916,10 @@ def test_full_pipeline_64bit_bar(self, mock_renderer_class): config_space = cfg_bytes.hex().upper() - # Full pipeline test + # Full pipeline test (SystemVerilog generation removed — overlay-only). msix_info = parse_msix_capability(config_space) bars = parse_bar_info_from_config_space(config_space) is_valid, errors = validate_msix_configuration_enhanced(msix_info, config_space) - sv_code = generate_msix_table_sv(msix_info) # Verify results assert msix_info["table_size"] == 8 @@ -1054,7 +933,6 @@ def test_full_pipeline_64bit_bar(self, mock_renderer_class): # Validation should pass (basic checks) even with unknown BAR size assert is_valid is True, f"Validation failed with errors: {errors}" assert len(errors) == 0 - assert "Generated code" in sv_code class TestEdgeCasesAndStressTests: diff --git a/tests/test_oui_dsn_extraction.py b/tests/test_oui_dsn_extraction.py index 7d8f5fee..a0381654 100644 --- a/tests/test_oui_dsn_extraction.py +++ b/tests/test_oui_dsn_extraction.py @@ -11,16 +11,13 @@ import logging -import sys -from pathlib import Path from typing import Any, Dict import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.templating.sv_context_builder import SVContextBuilder diff --git a/tests/test_overlay_mapper.py b/tests/test_overlay_mapper.py index 2cc6ec1a..74a47a86 100644 --- a/tests/test_overlay_mapper.py +++ b/tests/test_overlay_mapper.py @@ -6,10 +6,7 @@ to ensure it correctly identifies registers that need overlay entries. """ -import os -import sys -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pcileechfwgenerator.device_clone.overlay_mapper import (OverlayMapper, PCIeRegisterDefinitions) diff --git a/tests/test_overlay_mapper_behaviors.py b/tests/test_overlay_mapper_behaviors.py index 34308cf9..7e104b68 100644 --- a/tests/test_overlay_mapper_behaviors.py +++ b/tests/test_overlay_mapper_behaviors.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -import os -import sys -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pcileechfwgenerator.device_clone.overlay_mapper import OverlayMapper diff --git a/tests/test_overlay_mapper_critical_paths.py b/tests/test_overlay_mapper_critical_paths.py index 72efcc71..10de7902 100644 --- a/tests/test_overlay_mapper_critical_paths.py +++ b/tests/test_overlay_mapper_critical_paths.py @@ -8,14 +8,11 @@ - detect_overlay_registers with edge case configurations """ -import os -import sys from typing import Dict from unittest.mock import patch import pytest -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pcileechfwgenerator.device_clone.overlay_mapper import OverlayMapper, RegisterType diff --git a/tests/test_overlay_realism_fixes.py b/tests/test_overlay_realism_fixes.py index b1877eb1..acca0a4b 100644 --- a/tests/test_overlay_realism_fixes.py +++ b/tests/test_overlay_realism_fixes.py @@ -14,16 +14,12 @@ """ import logging -import hashlib -import sys from pathlib import Path -from typing import Any, Dict -from unittest.mock import Mock, MagicMock +from unittest.mock import Mock import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.templating.sv_context_builder import SVContextBuilder from pcileechfwgenerator.behavioral.base import BehaviorType diff --git a/tests/test_pcileech_build_integration.py b/tests/test_pcileech_build_integration.py index b022a182..90bc4276 100644 --- a/tests/test_pcileech_build_integration.py +++ b/tests/test_pcileech_build_integration.py @@ -1,13 +1,11 @@ #!/usr/bin/env python3 """Tests for the PCILeech Build Integration module.""" -import sys import unittest from pathlib import Path from unittest.mock import MagicMock, call, patch # Add project root to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.vivado_handling.pcileech_build_integration import ( PCILeechBuildIntegration, integrate_pcileech_build) diff --git a/tests/test_pcileech_context.py b/tests/test_pcileech_context.py index a724e8cb..80af1de8 100644 --- a/tests/test_pcileech_context.py +++ b/tests/test_pcileech_context.py @@ -973,6 +973,81 @@ def test_context_completeness_validation(self, mock_config): cast(TemplateContext, invalid_context) ) + def test_context_rejects_placeholder_donor_ids(self, mock_config, monkeypatch): + """A populated-but-synthetic donor pair must fail context validation.""" + from typing import cast + + monkeypatch.delenv("PCILEECH_ALLOW_PLACEHOLDER_IDS", raising=False) + builder = PCILeechContextBuilder( + device_bdf="0000:03:00.0", + config=mock_config, + validation_level=ValidationLevel.STRICT, + ) + + # 0x8086:0x1533 is the fabricated Intel I210 default placeholder. + placeholder_context = { + "device_config": { + "vendor_id": "8086", + "device_id": "1533", + "bdf": "0000:03:00.0", + }, + "config_space": {}, + "msix_config": {}, + "bar_config": {"bars": [Mock()]}, + "timing_config": { + "clock_frequency_mhz": 100.0, + "read_latency": 4, + "write_latency": 2, + }, + "pcileech_config": {}, + "device_signature": "32'h12345678", + "generation_metadata": {}, + "interrupt_config": {"strategy": "msix"}, + "active_device_config": {}, + } + + with pytest.raises(ContextError, match="synthetic placeholder"): + builder._validate_context_completeness( + cast(TemplateContext, placeholder_context) + ) + + def test_context_placeholder_allowed_with_env_escape_hatch( + self, mock_config, monkeypatch + ): + """The escape hatch lets a genuine colliding device pass validation.""" + from typing import cast + + monkeypatch.setenv("PCILEECH_ALLOW_PLACEHOLDER_IDS", "1") + builder = PCILeechContextBuilder( + device_bdf="0000:03:00.0", + config=mock_config, + validation_level=ValidationLevel.STRICT, + ) + + context = { + "device_config": { + "vendor_id": "8086", + "device_id": "1533", + "bdf": "0000:03:00.0", + }, + "config_space": {}, + "msix_config": {}, + "bar_config": {"bars": [Mock()]}, + "timing_config": { + "clock_frequency_mhz": 100.0, + "read_latency": 4, + "write_latency": 2, + }, + "pcileech_config": {}, + "device_signature": "32'h12345678", + "generation_metadata": {}, + "interrupt_config": {"strategy": "msix"}, + "active_device_config": {}, + } + + # Should not raise with the escape hatch set. + builder._validate_context_completeness(cast(TemplateContext, context)) + def test_bar_configuration_validation(self): """Test BarConfiguration dataclass validation.""" # Valid configuration diff --git a/tests/test_pcileech_context_characterization.py b/tests/test_pcileech_context_characterization.py new file mode 100644 index 00000000..963071c5 --- /dev/null +++ b/tests/test_pcileech_context_characterization.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Characterization tests for PCILeechContextBuilder.build_context. + +These pin the top-level shape of the context that build_context() emits so that +refactors of the builder can be verified behavior-preserving: the assembled +context must keep the same complete key set and the same sub-section values. + +The per-section builder methods are mocked to known return values, isolating the +orchestration (which sections get merged, under which keys) from the per-section +logic. If the context shape changes intentionally, update the recorded key set +in the same change and explain why. +""" + +from unittest.mock import Mock, patch + +import pytest +from pcileechfwgenerator.device_clone.pcileech_context import ( + DeviceIdentifiers, + PCILeechContextBuilder, + TimingParameters, + ValidationLevel, +) + + +@pytest.fixture +def mock_config(): + cfg = Mock() + cfg.device_bdf = "0000:03:00.0" + cfg.enable_behavior_profiling = False + return cfg + + +@pytest.fixture +def config_space_data(): + return { + "vendor_id": "10ee", + "device_id": "7024", + "class_code": "020000", + "revision_id": "01", + "config_space_hex": "00" * 256, + "config_space_size": 256, + "bars": [], + } + + +def _mock_section_builders(builder): + """Mock every per-section builder to a known sentinel value.""" + builder._extract_device_identifiers = Mock( + return_value=DeviceIdentifiers( + vendor_id="10ee", + device_id="7024", + class_code="020000", + revision_id="01", + subsystem_vendor_id="10ee", + subsystem_device_id="0007", + ) + ) + builder._build_device_config = Mock( + return_value={"vendor_id": "10ee", "device_id": "7024"} + ) + builder._build_config_space_context = Mock( + return_value={"config_space": "SENTINEL"} + ) + builder._build_msix_context = Mock(return_value={"msix": "SENTINEL"}) + builder._build_bar_config = Mock(return_value={"bars": [{"type": "memory"}]}) + builder._build_timing_config = Mock( + return_value=TimingParameters( + read_latency=4, + write_latency=2, + burst_length=16, + inter_burst_gap=8, + timeout_cycles=1024, + clock_frequency_mhz=100.0, + timing_regularity=0.9, + ) + ) + builder._build_pcileech_config = Mock(return_value={"pcileech": "SENTINEL"}) + builder._build_active_device_config = Mock(return_value={"active": "SENTINEL"}) + builder._generate_unique_device_signature = Mock(return_value="32'h12345678") + builder._build_generation_metadata = Mock(return_value={"metadata": "SENTINEL"}) + builder._build_board_config = Mock( + return_value={ + "name": "test_board", + "fpga_part": "xc7a35t", + "fpga_family": "artix7", + "pcie_ip_type": "pcie_7x", + "max_lanes": 1, + "supports_msi": True, + "supports_msix": False, + "constraints": {"xdc_file": "pcileech_test.xdc"}, + "sys_clk_freq_mhz": 100, + } + ) + + +def _mock_overlay(): + """Patch the overlay mapper used during context assembly.""" + overlay = Mock() + overlay.generate_overlay_map.return_value = { + "OVERLAY_MAP": [(0, 0xFFFFFFFF)], + "OVERLAY_ENTRIES": 1, + } + return patch( + "pcileechfwgenerator.device_clone.pcileech_context.OverlayMapper", + return_value=overlay, + ) + + +# The complete top-level key set build_context() must emit. +_REQUIRED_TOP_LEVEL_KEYS = { + "device_config", + "config_space", + "msix_config", + "bar_config", + "timing_config", + "pcileech_config", + "device_signature", + "generation_metadata", + "interrupt_config", + "active_device_config", + "EXT_CFG_CAP_PTR", + "EXT_CFG_XP_CAP_PTR", + "OVERLAY_MAP", + "OVERLAY_ENTRIES", +} + + +def _build(mock_config, config_space_data): + builder = PCILeechContextBuilder( + device_bdf="0000:03:00.0", + config=mock_config, + validation_level=ValidationLevel.STRICT, + ) + _mock_section_builders(builder) + with _mock_overlay(): + return builder.build_context( + behavior_profile=None, + config_space_data=config_space_data, + msix_data=None, + interrupt_strategy="msix", + interrupt_vectors=32, + ) + + +class TestContextBuilderCharacterization: + def test_required_top_level_keys_present(self, mock_config, config_space_data): + context = _build(mock_config, config_space_data) + # Extra derived keys are allowed; a required section must never be dropped. + missing = _REQUIRED_TOP_LEVEL_KEYS - set(context) + assert not missing, f"build_context dropped sections: {missing}" + + def test_sections_carry_through_with_finalization_wrapping( + self, mock_config, config_space_data + ): + """Dict sections become TemplateObjects whose content preserves the + sub-builder values (the ensure_template_compatibility finalization step). + """ + context = _build(mock_config, config_space_data) + + def _content(v): + # TemplateObject exposes attributes; fall back to dict identity. + return ( + getattr(v, "config_space", None) + or getattr(v, "msix", None) + or getattr(v, "pcileech", None) + or getattr(v, "active", None) + or (v.get("config_space") if isinstance(v, dict) else None) + ) + + assert _content(context["config_space"]) == "SENTINEL" + assert getattr(context["msix_config"], "msix", None) == "SENTINEL" + assert getattr(context["pcileech_config"], "pcileech", None) == "SENTINEL" + assert getattr(context["active_device_config"], "active", None) == "SENTINEL" + + def test_device_signature_is_derived_from_identifiers( + self, mock_config, config_space_data + ): + """build_context derives device_signature from the device IDs, + overriding the per-section _generate_unique_device_signature mock. + """ + context = _build(mock_config, config_space_data) + assert context["device_signature"] == "10ee:7024:01" + + def test_interrupt_config_assembled_from_args(self, mock_config, config_space_data): + context = _build(mock_config, config_space_data) + ic = context["interrupt_config"] + strategy = ( + ic["strategy"] if isinstance(ic, dict) else getattr(ic, "strategy", None) + ) + vectors = ( + ic["vectors"] if isinstance(ic, dict) else getattr(ic, "vectors", None) + ) + assert strategy == "msix" + assert vectors == 32 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_pcileech_core_discovery.py b/tests/test_pcileech_core_discovery.py index d5447332..5e5e53ee 100644 --- a/tests/test_pcileech_core_discovery.py +++ b/tests/test_pcileech_core_discovery.py @@ -1,8 +1,7 @@ from pathlib import Path -import pytest -from src import pcileech_core_discovery +from pcileechfwgenerator import pcileech_core_discovery def test_discover_pcileech_files_generic(monkeypatch): diff --git a/tests/test_pcileech_vfio_rebuild.py b/tests/test_pcileech_vfio_rebuild.py index 7fcde638..fad046f6 100644 --- a/tests/test_pcileech_vfio_rebuild.py +++ b/tests/test_pcileech_vfio_rebuild.py @@ -13,13 +13,12 @@ # Add project root to path for imports project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) # Import pytest (install with: pip install -r requirements-test.txt) import pytest # type: ignore # Import the functions we want to test -from pcileech import check_vfio_requirements, rebuild_vfio_constants +from pcileech import check_vfio_requirements class TestVFIOConstantsRebuilding: diff --git a/tests/test_placeholder_donor_ids.py b/tests/test_placeholder_donor_ids.py new file mode 100644 index 00000000..94dd60ec --- /dev/null +++ b/tests/test_placeholder_donor_ids.py @@ -0,0 +1,63 @@ +"""Tests for the synthetic/placeholder donor-ID rejection helper. + +The project rejects synthetic donor data (AGENTS.md). The fail-fast guards +historically only caught missing/zero IDs; ``is_placeholder_donor_id`` closes +the gap for populated-but-fabricated *pairs* (e.g. the Intel I210 default +0x8086:0x1533) without rejecting a vendor on its own. +""" + +import pytest +from pcileechfwgenerator.device_clone.constants import ( + KNOWN_PLACEHOLDER_IDS, + is_placeholder_donor_id, +) + + +class TestIsPlaceholderDonorId: + @pytest.mark.parametrize( + "vid,did", + [ + (0x8086, 0x1234), # generic fallback + (0x8086, 0x1533), # synthetic Intel I210 + (0x8086, 0x2522), # synthetic Intel NVMe + (0x10EC, 0x8168), # tcl/j2 default RTL8168 + (0x10DE, 0x2204), # synthetic NVIDIA GPU + (0x10EE, 0x0666), # Xilinx FIFO default + (0x10EE, 0x0007), # Xilinx FIFO default + ], + ) + def test_known_placeholders_rejected(self, vid, did): + assert is_placeholder_donor_id(vid, did) is True + + @pytest.mark.parametrize( + "vid,did", + [ + (0x1AF4, 0x1041), # real virtio-net + (0x8086, 0x10D3), # real Intel 82574L (not a synthetic default) + (0x10EC, 0x8139), # real Realtek RTL8139 + (0x10DE, 0x1B80), # real NVIDIA GTX 1080 + (0x10EE, 0x7024), # real Xilinx device + ], + ) + def test_real_pairs_accepted(self, vid, did): + assert is_placeholder_donor_id(vid, did) is False + + def test_vendor_alone_is_not_a_placeholder(self): + # A real vendor paired with an unrelated device must never be rejected. + assert is_placeholder_donor_id(0x8086, 0x9999) is False + assert is_placeholder_donor_id(0x10EC, 0x1234) is False + + def test_accepts_int_and_enum_inputs(self): + # The set is built from VendorID enum members coerced to int; the + # function must work for plain ints regardless. + assert is_placeholder_donor_id(int(0x8086), int(0x1533)) is True + + def test_set_contains_only_pairs(self): + assert all( + isinstance(entry, tuple) and len(entry) == 2 + for entry in KNOWN_PLACEHOLDER_IDS + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_post_build_validator.py b/tests/test_post_build_validator.py index 132614b0..b23efb4a 100644 --- a/tests/test_post_build_validator.py +++ b/tests/test_post_build_validator.py @@ -6,15 +6,13 @@ proper validation of firmware output for driver compatibility. """ -import sys from pathlib import Path -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock, patch import struct import pytest # Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.utils.post_build_validator import ( PostBuildValidator, diff --git a/tests/test_sv_module_generator.py b/tests/test_sv_module_generator.py index 87cd868f..8a3c59a5 100644 --- a/tests/test_sv_module_generator.py +++ b/tests/test_sv_module_generator.py @@ -18,10 +18,7 @@ from pcileechfwgenerator.templating.sv_overlay_generator import SVOverlayGenerator -from pcileechfwgenerator.templating.template_renderer import ( - TemplateRenderer, - TemplateRenderError, -) +from pcileechfwgenerator.templating.template_renderer import TemplateRenderer class TestSVModuleGenerator: @@ -148,663 +145,6 @@ def test_generate_pcileech_modules_error_handling( with pytest.raises(Exception, match="Test error"): sv_generator.generate_config_space_overlay(valid_context) - # Stub out all the tests that no longer apply since we only generate overlays - def test_generate_legacy_modules_success(self, sv_generator, valid_context): - """Legacy test - stub (no longer generates SV modules).""" - pass - - def test_generate_legacy_modules_with_behavior_profile( - self, sv_generator, valid_context - ): - """Legacy test - stub.""" - pass - - def test_generate_legacy_modules_template_error( - self, sv_generator, valid_context, mock_logger - ): - """Legacy test - stub.""" - pass - - def test_generate_device_specific_ports_success(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_generate_device_specific_ports_with_cache_key(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_generate_device_specific_ports_different_cache_keys( - self, sv_generator - ): - """Legacy test - stub.""" - pass - - def test_generate_core_pcileech_modules_missing_device_ids( - self, sv_generator, valid_context - ): - """Legacy test - stub.""" - pass - - def test_generate_core_pcileech_modules_success( - self, sv_generator, valid_context - ): - """Legacy test - stub.""" - pass - - def test_is_msix_enabled_true(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_is_msix_enabled_false(self, sv_generator, valid_context): - """Legacy test - stub.""" - pass - - def test_is_msix_enabled_disabled_config(self, sv_generator, valid_context): - """Legacy test - stub.""" - pass - - def test_get_msix_vectors_from_config(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_get_msix_vectors_default(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_get_register_name_from_offset(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_get_offset_from_register_name(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_get_offset_from_register_name_invalid(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_get_default_registers(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_generate_msix_pba_init(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_generate_msix_table_init(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_extract_registers_with_profile(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_extract_registers_no_profile(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_process_register_access(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_device_identifier_validation_logging(self, sv_generator, valid_context): - """Legacy test - stub.""" - pass - - def test_generate_variance_model(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_generate_variance_model_no_profile(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_msix_modules_generation_when_enabled(self, sv_generator): - """Legacy test - stub.""" - pass - - def test_msix_modules_generation_when_disabled(self, sv_generator, valid_context): - """Legacy test - stub.""" - pass - - def test_advanced_modules_generation(self, sv_generator, valid_context): - """Legacy test - stub.""" - pass - - def test_caching_behavior(self, sv_generator): - """Legacy test - stub.""" - pass - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) - - - @pytest.fixture - def mock_renderer(self): - """Provide mock template renderer.""" - renderer = Mock(spec=TemplateRenderer) - renderer.render_template.return_value = "// Generated SystemVerilog module" - return renderer - - @pytest.fixture - def mock_logger(self): - """Provide mock logger.""" - return Mock(spec=logging.Logger) - - @pytest.fixture - def sv_generator(self, mock_renderer, mock_logger): - """Provide SVModuleGenerator instance with mocks.""" - return SVModuleGenerator( - renderer=mock_renderer, - logger=mock_logger, - prefix="TEST_SV" - ) - - @pytest.fixture - def valid_context(self): - """Provide valid test context matching current contract.""" - return { - "vendor_id": "0x10de", - "device_id": "0x1234", - "device": { - "vendor_id": "0x10de", - "device_id": "0x1234", - "class_code": "0x030000" - }, - "device_config": { - "vendor_id": "0x10de", - "device_id": "0x1234", - "enable_advanced_features": False - }, - "config_space": bytes(256), - "bar_config": {"bars": [{"size": 0x1000}]}, - "generation_metadata": {"version": "1.0"}, - "device_signature": "test_signature_12345" - } - - @pytest.fixture - def msix_context(self, valid_context): - """Provide context with MSI-X enabled.""" - msix_context = valid_context.copy() - msix_context["msix_config"] = { - "enabled": True, - "num_vectors": 4, - "table_offset": 0x1000, - "pba_offset": 0x2000 - } - return msix_context - - def validate_test_contract(self, context: Dict[str, Any]) -> None: - """Validate test context against current contract.""" - required_keys = [ - "vendor_id", - "device_id", - "config_space", - "bar_config", - "generation_metadata" - ] - missing = [key for key in required_keys if key not in context] - if missing: - log_error_safe( - logging.getLogger(__name__), - safe_format( - "Stale test or incorrect fixture; missing: {missing}", - missing=missing - ) - ) - raise AssertionError(f"Fixture/contract mismatch: {missing}") - - def test_init(self, mock_renderer, mock_logger): - """Test SVModuleGenerator initialization.""" - generator = SVModuleGenerator( - renderer=mock_renderer, - logger=mock_logger, - prefix="TEST_PREFIX" - ) - - assert generator.renderer == mock_renderer - assert generator.logger == mock_logger - assert generator.prefix == "TEST_PREFIX" - assert generator._module_cache == {} - assert generator._ports_cache == {} - - def test_init_default_prefix(self, mock_renderer, mock_logger): - """Test SVModuleGenerator initialization with default prefix.""" - generator = SVModuleGenerator( - renderer=mock_renderer, - logger=mock_logger - ) - - assert generator.prefix == "SV_GEN" - - def test_generate_pcileech_modules_success(self, sv_generator, valid_context): - """Test successful PCILeech module generation.""" - self.validate_test_contract(valid_context) - - # Mock the internal methods - with patch.object( - sv_generator, '_generate_core_pcileech_modules' - ) as mock_core, patch.object( - sv_generator, '_generate_msix_modules_if_needed' - ) as mock_msix: - - result = sv_generator.generate_pcileech_modules(valid_context) - - assert isinstance(result, dict) - mock_core.assert_called_once() - mock_msix.assert_called_once() - - def test_generate_pcileech_modules_with_behavior_profile( - self, sv_generator, valid_context - ): - """Test PCILeech with behavior profile and advanced features.""" - self.validate_test_contract(valid_context) - - # Enable advanced features - valid_context["device_config"]["enable_advanced_features"] = True - behavior_profile = Mock() - - with patch.object( - sv_generator, '_generate_core_pcileech_modules' - ) as mock_core, patch.object( - sv_generator, '_generate_msix_modules_if_needed' - ) as mock_msix, patch.object( - sv_generator, '_generate_advanced_modules' - ) as mock_advanced: - - result = sv_generator.generate_pcileech_modules( - valid_context, behavior_profile - ) - - assert isinstance(result, dict) - mock_core.assert_called_once() - mock_msix.assert_called_once() - mock_advanced.assert_called_once_with( - valid_context, behavior_profile, {} - ) - - def test_generate_pcileech_modules_error_handling( - self, sv_generator, valid_context - ): - """Test error handling in PCILeech module generation.""" - self.validate_test_contract(valid_context) - - with patch.object(sv_generator, '_generate_core_pcileech_modules', - side_effect=Exception("Test error")): - - with pytest.raises(Exception, match="Test error"): - sv_generator.generate_pcileech_modules(valid_context) - - def test_generate_legacy_modules_success(self, sv_generator, valid_context): - """Test successful legacy module generation.""" - self.validate_test_contract(valid_context) - - # Mock the templates - with patch.object(sv_generator.templates, 'BASIC_SV_MODULES', - ["module1.sv.j2", "module2.sv.j2"]): - - result = sv_generator.generate_legacy_modules(valid_context) - - assert isinstance(result, dict) - assert "module1" in result - assert "module2" in result - assert sv_generator.renderer.render_template.call_count == 2 - - def test_generate_legacy_modules_with_behavior_profile( - self, sv_generator, valid_context - ): - """Test legacy module generation with behavior profile.""" - self.validate_test_contract(valid_context) - - behavior_profile = Mock() - - with patch.object( - sv_generator.templates, 'BASIC_SV_MODULES', [] - ), patch.object( - sv_generator, '_extract_registers', return_value=[] - ), patch.object( - sv_generator, - '_generate_advanced_controller', - return_value="// Advanced controller", - ) as mock_advanced: - - result = sv_generator.generate_legacy_modules( - valid_context, behavior_profile - ) - - assert "advanced_controller" in result - assert result["advanced_controller"] == "// Advanced controller" - mock_advanced.assert_called_once() - - def test_generate_legacy_modules_template_error( - self, sv_generator, valid_context, mock_logger - ): - """Test handling of template errors in legacy module generation.""" - self.validate_test_contract(valid_context) - - # Mock renderer to raise an error for one template - sv_generator.renderer.render_template.side_effect = [ - "// Good module", - TemplateRenderError("Template error") - ] - - with patch.object(sv_generator.templates, 'BASIC_SV_MODULES', - ["good_module.sv.j2", "bad_module.sv.j2"]): - - result = sv_generator.generate_legacy_modules(valid_context) - - # Should have one successful module - assert "good_module" in result - assert "bad_module" not in result - - # Should have logged the error - assert mock_logger.method_calls - - def test_generate_device_specific_ports_success(self, sv_generator): - """Test successful device-specific port generation.""" - device_type = "network" - device_class = "ethernet" - - result = sv_generator.generate_device_specific_ports( - device_type, device_class - ) - - assert result == "// Generated SystemVerilog module" - sv_generator.renderer.render_template.assert_called_once() - - # Test caching - second call should not render again - result2 = sv_generator.generate_device_specific_ports( - device_type, device_class - ) - assert result2 == result - assert sv_generator.renderer.render_template.call_count == 1 - - def test_generate_device_specific_ports_with_cache_key(self, sv_generator): - """Test device-specific port generation with cache key.""" - device_type = "storage" - device_class = "nvme" - cache_key = "test_key" - - # First call - result1 = sv_generator.generate_device_specific_ports( - device_type, device_class, cache_key - ) - - # Second call with same cache key should return cached result - result2 = sv_generator.generate_device_specific_ports( - device_type, device_class, cache_key - ) - - assert result1 == result2 - assert sv_generator.renderer.render_template.call_count == 1 - - def test_generate_device_specific_ports_different_cache_keys(self, sv_generator): - """Test device-specific port generation with different cache keys.""" - device_type = "storage" - device_class = "nvme" - - # Calls with different cache keys should render separately - result1 = sv_generator.generate_device_specific_ports( - device_type, device_class, "key1" - ) - result2 = sv_generator.generate_device_specific_ports( - device_type, device_class, "key2" - ) - - assert sv_generator.renderer.render_template.call_count == 2 - - def test_generate_core_pcileech_modules_missing_device_ids(self, sv_generator, valid_context): - """Test _generate_core_pcileech_modules with missing device identifiers.""" - self.validate_test_contract(valid_context) - - # Remove vendor_id from context - invalid_context = valid_context.copy() - invalid_context.pop("vendor_id") - invalid_context["device"] = {} - invalid_context["device_config"] = {} - - modules = {} - - with pytest.raises(TemplateRenderError): - sv_generator._generate_core_pcileech_modules(invalid_context, modules) - - def test_generate_core_pcileech_modules_success( - self, sv_generator, valid_context - ): - """Test successful _generate_core_pcileech_modules execution.""" - self.validate_test_contract(valid_context) - - modules = {} - - # Call the actual method - it renders specific templates based on the implementation - sv_generator._generate_core_pcileech_modules(valid_context, modules) - - # Check that the expected core modules are generated - expected_modules = [ - "pcileech_tlps128_bar_controller", - "pcileech_fifo", - "device_config", - "top_level_wrapper", - "pcileech_cfgspace.coe" - ] - - for module_name in expected_modules: - assert module_name in modules, f"Expected module {module_name} not found in {list(modules.keys())}" - - def test_is_msix_enabled_true(self, sv_generator, msix_context): - """Test MSI-X detection when enabled.""" - msix_config = msix_context.get("msix_config", {}) - # Add the flag the implementation expects for pytest - msix_config["is_supported"] = True - result = sv_generator._is_msix_enabled(msix_config, msix_context) - assert result is True - - def test_is_msix_enabled_false(self, sv_generator, valid_context): - """Test MSI-X detection when disabled.""" - result = sv_generator._is_msix_enabled(valid_context, None) - assert result is False - - def test_is_msix_enabled_disabled_config(self, sv_generator, valid_context): - """Test MSI-X detection with disabled config.""" - msix_config = {"enabled": False} - result = sv_generator._is_msix_enabled(valid_context, msix_config) - assert result is False - - def test_get_msix_vectors_from_config(self, sv_generator): - """Test MSI-X vector count extraction from config.""" - msix_config = {"num_vectors": 8} - result = sv_generator._get_msix_vectors(msix_config) - assert result == 8 - - def test_get_msix_vectors_default(self, sv_generator): - """Test MSI-X vector count default value.""" - msix_config = {} - result = sv_generator._get_msix_vectors(msix_config) - assert result == 1 # Default value - - def test_get_register_name_from_offset(self, sv_generator): - """Test register name generation from offset.""" - # The actual implementation uses a mapping, check what BAR0 maps to - with patch.object(sv_generator, '_get_register_name_from_offset', return_value="reg_0x10"): - result = sv_generator._get_register_name_from_offset(0x10) - assert result == "reg_0x10" - - def test_get_offset_from_register_name(self, sv_generator): - """Test offset extraction from register name.""" - # Mock the actual implementation since it uses a lookup table - with patch.object(sv_generator, '_get_offset_from_register_name', return_value=0x20): - result = sv_generator._get_offset_from_register_name("reg_0x20") - assert result == 0x20 - - def test_get_offset_from_register_name_invalid(self, sv_generator): - """Test offset extraction from invalid register name.""" - result = sv_generator._get_offset_from_register_name("invalid_name") - assert result is None - - def test_get_default_registers(self, sv_generator): - """Test default register generation.""" - result = sv_generator._get_default_registers() - assert isinstance(result, list) - # Should have at least one default register - assert len(result) >= 1 - - def test_generate_msix_pba_init(self, sv_generator): - """Test MSI-X PBA initialization generation.""" - result = sv_generator._generate_msix_pba_init(4) - assert isinstance(result, str) - assert len(result) > 0 - - def test_generate_msix_table_init(self, sv_generator): - """Test MSI-X table initialization generation.""" - num_vectors = 2 - context = {} - result = sv_generator._generate_msix_table_init(num_vectors, context) - assert isinstance(result, str) - assert len(result) > 0 - - def test_extract_registers_with_profile(self, sv_generator): - """Test register extraction from behavior profile.""" - behavior_profile = Mock() - behavior_profile.register_accesses = [ - Mock(offset=0x10, access_type="read"), - Mock(offset=0x20, access_type="write") - ] - - with patch.object(sv_generator, '_process_register_access', - return_value={"offset": 0x10, "name": "reg_0x10"}): - - result = sv_generator._extract_registers(behavior_profile) - assert isinstance(result, list) - - def test_extract_registers_no_profile(self, sv_generator): - """Test register extraction without behavior profile.""" - result = sv_generator._extract_registers(None) - assert isinstance(result, list) - # Should return default registers - assert len(result) >= 1 - - def test_process_register_access(self, sv_generator): - """Test register access processing.""" - access = Mock() - access.offset = 0x100 - access.access_type = "read" - access.data_size = 4 - - # Mock to return a proper dict since the real implementation might return None for some cases - with patch.object(sv_generator, '_process_register_access', - return_value={ - "offset": 0x100, - "access_type": "read", - "data_size": 4, - "name": "test_reg" - }): - result = sv_generator._process_register_access(access, {}) - - assert result["offset"] == 0x100 - assert result["access_type"] == "read" - assert result["data_size"] == 4 - assert "name" in result - - @patch('pcileechfwgenerator.templating.sv_module_generator.log_error_safe') - def test_device_identifier_validation_logging(self, mock_log, sv_generator, valid_context): - """Test that device identifier validation logs correctly.""" - self.validate_test_contract(valid_context) - - # Create context with missing device identifiers - invalid_context = valid_context.copy() - invalid_context.pop("vendor_id") - invalid_context["device"] = {} - invalid_context["device_config"] = {} - - modules = {} - - with pytest.raises(TemplateRenderError): - sv_generator._generate_core_pcileech_modules(invalid_context, modules) - - # Verify that log_error_safe was called with safe_format - mock_log.assert_called_once() - call_args = mock_log.call_args - - # The first argument should be the logger - assert call_args[0][0] == sv_generator.logger - - # The second argument should be the formatted message - assert "Missing required device identifiers" in call_args[0][1] - - # Verify prefix was passed - assert call_args[1]["prefix"] == sv_generator.prefix - - def test_generate_variance_model(self, sv_generator): - """Test variance model generation.""" - behavior_profile = Mock() - behavior_profile.variance_metadata = Mock() - - result = sv_generator._get_variance_model(behavior_profile) - assert result == behavior_profile.variance_metadata - - def test_generate_variance_model_no_profile(self, sv_generator): - """Test variance model generation without profile.""" - result = sv_generator._get_variance_model(None) - assert result is None - - def test_msix_modules_generation_when_enabled(self, sv_generator, msix_context): - """Test MSI-X module generation when enabled.""" - modules = {} - - with patch.object(sv_generator, '_is_msix_enabled', return_value=True), \ - patch.object(sv_generator, '_get_msix_vectors', return_value=4), \ - patch.object(sv_generator, '_generate_msix_pba_init', return_value="// PBA init"), \ - patch.object(sv_generator, '_generate_msix_table_init', return_value="// Table init"): - - sv_generator._generate_msix_modules_if_needed(msix_context, modules) - - # Should have generated MSI-X related modules - assert sv_generator.renderer.render_template.call_count > 0 - - def test_msix_modules_generation_when_disabled(self, sv_generator, valid_context): - """Test MSI-X module generation when disabled.""" - modules = {} - - with patch.object(sv_generator, '_is_msix_enabled', return_value=False): - - sv_generator._generate_msix_modules_if_needed(valid_context, modules) - - # Should not have rendered any templates - sv_generator.renderer.render_template.assert_not_called() - - def test_advanced_modules_generation(self, sv_generator, valid_context): - """Test advanced module generation.""" - behavior_profile = Mock() - modules = {} - - with patch.object(sv_generator, '_extract_registers', return_value=[]), \ - patch.object(sv_generator, '_generate_advanced_controller', - return_value="// Advanced controller"): - - sv_generator._generate_advanced_modules(valid_context, behavior_profile, modules) - - # The actual implementation might use a different key name - assert "pcileech_advanced_controller" in modules or "advanced_controller" in modules - - def test_caching_behavior(self, sv_generator): - """Test that caching works correctly for ports.""" - device_type = "test_type" - device_class = "test_class" - - # First call should render - result1 = sv_generator.generate_device_specific_ports(device_type, device_class) - - # Second call should use cache - result2 = sv_generator.generate_device_specific_ports(device_type, device_class) - - assert result1 == result2 - assert sv_generator.renderer.render_template.call_count == 1 - - # Verify cache key is in cache - cache_key = (device_type, device_class, "") - assert cache_key in sv_generator._ports_cache - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_sv_validator.py b/tests/test_sv_validator.py index 997214b4..9a0a64d1 100644 --- a/tests/test_sv_validator.py +++ b/tests/test_sv_validator.py @@ -7,16 +7,12 @@ """ import logging -import sys -from pathlib import Path from typing import Any, Dict import pytest # Add src to path -sys.path.insert(0, str(Path(__file__).parent.parent)) -from pcileechfwgenerator.string_utils import safe_format from pcileechfwgenerator.templating.sv_validator import SVValidator from pcileechfwgenerator.templating.template_renderer import TemplateRenderError diff --git a/tests/test_tcl_builder_safety.py b/tests/test_tcl_builder_safety.py index e4eb5b4b..4bff4d45 100644 --- a/tests/test_tcl_builder_safety.py +++ b/tests/test_tcl_builder_safety.py @@ -8,17 +8,15 @@ import sys import unittest -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) from pcileechfwgenerator.templating.tcl_builder import ( BuildContext, TCLBuilder, - PCIE_SPEED_CODES, ) +# Add parent directory to path for imports + + # Note: SystemVerilog generator tests are optional - the class name may vary @@ -165,21 +163,26 @@ class TestImportResilience(unittest.TestCase): def test_tcl_builder_imports(self): """Test that TCL builder can be imported.""" try: - from pcileechfwgenerator.templating.tcl_builder import TCLBuilder + from pcileechfwgenerator.templating.tcl_builder import ( + BuildContext, + TCLBuilder, + ) - self.assertTrue(True) + self.assertIsNotNone(TCLBuilder) + self.assertIsNotNone(BuildContext) except ImportError as e: self.fail(f"Failed to import TCLBuilder: {e}") def test_systemverilog_generator_imports(self): """Test that SystemVerilog generator module can be imported.""" try: - # Just test that the module itself can be imported - import pcileechfwgenerator.templating.systemverilog_generator + from pcileechfwgenerator.templating.sv_overlay_generator import ( + SVOverlayGenerator, + ) - self.assertTrue(True) + self.assertIsNotNone(SVOverlayGenerator) except ImportError as e: - # This is not critical - the module might not be available in all environments + # Not critical - the module might not be available in all environments print(f"Note: SystemVerilog generator module not available: {e}") self.skipTest("SystemVerilog generator module not available") diff --git a/tests/test_template_critical_paths.py b/tests/test_template_critical_paths.py index 6abd2547..4c0ab566 100644 --- a/tests/test_template_critical_paths.py +++ b/tests/test_template_critical_paths.py @@ -13,18 +13,14 @@ """ import logging -import sys -from pathlib import Path import pytest # Add src to path -sys.path.insert(0, str(Path(__file__).parent.parent)) -from pcileechfwgenerator.string_utils import log_error_safe, log_info_safe, safe_format +from pcileechfwgenerator.string_utils import log_error_safe, safe_format from pcileechfwgenerator.templating.template_renderer import ( - MappingFileSystemLoader, TemplateRenderer, TemplateRenderError, ) diff --git a/tests/test_template_renderer.py b/tests/test_template_renderer.py index 08746a40..b4840dd4 100644 --- a/tests/test_template_renderer.py +++ b/tests/test_template_renderer.py @@ -1,9 +1,10 @@ from pathlib import Path import pytest - -from pcileechfwgenerator.templating.template_renderer import (TemplateRenderer, - TemplateRenderError) +from pcileechfwgenerator.templating.template_renderer import ( + TemplateRenderer, + TemplateRenderError, +) def test_render_string_basic(): @@ -73,8 +74,10 @@ def test_render_many(tmp_path): def test_context_validation_missing_required_key(): - from pcileechfwgenerator.utils.unified_context import (UnifiedContextBuilder, - ensure_template_compatibility) + from pcileechfwgenerator.utils.unified_context import ( + UnifiedContextBuilder, + ensure_template_compatibility, + ) builder = UnifiedContextBuilder() # Minimal context missing required keys @@ -123,8 +126,10 @@ def build_context_or_die(device_bdf, cfg, **deps): def test_template_renderer_critical_path_failure(): - from pcileechfwgenerator.templating.template_renderer import (TemplateRenderer, - TemplateRenderError) + from pcileechfwgenerator.templating.template_renderer import ( + TemplateRenderer, + TemplateRenderError, + ) renderer = TemplateRenderer(strict=True) # Template expects 'name', but context is missing it @@ -218,8 +223,10 @@ def test_template_renderer_integration_complex(tmp_path): def test_template_renderer_fallback_logic(tmp_path): - from pcileechfwgenerator.templating.template_renderer import (TemplateRenderer, - TemplateRenderError) + from pcileechfwgenerator.templating.template_renderer import ( + TemplateRenderer, + TemplateRenderError, + ) renderer = TemplateRenderer(template_dir=tmp_path) # Template with error tag triggers fallback @@ -227,3 +234,34 @@ def test_template_renderer_fallback_logic(tmp_path): template_file.write_text("{% error 'fail' %}") with pytest.raises(TemplateRenderError): renderer.render_template("fail.j2", {}) + + +def test_bitwise_filters_parse_pci_ids_as_hex(): + """bitor/bitxor/bitand must treat bare string operands as hex PCI IDs. + + PCI IDs are always hexadecimal (issue #620). A bare "1234" is 0x1234, not + decimal, and "10de" (no 0x prefix) must not raise. + """ + renderer = TemplateRenderer() + bitor = renderer.env.filters["bitor"] + bitxor = renderer.env.filters["bitxor"] + bitand = renderer.env.filters["bitand"] + + assert bitor("10de", 0) == 0x10DE + assert bitor("0x10de", 0) == 0x10DE + assert bitor("1234", 0) == 0x1234 # hex, not decimal 1234 + assert bitor(0x8086, 0) == 0x8086 + assert bitor("", 0x1234) == 0x1234 + assert bitor(None, 0x1234) == 0x1234 + assert bitxor("10de", "0001") == 0x10DF + assert bitand("ffff", "10de") == 0x10DE + + +def test_bitwise_filters_in_template(): + """The bit filters work end-to-end with string PCI IDs in a template.""" + renderer = TemplateRenderer() + out = renderer.render_string( + "{{ '%04X' % (vendor_id | bitor(device_id)) }}", + {"vendor_id": "10de", "device_id": "0001"}, + ) + assert out.strip() == "10DF" diff --git a/tests/test_top_level_wrapper_completion.py b/tests/test_top_level_wrapper_completion.py deleted file mode 100644 index ad4e8cff..00000000 --- a/tests/test_top_level_wrapper_completion.py +++ /dev/null @@ -1,444 +0,0 @@ -#!/usr/bin/env python3 -""" -Test suite for completion TLP generation in the SystemVerilog code. -Tests the generation of completion TLPs with proper headers and data. - -This test verifies: -- Completion header generation function -- Proper completion header field population -- 3-DW completion header format -- Completion with data (CplD) generation -- Completion status codes -- Byte count calculation -- Completer ID usage -- Requester ID/Tag from stored transaction -- Lower address handling -- AXI-Stream interface for completion transmission -- Multi-beat completion for 64-bit interface -""" - -import pytest -import re -from typing import Dict, List, Tuple, Optional - - -class TestTopLevelWrapperCompletion: - """Test suite for PCIe completion TLP generation.""" - - @pytest.fixture - def completion_header_fields(self): - """Provide completion header field definitions.""" - return { - "format": {"bits": "[31:29]", "value": "3'b010"}, # 3DW with data - "type": {"bits": "[28:24]", "value": "5'b01010"}, # Completion with Data - "tc": {"bits": "[22:20]", "value": "3'b000"}, - "attr": {"bits": "[13:12]", "value": "2'b00"}, - "length": {"bits": "[9:0]", "desc": "Length in DWORDs"}, - "completer_id": {"bits": "[63:48]", "desc": "Bus/Dev/Func of completer"}, - "status": {"bits": "[47:45]", "desc": "Completion status"}, - "byte_count": {"bits": "[43:32]", "desc": "Remaining bytes"}, - "requester_id": {"bits": "[95:80]", "desc": "Original requester"}, - "tag": {"bits": "[79:72]", "desc": "Original tag"}, - "lower_addr": {"bits": "[70:64]", "desc": "Lower 7 bits of address"}, - } - - @pytest.fixture - def completion_status_codes(self): - """Provide completion status code definitions.""" - return { - "SC": "3'b000", # Successful Completion - "UR": "3'b001", # Unsupported Request - "CRS": "3'b010", # Configuration Request Retry Status - "CA": "3'b100", # Completer Abort - } - - def test_completion_header_function_signature(self): - """Test completion header generation function signature.""" - sv_code = """ - // TLP Completion Header Generation Function - function logic [95:0] generate_cpld_header; - input logic [15:0] requester_id; - input logic [7:0] tag; - input logic [6:0] lower_addr; - input logic [9:0] length; - input logic [15:0] completer_id; - input logic [2:0] status; - input logic [11:0] byte_count; - logic [95:0] header; - begin - // Header generation logic - generate_cpld_header = header; - end - endfunction - """ - - # Verify function declaration - assert "function logic [95:0] generate_cpld_header;" in sv_code - - # Verify all input parameters - assert "input logic [15:0] requester_id;" in sv_code - assert "input logic [7:0] tag;" in sv_code - assert "input logic [6:0] lower_addr;" in sv_code - assert "input logic [9:0] length;" in sv_code - assert "input logic [15:0] completer_id;" in sv_code - assert "input logic [2:0] status;" in sv_code - assert "input logic [11:0] byte_count;" in sv_code - - def test_completion_header_dw0_fields(self, completion_header_fields): - """Test DW0 field assignments in completion header.""" - sv_code = """ - // DW0: Format, Type, and other fields - header[31:29] = 3'b010; // Format: 3DW with data - header[28:24] = 5'b01010; // Type: Completion with Data - header[23] = 1'b0; // Reserved - header[22:20] = 3'b000; // TC (Traffic Class) - header[19:16] = 4'b0000; // Reserved - header[15] = 1'b0; // TD (TLP Digest) - header[14] = 1'b0; // EP (Poisoned) - header[13:12] = 2'b00; // Attr - header[11:10] = 2'b00; // AT - header[9:0] = length; // Length in DW - """ - - # Verify format field - assert "header[31:29] = 3'b010" in sv_code - assert "// Format: 3DW with data" in sv_code - - # Verify type field - assert "header[28:24] = 5'b01010" in sv_code - assert "// Type: Completion with Data" in sv_code - - # Verify length field - assert "header[9:0] = length" in sv_code - - def test_completion_header_dw1_fields(self): - """Test DW1 field assignments in completion header.""" - sv_code = """ - // DW1: Completer ID and status - header[63:48] = completer_id; // Completer ID - header[47:45] = status; // Completion Status - header[44] = 1'b0; // BCM - header[43:32] = byte_count; // Byte Count - """ - - # Verify completer ID - assert "header[63:48] = completer_id" in sv_code - - # Verify status field - assert "header[47:45] = status" in sv_code - - # Verify byte count - assert "header[43:32] = byte_count" in sv_code - - def test_completion_header_dw2_fields(self): - """Test DW2 field assignments in completion header.""" - sv_code = """ - // DW2: Requester ID, Tag, and Lower Address - header[95:80] = requester_id; // Requester ID - header[79:72] = tag; // Tag - header[71] = 1'b0; // Reserved - header[70:64] = lower_addr; // Lower Address[6:0] - """ - - # Verify requester ID - assert "header[95:80] = requester_id" in sv_code - - # Verify tag field - assert "header[79:72] = tag" in sv_code - - # Verify lower address - assert "header[70:64] = lower_addr" in sv_code - - def test_completion_generation_from_fifo(self): - """Test completion generation using transaction FIFO data.""" - sv_code = """ - TLP_COMPLETION: begin - // Generate proper completion TLP - logic [95:0] cpld_header; - transaction_info_t trans_info; - logic [9:0] completion_length; - logic [11:0] byte_count; - - trans_info = transaction_fifo[transaction_rd_ptr]; - - // Calculate completion length and byte count based on request - completion_length = trans_info.length; - byte_count = {2'b00, trans_info.length} << 2; // Convert DWORDs to bytes - - cpld_header = generate_cpld_header( - trans_info.requester_id, - trans_info.tag, - trans_info.lower_addr, - completion_length, - 16'h10ee, // Completer ID (vendor ID as example) - 3'b000, // Successful completion - byte_count - ); - end - """ - - # Verify transaction info retrieval - assert "trans_info = transaction_fifo[transaction_rd_ptr]" in sv_code - - # Verify byte count calculation - assert "byte_count = {2'b00, trans_info.length} << 2" in sv_code - - # Verify function call with proper parameters - assert "cpld_header = generate_cpld_header(" in sv_code - assert "trans_info.requester_id" in sv_code - assert "trans_info.tag" in sv_code - - def test_completion_data_transmission_64bit(self): - """Test completion transmission on 64-bit AXI-Stream interface.""" - sv_code = """ - // For 64-bit interface - send 3DW header + 1DW data in 2 cycles - if (tlp_current_beat == 11'h0) begin - // First beat: DW0 and DW1 - pcie_tx_data <= {cpld_header[63:32], cpld_header[31:0]}; - pcie_tx_valid <= 1'b1; - s_axis_tx_tkeep <= 8'b1111_1111; // All 8 bytes valid - s_axis_tx_tlast <= (completion_length == 10'd0); // Last if no data - tlp_current_beat <= tlp_current_beat + 1; - end else if (tlp_current_beat == 11'h1) begin - // Second beat: DW2 + first data DWORD - pcie_tx_data <= {bar_rd_data_captured, cpld_header[95:64]}; - pcie_tx_valid <= 1'b1; - - if (completion_length == 10'd1) begin - s_axis_tx_tkeep <= 8'b1111_1111; // Full 8 bytes - s_axis_tx_tlast <= 1'b1; // This is the last beat - tlp_state <= TLP_IDLE; - end else begin - s_axis_tx_tkeep <= 8'b1111_1111; - s_axis_tx_tlast <= 1'b0; // More data to follow - tlp_current_beat <= tlp_current_beat + 1; - end - end - """ - - # Verify first beat (DW0 + DW1) - assert "pcie_tx_data <= {cpld_header[63:32], cpld_header[31:0]}" in sv_code - - # Verify second beat (DW2 + data) - assert "pcie_tx_data <= {bar_rd_data_captured, cpld_header[95:64]}" in sv_code - - # Verify tkeep handling - assert "s_axis_tx_tkeep <= 8'b1111_1111" in sv_code - - # Verify tlast generation - assert "s_axis_tx_tlast <= 1'b1" in sv_code - - def test_completion_data_transmission_32bit(self): - """Test completion transmission on 32-bit AXI-Stream interface.""" - sv_code = """ - // For 32-bit interface - send header DWs then data sequentially - if (tlp_current_beat < 3) begin - // Send header DWs - case (tlp_current_beat) - 11'h0: pcie_tx_data <= cpld_header[31:0]; - 11'h1: pcie_tx_data <= cpld_header[63:32]; - 11'h2: pcie_tx_data <= cpld_header[95:64]; - endcase - pcie_tx_valid <= 1'b1; - s_axis_tx_tkeep <= 4'b1111; // All 4 bytes valid - s_axis_tx_tlast <= 1'b0; // Not last - tlp_current_beat <= tlp_current_beat + 1; - end else if (tlp_current_beat == 11'h3) begin - // Send first data DWORD - pcie_tx_data <= bar_rd_data_captured; - pcie_tx_valid <= 1'b1; - s_axis_tx_tkeep <= 4'b1111; - s_axis_tx_tlast <= (completion_length == 10'd1); - end - """ - - # Verify sequential header transmission - assert "11'h0: pcie_tx_data <= cpld_header[31:0]" in sv_code - assert "11'h1: pcie_tx_data <= cpld_header[63:32]" in sv_code - assert "11'h2: pcie_tx_data <= cpld_header[95:64]" in sv_code - - # Verify data transmission - assert "pcie_tx_data <= bar_rd_data_captured" in sv_code - - def test_byte_count_calculation(self): - """Test byte count calculation for various lengths.""" - sv_code = """ - // Convert DWORDs to bytes - logic [11:0] byte_count; - byte_count = {2'b00, trans_info.length} << 2; - - // Examples: - // length = 10'h001 (1 DWORD) -> byte_count = 12'h004 (4 bytes) - // length = 10'h004 (4 DWORDs) -> byte_count = 12'h010 (16 bytes) - // length = 10'h100 (256 DWORDs) -> byte_count = 12'h400 (1024 bytes) - """ - - # Verify byte count calculation - assert "byte_count = {2'b00, trans_info.length} << 2" in sv_code - - def test_completion_state_transitions(self): - """Test state machine transitions for completion generation.""" - sv_code = """ - TLP_PROCESSING: begin - case (tlp_type) - TLP_MEM_RD_32, TLP_MEM_RD_64: begin - // Send completion for memory read - if (!transaction_fifo_empty) begin - retrieve_transaction <= 1'b1; - tlp_state <= TLP_COMPLETION; - end else begin - tlp_state <= TLP_IDLE; - end - end - endcase - end - - TLP_COMPLETION: begin - // Generate and send completion - // State transitions handled in transmission logic - end - """ - - # Verify state transition to completion - assert "tlp_state <= TLP_COMPLETION" in sv_code - - # Verify FIFO check before completion - assert "if (!transaction_fifo_empty)" in sv_code - - def test_completer_id_generation(self): - """Test completer ID generation from device configuration.""" - sv_code = """ - // Completer ID should be device's Bus/Dev/Func - logic [15:0] completer_id; - completer_id = 16'h10ee; // Example: vendor ID - - // In actual implementation, might use: - // completer_id = {cfg_bus_number, cfg_device_number, cfg_function_number}; - """ - - # Basic check for completer ID usage - assert True # Placeholder - actual implementation varies - - def test_completion_without_data(self): - """Test completion without data (Cpl) generation.""" - sv_code = """ - // For completions without data (e.g., writes) - // Format would be 3'b000 (3DW, no data) - // Type would be 5'b00010 (Completion without Data) - - header[31:29] = 3'b000; // Format: 3DW no data - header[28:24] = 5'b00010; // Type: Completion without Data - """ - - # This would be for write completions - assert True # Placeholder - - def test_completion_error_status(self, completion_status_codes): - """Test completion with error status generation.""" - sv_code = """ - // Handle error completions - logic [2:0] completion_status; - - if (bar_access_error) begin - completion_status = 3'b001; // Unsupported Request - end else if (timeout_error) begin - completion_status = 3'b100; // Completer Abort - end else begin - completion_status = 3'b000; // Successful Completion - end - - cpld_header = generate_cpld_header( - trans_info.requester_id, - trans_info.tag, - trans_info.lower_addr, - completion_length, - completer_id, - completion_status, // Error status - byte_count - ); - """ - - # Verify error status handling - assert True # Placeholder - depends on error handling implementation - - def test_multi_dword_completion(self): - """Test completion with multiple DWORDs of data.""" - sv_code = """ - // For multi-DWORD completions - logic [10:0] remaining_dwords; - remaining_dwords = completion_length - 1; // Already sent first DWORD - - // Continue sending data DWORDs - if (remaining_dwords > 0) begin - // Read next data from BAR memory - // Send in subsequent beats - end - """ - - # This tests extended completion handling - assert True # Placeholder - - def test_completion_tlast_generation(self): - """Test proper tlast signal generation for completions.""" - sv_code = """ - // tlast should be asserted on the last beat of the TLP - - // For single DWORD completion on 64-bit interface: - // Beat 0: DW0 + DW1 (tlast = 0) - // Beat 1: DW2 + Data (tlast = 1) - - // For multi-DWORD completion: - // Calculate total beats needed - logic [10:0] total_beats; - total_beats = 2 + ((completion_length - 1) >> 1); // Header + data beats - - s_axis_tx_tlast <= (tlp_current_beat == total_beats - 1); - """ - - # Verify tlast calculation logic - assert True # Placeholder - - def test_lower_address_preservation(self): - """Test that lower address bits are preserved in completion.""" - sv_code = """ - // Lower 7 bits of address must be preserved - // This is important for byte-level addressing - - current_transaction.lower_addr <= tlp_address[6:0]; - - // In completion: - cpld_header = generate_cpld_header( - trans_info.requester_id, - trans_info.tag, - trans_info.lower_addr, // Preserved from request - ... - ); - """ - - # Verify lower address handling - assert True # Placeholder - - def test_completion_data_alignment(self): - """Test data alignment in completion based on lower address.""" - sv_code = """ - // Data alignment based on lower address - // For non-aligned reads, first DWORD might be partial - - logic [1:0] start_offset; - start_offset = trans_info.lower_addr[1:0]; - - // Adjust data based on offset - case (start_offset) - 2'b00: completion_data = bar_rd_data_captured; - 2'b01: completion_data = {8'h00, bar_rd_data_captured[31:8]}; - 2'b10: completion_data = {16'h0000, bar_rd_data_captured[31:16]}; - 2'b11: completion_data = {24'h000000, bar_rd_data_captured[31:24]}; - endcase - """ - - # Advanced feature for byte-aligned completions - assert True # Placeholder - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_top_level_wrapper_fifo.py b/tests/test_top_level_wrapper_fifo.py deleted file mode 100644 index 6975683f..00000000 --- a/tests/test_top_level_wrapper_fifo.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env python3 -""" -Test suite for transaction tracking FIFO operations in the SystemVerilog code. -Tests the FIFO implementation for tracking outstanding PCIe transactions. - -This test verifies: -- FIFO structure definition with transaction_info_t -- FIFO pointer management (read/write) -- Transaction count tracking -- Full and empty flag generation -- Store transaction operation -- Retrieve transaction operation -- FIFO overflow protection -- FIFO underflow protection -- Proper initialization on reset -""" - -import pytest -import re -from typing import Dict, List, Optional - - -class TestTopLevelWrapperFIFO: - """Test suite for transaction tracking FIFO functionality.""" - - @pytest.fixture - def transaction_info_fields(self): - """Provide expected transaction info structure fields.""" - return { - "requester_id": "logic [15:0]", - "tag": "logic [7:0]", - "lower_addr": "logic [6:0]", - "length": "logic [9:0]", - } - - @pytest.fixture - def fifo_parameters(self): - """Provide FIFO configuration parameters.""" - return { - "depth": 32, - "ptr_width": 5, # log2(32) = 5 - "count_width": 6, # Need 6 bits to represent 0-32 - "array_range": "[0:31]", - } - - def test_transaction_structure_definition(self, transaction_info_fields): - """Test transaction info structure definition.""" - sv_code = """ - // Transaction tracking structure and FIFO - typedef struct packed { - logic [15:0] requester_id; - logic [7:0] tag; - logic [6:0] lower_addr; - logic [9:0] length; - } transaction_info_t; - """ - - # Verify structure definition - assert "typedef struct packed" in sv_code - assert "transaction_info_t" in sv_code - - # Verify all fields (tolerate multiple spaces) - for field, type_def in transaction_info_fields.items(): - # Build a regex that matches arbitrary whitespace between tokens - # while treating bracketed ranges like [15:0] as literals. - type_def_clean = type_def.strip() - # First escape all regex metacharacters (including [ and ]) - type_regex = re.escape(type_def_clean) - # Then allow flexible whitespace where spaces appear in the type - type_regex = re.sub(r"(?:\\ )+", r"\\s+", type_regex) - pattern = rf"{type_regex}\s+{field};" - assert ( - re.search(pattern, sv_code) is not None - ), f"Missing field: {type_def} {field};" - - def test_fifo_array_declaration(self, fifo_parameters): - """Test FIFO array and control signal declarations.""" - sv_code = f""" - // Transaction tracking FIFO - transaction_info_t transaction_fifo {fifo_parameters['array_range']}; - logic [{fifo_parameters['ptr_width']-1}:0] transaction_wr_ptr; - logic [{fifo_parameters['ptr_width']-1}:0] transaction_rd_ptr; - logic [{fifo_parameters['count_width']-1}:0] transaction_count; - logic transaction_fifo_full; - logic transaction_fifo_empty; - """ - - # Verify FIFO array - assert f"transaction_fifo {fifo_parameters['array_range']}" in sv_code - - # Verify pointer widths - assert f"logic [4:0] transaction_wr_ptr" in sv_code - assert f"logic [4:0] transaction_rd_ptr" in sv_code - - # Verify count width (6 bits for 0-32) - assert f"logic [5:0] transaction_count" in sv_code - - # Verify status flags - assert "logic transaction_fifo_full" in sv_code - assert "logic transaction_fifo_empty" in sv_code - - def test_fifo_status_flag_generation(self, fifo_parameters): - """Test FIFO full and empty flag generation.""" - sv_code = f""" - assign transaction_fifo_full = (transaction_count == 6'd{fifo_parameters['depth']}); - assign transaction_fifo_empty = (transaction_count == 6'd0); - """ - - # Verify full flag - assert ( - f"transaction_fifo_full = (transaction_count == 6'd{fifo_parameters['depth']})" - in sv_code - ) - - # Verify empty flag - assert "transaction_fifo_empty = (transaction_count == 6'd0)" in sv_code - - def test_fifo_reset_initialization(self): - """Test FIFO initialization during reset.""" - sv_code = """ - // Transaction FIFO management - always_ff @(posedge clk) begin - if (reset) begin - transaction_wr_ptr <= 5'h0; - transaction_rd_ptr <= 5'h0; - transaction_count <= 6'h0; - end else begin - // FIFO operations - end - end - """ - - # Verify reset values - assert "transaction_wr_ptr <= 5'h0" in sv_code - assert "transaction_rd_ptr <= 5'h0" in sv_code - assert "transaction_count <= 6'h0" in sv_code - - def test_store_transaction_operation(self): - """Test storing transaction in FIFO.""" - sv_code = """ - // Store transaction signals - transaction_info_t current_transaction; - logic store_transaction; - - // FIFO write operation - if (store_transaction && !transaction_fifo_full) begin - transaction_fifo[transaction_wr_ptr] <= current_transaction; - transaction_wr_ptr <= transaction_wr_ptr + 1; - transaction_count <= transaction_count + 1; - end - """ - - # Verify store signals - assert "transaction_info_t current_transaction" in sv_code - assert "logic store_transaction" in sv_code - - # Verify write operation - assert "if (store_transaction && !transaction_fifo_full)" in sv_code - assert "transaction_fifo[transaction_wr_ptr] <= current_transaction" in sv_code - assert "transaction_wr_ptr <= transaction_wr_ptr + 1" in sv_code - assert "transaction_count <= transaction_count + 1" in sv_code - - def test_retrieve_transaction_operation(self): - """Test retrieving transaction from FIFO.""" - sv_code = """ - // Retrieve transaction signal - logic retrieve_transaction; - - // FIFO read operation - if (retrieve_transaction && !transaction_fifo_empty) begin - transaction_rd_ptr <= transaction_rd_ptr + 1; - transaction_count <= transaction_count - 1; - end - """ - - # Verify retrieve signal - assert "logic retrieve_transaction" in sv_code - - # Verify read operation - assert "if (retrieve_transaction && !transaction_fifo_empty)" in sv_code - assert "transaction_rd_ptr <= transaction_rd_ptr + 1" in sv_code - assert "transaction_count <= transaction_count - 1" in sv_code - - def test_transaction_data_capture(self): - """Test capturing transaction data from TLP header.""" - sv_code = """ - // Extract info based on full TLP type (all 7 bits) - case (tlp_type) - TLP_MEM_RD_32, TLP_MEM_RD_64: begin - // Store transaction info for completion - current_transaction.requester_id <= tlp_header[1][31:16]; // From DW1 - current_transaction.tag <= tlp_header[1][15:8]; // From DW1 - current_transaction.lower_addr <= tlp_address[6:0]; - current_transaction.length <= tlp_header[0][9:0]; // From DW0 - store_transaction <= 1'b1; - end - endcase - """ - - # Verify field assignments - assert "current_transaction.requester_id <= tlp_header[1][31:16]" in sv_code - assert "current_transaction.tag <= tlp_header[1][15:8]" in sv_code - assert "current_transaction.lower_addr <= tlp_address[6:0]" in sv_code - assert "current_transaction.length <= tlp_header[0][9:0]" in sv_code - assert "store_transaction <= 1'b1" in sv_code - - def test_fifo_overflow_protection(self): - """Test FIFO overflow protection.""" - sv_code = """ - // Only store if FIFO not full - if (store_transaction && !transaction_fifo_full) begin - // Safe to store - end else if (store_transaction && transaction_fifo_full) begin - // Handle overflow - could set error flag - debug_status[30] <= 1'b1; // FIFO overflow flag - end - """ - - # Verify overflow protection - assert "!transaction_fifo_full" in sv_code - - # Optional: Check for overflow error handling - # This might not be in the actual implementation - - def test_fifo_underflow_protection(self): - """Test FIFO underflow protection.""" - sv_code = """ - // Only retrieve if FIFO not empty - if (retrieve_transaction && !transaction_fifo_empty) begin - // Safe to retrieve - end else if (retrieve_transaction && transaction_fifo_empty) begin - // Handle underflow - could set error flag - debug_status[29] <= 1'b1; // FIFO underflow flag - end - """ - - # Verify underflow protection - assert "!transaction_fifo_empty" in sv_code - - def test_pointer_wraparound(self, fifo_parameters): - """Test pointer wraparound behavior.""" - sv_code = f""" - // Pointers naturally wrap around due to fixed width - logic [4:0] transaction_wr_ptr; // 5-bit wraps at 32 - logic [4:0] transaction_rd_ptr; // 5-bit wraps at 32 - - // Increment operations - transaction_wr_ptr <= transaction_wr_ptr + 1; // Wraps from 31 to 0 - transaction_rd_ptr <= transaction_rd_ptr + 1; // Wraps from 31 to 0 - """ - - # Verify 5-bit pointers for 32-deep FIFO - assert "[4:0] transaction_wr_ptr" in sv_code - assert "[4:0] transaction_rd_ptr" in sv_code - - def test_fifo_usage_in_completion(self): - """Test FIFO usage during completion generation.""" - sv_code = """ - TLP_PROCESSING: begin - // Process based on TLP type - case (tlp_type) - TLP_MEM_RD_32, TLP_MEM_RD_64: begin - // Send completion for memory read - if (!transaction_fifo_empty) begin - retrieve_transaction <= 1'b1; - tlp_state <= TLP_COMPLETION; - end else begin - tlp_state <= TLP_IDLE; - end - end - endcase - end - - TLP_COMPLETION: begin - // Use retrieved transaction info - transaction_info_t trans_info; - trans_info = transaction_fifo[transaction_rd_ptr]; - end - """ - - # Verify FIFO check before completion - assert "if (!transaction_fifo_empty)" in sv_code - assert "retrieve_transaction <= 1'b1" in sv_code - - # Verify transaction data usage - assert "transaction_fifo[transaction_rd_ptr]" in sv_code - - def test_simultaneous_read_write(self): - """Test behavior with simultaneous read and write.""" - sv_code = """ - // Handle simultaneous operations - if (store_transaction && !transaction_fifo_full && - retrieve_transaction && !transaction_fifo_empty) begin - // Both operations valid - count stays same - transaction_fifo[transaction_wr_ptr] <= current_transaction; - transaction_wr_ptr <= transaction_wr_ptr + 1; - transaction_rd_ptr <= transaction_rd_ptr + 1; - // transaction_count stays the same - end else if (store_transaction && !transaction_fifo_full) begin - // Only write - transaction_count <= transaction_count + 1; - end else if (retrieve_transaction && !transaction_fifo_empty) begin - // Only read - transaction_count <= transaction_count - 1; - end - """ - - # This tests more complex FIFO behavior - # Actual implementation might handle this differently - assert True # Placeholder - - def test_fifo_count_accuracy(self, fifo_parameters): - """Test FIFO count calculation accuracy.""" - # Count should accurately reflect number of valid entries - max_count = fifo_parameters["depth"] - count_width = fifo_parameters["count_width"] - - # Verify count can represent 0 to depth - assert 2**count_width > max_count, "Count width insufficient" - - # Verify count width in implementation - sv_code = f"logic [{count_width-1}:0] transaction_count;" - assert f"[{count_width-1}:0]" in sv_code - - def test_transaction_info_usage_pattern(self): - """Test typical usage pattern of transaction FIFO.""" - sv_code = """ - // Typical usage flow: - // 1. Receive memory read request - // 2. Store transaction info - // 3. Perform BAR read - // 4. Generate completion using stored info - // 5. Remove transaction from FIFO - - // Step 2: Store on read request - case (tlp_type) - TLP_MEM_RD_32, TLP_MEM_RD_64: begin - current_transaction.requester_id <= tlp_header[1][31:16]; - current_transaction.tag <= tlp_header[1][15:8]; - store_transaction <= 1'b1; - bar_rd_en <= 1'b1; - tlp_state <= TLP_BAR_WAIT; - end - endcase - - // Step 4: Use for completion - TLP_COMPLETION: begin - trans_info = transaction_fifo[transaction_rd_ptr]; - cpld_header = generate_cpld_header( - trans_info.requester_id, - trans_info.tag, - trans_info.lower_addr, - completion_length - ); - end - """ - - # Verify the flow exists in some form - assert "store_transaction <= 1'b1" in sv_code - assert "generate_cpld_header" in sv_code - assert "trans_info.requester_id" in sv_code - - def test_debug_visibility(self): - """Test debug visibility of FIFO state.""" - sv_code = """ - // Debug signals for FIFO state - logic [5:0] fifo_occupancy; - assign fifo_occupancy = transaction_count; - - // Debug status could include FIFO state - always_comb begin - debug_fifo_state = { - transaction_fifo_full, // bit 31 - transaction_fifo_empty, // bit 30 - 2'b00, // bits 29:28 - transaction_count, // bits 27:22 (6 bits) - transaction_wr_ptr, // bits 21:17 (5 bits) - transaction_rd_ptr, // bits 16:12 (5 bits) - 12'h000 // bits 11:0 - }; - end - """ - - # This is optional debug feature - assert True # Placeholder - - def test_fifo_clear_on_error(self): - """Test FIFO clearing on error conditions.""" - sv_code = """ - // Clear FIFO on certain error conditions - if (fatal_error || bus_reset) begin - transaction_wr_ptr <= 5'h0; - transaction_rd_ptr <= 5'h0; - transaction_count <= 6'h0; - end - """ - - # This might be implemented for robustness - assert True # Placeholder - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_top_level_wrapper_reset.py b/tests/test_top_level_wrapper_reset.py deleted file mode 100644 index 936745cb..00000000 --- a/tests/test_top_level_wrapper_reset.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python3 -""" -Test suite for verifying reset behavior in the generated SystemVerilog code. -Tests the active-high reset functionality across all components. - -This test verifies: -- Active-high reset polarity (changed from active-low reset_n) -- Proper reset of all state machines -- Reset of all registers and FIFOs -- Reset behavior in different clock domains -- Reset synchronization -""" - -import pytest -import re -from pathlib import Path -from typing import List, Tuple, Optional - - -class TestTopLevelWrapperReset: - """Test suite for PCIe top-level wrapper reset behavior.""" - - @pytest.fixture - def sample_sv_code(self): - """Provide sample SystemVerilog code that would be generated from template.""" - return """ -module pcileech_top ( - input wire sys_clk_p, - input wire sys_clk_n, - input wire sys_rst_n -); - // Internal signals - logic clk; - logic reset; // Active-high reset - logic device_ready; - - // State machine - typedef enum logic [3:0] { - TLP_IDLE, - TLP_HEADER, - TLP_DATA, - TLP_PROCESSING, - TLP_COMPLETION, - TLP_BAR_WAIT, - TLP_WRITE_DATA - } tlp_state_t; - - tlp_state_t tlp_state; - - // Transaction tracking - logic [4:0] transaction_wr_ptr; - logic [4:0] transaction_rd_ptr; - logic [5:0] transaction_count; - - // BAR signals - logic [31:0] bar_addr; - logic [31:0] bar_wr_data; - logic bar_wr_en; - logic bar_rd_en; - logic bar_rd_valid; - logic [31:0] bar_rd_data_captured; - - // TLP signals - logic [63:0] pcie_tx_data; - logic pcie_tx_valid; - logic [31:0] tlp_header [0:3]; - logic [7:0] tlp_header_count; - logic [10:0] tlp_data_count; - - // PCIe IP Core - pcie_7x_bridge pcie_core ( - .sys_rst_n(sys_rst_n), - .user_reset_out(reset), // Active-high reset output - .user_clk_out(clk) - ); - - // Main state machine with reset - always_ff @(posedge clk) begin - if (reset) begin - pcie_tx_data <= '0; - pcie_tx_valid <= 1'b0; - tlp_state <= TLP_IDLE; - tlp_header_count <= 8'h0; - bar_addr <= 32'h0; - bar_wr_data <= 32'h0; - bar_wr_en <= 1'b0; - bar_rd_en <= 1'b0; - tlp_data_count <= 11'h0; - end else begin - // Normal operation - case (tlp_state) - TLP_IDLE: begin - // State logic - end - endcase - end - end - - // Transaction FIFO management with reset - always_ff @(posedge clk) begin - if (reset) begin - transaction_wr_ptr <= 5'h0; - transaction_rd_ptr <= 5'h0; - transaction_count <= 6'h0; - end else begin - // FIFO operations - end - end - - // BAR read data capture pipeline with reset - always_ff @(posedge clk) begin - if (reset) begin - bar_rd_valid <= 1'b0; - bar_rd_data_captured <= 32'h0; - end else begin - bar_rd_valid <= bar_rd_en; - if (bar_rd_en) begin - bar_rd_data_captured <= bar_rd_data; - end - end - end -endmodule -""" - - def test_reset_signal_declaration(self, sample_sv_code): - """Verify reset signal is declared as active-high.""" - # Check for active-high reset declaration - assert "logic reset;" in sample_sv_code - - # Ensure old reset_n is not used (except for system input) - lines_with_reset_n = [ - line - for line in sample_sv_code.split("\n") - if "reset_n" in line and "sys_rst_n" not in line - ] - assert ( - len(lines_with_reset_n) == 0 - ), "Found reset_n usage outside of system reset" - - # Verify comment indicates active-high - assert ( - "// Active-high reset" in sample_sv_code - or "active-high reset" in sample_sv_code.lower() - ) - - def test_pcie_core_reset_connection(self, sample_sv_code): - """Verify PCIe core reset connection uses active-high reset.""" - # Check user_reset_out connection - assert ".user_reset_out(reset)," in sample_sv_code - - # Verify it's not inverted - assert ".user_reset_out(~reset)" not in sample_sv_code - assert ".user_reset_out(!reset)" not in sample_sv_code - - def test_reset_condition_polarity(self, sample_sv_code): - """Test all reset conditions use positive polarity.""" - # Find all reset conditions - reset_conditions = re.findall(r"if\s*\(\s*reset\s*\)\s*begin", sample_sv_code) - assert len(reset_conditions) > 0, "No reset conditions found" - - # Check for incorrect negative reset conditions - negative_resets = re.findall(r"if\s*\(\s*!reset\s*\)\s*begin", sample_sv_code) - negative_resets += re.findall(r"if\s*\(\s*~reset\s*\)\s*begin", sample_sv_code) - assert ( - len(negative_resets) == 0 - ), f"Found negative reset conditions: {negative_resets}" - - def test_all_registers_have_reset(self, sample_sv_code): - """Verify all registers are properly reset.""" - # Extract all register declarations - register_pattern = r"(logic\s+(?:\[[^\]]+\]\s+)?(\w+);)" - registers = re.findall(register_pattern, sample_sv_code) - - # Filter out non-register signals - excluded = [ - "clk", - "reset", - "sys_clk_p", - "sys_clk_n", - "sys_rst_n", - "device_ready", - ] - register_names = [name for _, name in registers if name not in excluded] - - # Check each register has a reset assignment - reset_blocks = re.findall( - r"if\s*\(reset\)\s*begin(.*?)end", sample_sv_code, re.DOTALL - ) - reset_text = "\n".join(reset_blocks) - - for reg in register_names: - if reg.endswith("_t"): # Skip type definitions - continue - # Check for reset assignment - reset_pattern = rf"{reg}\s*<=\s*[^;]+;" - if not re.search(reset_pattern, reset_text): - # Some registers might be reset in separate blocks, so check full code - full_reset_pattern = rf"if\s*\(reset\).*?{reg}\s*<=\s*[^;]+;" - assert re.search( - full_reset_pattern, sample_sv_code, re.DOTALL - ), f"Register {reg} does not have a reset assignment" - - def test_state_machine_reset(self, sample_sv_code): - """Test state machine reset to IDLE state.""" - # Find state machine reset - state_reset = re.search( - r"if\s*\(reset\).*?tlp_state\s*<=\s*(\w+);", sample_sv_code, re.DOTALL - ) - assert state_reset is not None, "State machine reset not found" - assert ( - state_reset.group(1) == "TLP_IDLE" - ), f"State machine should reset to TLP_IDLE, found: {state_reset.group(1)}" - - def test_fifo_pointer_reset(self, sample_sv_code): - """Test FIFO pointers reset to zero.""" - # Check write pointer reset - assert re.search( - r"if\s*\(reset\).*?transaction_wr_ptr\s*<=\s*5\'h0;", - sample_sv_code, - re.DOTALL, - ), "Write pointer not reset to 0" - - # Check read pointer reset - assert re.search( - r"if\s*\(reset\).*?transaction_rd_ptr\s*<=\s*5\'h0;", - sample_sv_code, - re.DOTALL, - ), "Read pointer not reset to 0" - - # Check count reset - assert re.search( - r"if\s*\(reset\).*?transaction_count\s*<=\s*6\'h0;", - sample_sv_code, - re.DOTALL, - ), "Transaction count not reset to 0" - - def test_data_signals_reset(self, sample_sv_code): - """Test data signals reset to zero.""" - data_signals = [ - ("pcie_tx_data", "'0"), - ("pcie_tx_valid", "1'b0"), - ("bar_addr", "32'h0"), - ("bar_wr_data", "32'h0"), - ("bar_wr_en", "1'b0"), - ("bar_rd_en", "1'b0"), - ] - - for signal, value in data_signals: - pattern = rf"if\s*\(reset\).*?{signal}\s*<=\s*{value};" - assert re.search( - pattern, sample_sv_code, re.DOTALL - ), f"Signal {signal} not properly reset to {value}" - - def test_reset_block_structure(self, sample_sv_code): - """Test reset block structure in always_ff blocks.""" - # Find all always_ff blocks - always_blocks = re.findall( - r"always_ff\s*@\s*\(posedge\s+clk\)\s*begin(.*?)end\s*(?=always_ff|endmodule)", - sample_sv_code, - re.DOTALL, - ) - - assert len(always_blocks) > 0, "No always_ff blocks found" - - for block in always_blocks: - # Each should have reset condition first - assert re.search( - r"if\s*\(reset\)\s*begin", block - ), "always_ff block missing reset condition" - - # Should have else for normal operation - assert ( - "else begin" in block or "end else begin" in block - ), "always_ff block missing else clause for normal operation" - - def test_no_reset_in_combinational(self): - """Test that reset is not used in combinational logic.""" - sample_comb = """ - always_comb begin - // Combinational logic should not depend on reset - next_state = current_state; - case (current_state) - IDLE: if (start) next_state = ACTIVE; - ACTIVE: if (done) next_state = IDLE; - endcase - end - """ - - # Reset should not appear in always_comb - assert "reset" not in sample_comb or re.search( - r"//.*reset", sample_comb - ), "Reset found in combinational logic" - - def test_reset_synchronization_template(self): - """Test template for proper reset synchronization.""" - sync_template = """ - // Reset synchronizer (if needed) - logic reset_sync_1, reset_sync_2; - - always_ff @(posedge clk or negedge sys_rst_n) begin - if (!sys_rst_n) begin - reset_sync_1 <= 1'b1; - reset_sync_2 <= 1'b1; - end else begin - reset_sync_1 <= 1'b0; - reset_sync_2 <= reset_sync_1; - end - end - - assign reset = reset_sync_2; - """ - - # Verify synchronizer structure if present - if "reset_sync" in sync_template: - assert "reset_sync_1" in sync_template - assert "reset_sync_2" in sync_template - assert "reset_sync_2 <= reset_sync_1" in sync_template - - def test_reset_value_consistency(self, sample_sv_code): - """Test that reset values are consistent and appropriate.""" - # Counters should reset to 0 - counter_resets = re.findall(r"(\w*count\w*)\s*<=\s*(\d+\'h\w+)", sample_sv_code) - for counter, value in counter_resets: - assert value.endswith( - "h0" - ), f"Counter {counter} should reset to 0, found {value}" - - # Pointers should reset to 0 - pointer_resets = re.findall(r"(\w*ptr\w*)\s*<=\s*(\d+\'h\w+)", sample_sv_code) - for pointer, value in pointer_resets: - assert value.endswith( - "h0" - ), f"Pointer {pointer} should reset to 0, found {value}" - - # Valid/enable signals should reset to 0 - enable_resets = re.findall( - r"(\w*(?:valid|en|enable)\w*)\s*<=\s*1\'b(\d)", sample_sv_code - ) - for signal, value in enable_resets: - assert ( - value == "0" - ), f"Enable signal {signal} should reset to 0, found {value}" - - def test_reset_coverage(self, sample_sv_code): - """Test that all major components have reset coverage.""" - required_reset_items = [ - "tlp_state", - "transaction_wr_ptr", - "transaction_rd_ptr", - "transaction_count", - "pcie_tx_valid", - "pcie_tx_data", - "bar_wr_en", - "bar_rd_en", - "tlp_header_count", - "tlp_data_count", - ] - - for item in required_reset_items: - pattern = rf"if\s*\(reset\).*?{item}\s*<=.*?;" - assert re.search( - pattern, sample_sv_code, re.DOTALL - ), f"Required signal {item} not found in reset blocks" - - def test_reset_does_not_affect_constants(self, sample_sv_code): - """Test that constants and parameters are not reset.""" - # Find all parameters and localparams - params = re.findall( - r"(?:parameter|localparam)\s+\w+\s+(\w+)\s*=", sample_sv_code - ) - - reset_blocks = re.findall( - r"if\s*\(reset\)\s*begin(.*?)end", sample_sv_code, re.DOTALL - ) - reset_text = "\n".join(reset_blocks) - - for param in params: - assert ( - param not in reset_text - ), f"Constant {param} should not be in reset block" - - def test_reset_timing_requirements(self): - """Test documentation of reset timing requirements.""" - reset_spec = """ - // Reset Requirements: - // - Minimum reset pulse width: 100ns - // - Reset must be held for at least 10 clock cycles - // - Reset is internally synchronized to clock domain - // - All outputs tristated during reset - """ - - # This test would verify comments/documentation - assert "clock cycles" in reset_spec or "pulse width" in reset_spec - - def test_pipeline_register_reset(self, sample_sv_code): - """Test pipeline registers are properly reset.""" - # Check BAR read pipeline - assert re.search( - r"if\s*\(reset\).*?bar_rd_valid\s*<=\s*1\'b0;", sample_sv_code, re.DOTALL - ), "BAR read valid not reset" - assert re.search( - r"if\s*\(reset\).*?bar_rd_data_captured\s*<=\s*32\'h0;", - sample_sv_code, - re.DOTALL, - ), "BAR read data not reset" - - def test_no_latches_during_reset(self, sample_sv_code): - """Ensure no latches are created during reset.""" - # This is more of a synthesis check, but we can verify coding style - always_ff_pattern = r"always_ff\s*@\s*\(posedge\s+clk\)" - always_latch_pattern = r"always_latch" - - # Should only use always_ff for sequential logic - assert re.search(always_ff_pattern, sample_sv_code) - assert not re.search(always_latch_pattern, sample_sv_code) - - def test_reset_assertion_order(self, sample_sv_code): - """Test that reset assertion follows proper order.""" - # In each always_ff block, reset should be the first condition - always_blocks = re.findall( - r"always_ff\s*@\s*\(posedge\s+clk\)\s*begin\s*\n\s*(if.*?)(?=end\s*$)", - sample_sv_code, - re.MULTILINE | re.DOTALL, - ) - - for block in always_blocks: - # First condition should be reset - first_if = re.search(r"if\s*\((.*?)\)", block) - if first_if: - condition = first_if.group(1).strip() - assert ( - condition == "reset" - ), f"First condition should be reset, found: {condition}" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_top_level_wrapper_tlp_header.py b/tests/test_top_level_wrapper_tlp_header.py deleted file mode 100644 index 460c38a1..00000000 --- a/tests/test_top_level_wrapper_tlp_header.py +++ /dev/null @@ -1,433 +0,0 @@ -#!/usr/bin/env python3 -""" -Test suite for TLP header parsing in the SystemVerilog code. -Tests both 3-DW and 4-DW header formats with various TLP types. - -This test verifies: -- Correct parsing of 3-DW headers (32-bit addressing) -- Correct parsing of 4-DW headers (64-bit addressing) -- Complete TLP type decoding using all 7 bits -- Format bit detection for header size determination -- Header field extraction (requester ID, tag, etc.) -- Byte enable extraction from header DW1 -- Address extraction based on header format -- Header completeness detection logic -""" - -import pytest -import re -from typing import Dict, List, Tuple, Optional - - -class TestTopLevelWrapperTLPHeader: - """Test suite for TLP header parsing functionality.""" - - @pytest.fixture - def tlp_type_constants(self): - """Provide TLP type constant definitions.""" - return { - "TLP_MEM_RD_32": "7'b0000000", - "TLP_MEM_RD_64": "7'b0100000", - "TLP_MEM_WR_32": "7'b1000000", - "TLP_MEM_WR_64": "7'b1100000", - "TLP_CFG_RD_0": "7'b0000100", - "TLP_CFG_WR_0": "7'b1000100", - "TLP_IO_RD": "7'b0000010", - "TLP_IO_WR": "7'b1000010", - "TLP_CPL": "7'b0001010", - "TLP_CPL_D": "7'b0101010", - "TLP_CPL_LK": "7'b0001011", - "TLP_CPL_D_LK": "7'b0101011", - } - - @pytest.fixture - def sample_3dw_header(self): - """Provide sample 3-DW TLP header data.""" - return { - "dw0": { - "format": "3'b000", # 3DW, no data - "type": "5'b00000", # Memory read - "tc": "3'b000", - "attr": "3'b000", - "length": "10'h001", # 1 DWORD - "hex": "0x00000001", - }, - "dw1": { - "requester_id": "16'h0100", - "tag": "8'h42", - "last_be": "4'hF", - "first_be": "4'hF", - "hex": "0x010042FF", - }, - "dw2": {"address": "32'h12345678", "hex": "0x12345678"}, - } - - @pytest.fixture - def sample_4dw_header(self): - """Provide sample 4-DW TLP header data.""" - return { - "dw0": { - "format": "3'b001", # 4DW, no data - "type": "5'b00000", # Memory read - "tc": "3'b000", - "attr": "3'b000", - "length": "10'h004", # 4 DWORDs - "hex": "0x20000004", - }, - "dw1": { - "requester_id": "16'h0200", - "tag": "8'h84", - "last_be": "4'h0", - "first_be": "4'hF", - "hex": "0x020084F0", - }, - "dw2": {"address_high": "32'h00000000", "hex": "0x00000000"}, - "dw3": {"address_low": "32'h87654321", "hex": "0x87654321"}, - } - - def test_tlp_header_structure_declaration(self): - """Test TLP header array declaration.""" - sv_code = """ - logic [31:0] tlp_header [0:3]; - logic [7:0] tlp_header_count; - logic [6:0] tlp_type; - logic tlp_fmt_4dw; - """ - - # Verify header array can store 4 DWORDs - assert "tlp_header [0:3]" in sv_code - assert "logic [7:0] tlp_header_count" in sv_code - assert "logic [6:0] tlp_type" in sv_code - assert "logic tlp_fmt_4dw" in sv_code - - def test_tlp_type_extraction(self): - """Test extraction of 7-bit TLP type from header.""" - sv_code = """ - // Extract TLP type, format and length from DW0 - tlp_type <= pcie_rx_data[30:24]; // Full 7-bit type from DW0 - tlp_fmt_4dw <= pcie_rx_data[29]; // Format bit (1 = 4DW header) - tlp_length <= pcie_rx_data[9:0]; // From DW0 - """ - - # Verify 7-bit type extraction (not 5-bit) - assert "tlp_type <= pcie_rx_data[30:24]" in sv_code - assert "[30:24]" in sv_code # Full 7 bits - - # Verify format bit extraction - assert "tlp_fmt_4dw <= pcie_rx_data[29]" in sv_code - - # Verify length extraction - assert "tlp_length <= pcie_rx_data[9:0]" in sv_code - - def test_64bit_interface_header_capture(self): - """Test header capture for 64-bit data interface.""" - sv_code = """ - case (tlp_state) - TLP_IDLE: begin - if (pcie_rx_valid) begin - // Fixed data slicing for 64-bit interface - tlp_header[0] <= pcie_rx_data[31:0]; // DW0 - tlp_header[1] <= pcie_rx_data[63:32]; // DW1 - tlp_header_count <= 8'h2; - - // Extract byte enables from DW1 (second header DW) - first_be <= pcie_rx_data[35:32]; // DW1[3:0] - last_be <= pcie_rx_data[39:36]; // DW1[7:4] - end - end - endcase - """ - - # Verify correct data slicing for 64-bit - assert "tlp_header[0] <= pcie_rx_data[31:0]" in sv_code - assert "tlp_header[1] <= pcie_rx_data[63:32]" in sv_code - - # Verify byte enable extraction - assert "first_be <= pcie_rx_data[35:32]" in sv_code - assert "last_be <= pcie_rx_data[39:36]" in sv_code - - def test_header_completeness_check(self): - """Test header completeness detection logic.""" - sv_code = """ - // Check if we have enough header DWs based on format - logic header_complete; - header_complete = tlp_fmt_4dw ? (tlp_header_count >= 8'h4) : (tlp_header_count >= 8'h3); - - if (header_complete) begin - // Process complete header - end - """ - - # Verify dynamic header size checking - assert ( - "header_complete = tlp_fmt_4dw ? (tlp_header_count >= 8'h4) : (tlp_header_count >= 8'h3)" - in sv_code - ) - - def test_3dw_address_extraction(self): - """Test address extraction from 3-DW header.""" - sv_code = """ - if (!tlp_fmt_4dw) begin - // 32-bit addressing: address is in DW2 - tlp_address <= tlp_header[2]; - bar_addr <= tlp_header[2]; - end - """ - - # Verify 3-DW header address extraction - assert "// 32-bit addressing: address is in DW2" in sv_code - assert "tlp_address <= tlp_header[2]" in sv_code - - def test_4dw_address_extraction(self): - """Test address extraction from 4-DW header.""" - sv_code = """ - if (tlp_fmt_4dw) begin - // 64-bit addressing: address is in DW2 and DW3 - tlp_address <= tlp_header[3]; // Lower 32 bits - bar_addr <= tlp_header[3]; - end - """ - - # Verify 4-DW header address extraction - assert "// 64-bit addressing: address is in DW2 and DW3" in sv_code - assert "tlp_address <= tlp_header[3]" in sv_code - - def test_requester_id_tag_extraction(self): - """Test extraction of requester ID and tag from header.""" - sv_code = """ - // Store transaction info for completion - current_transaction.requester_id <= tlp_header[1][31:16]; // From DW1 - current_transaction.tag <= tlp_header[1][15:8]; // From DW1 - current_transaction.lower_addr <= tlp_address[6:0]; - current_transaction.length <= tlp_header[0][9:0]; // From DW0 - """ - - # Verify field extraction from correct DWORDs - assert "requester_id <= tlp_header[1][31:16]" in sv_code - assert "tag <= tlp_header[1][15:8]" in sv_code - assert "length <= tlp_header[0][9:0]" in sv_code - - def test_tlp_type_decoding(self, tlp_type_constants): - """Test TLP type constant definitions use full 7 bits.""" - sv_code = f""" - // TLP Type constants for common operations - localparam TLP_MEM_RD_32 = {tlp_type_constants['TLP_MEM_RD_32']}; // Memory Read 32-bit - localparam TLP_MEM_RD_64 = {tlp_type_constants['TLP_MEM_RD_64']}; // Memory Read 64-bit - localparam TLP_MEM_WR_32 = {tlp_type_constants['TLP_MEM_WR_32']}; // Memory Write 32-bit - localparam TLP_MEM_WR_64 = {tlp_type_constants['TLP_MEM_WR_64']}; // Memory Write 64-bit - """ - - # Verify all constants are 7-bit values - for name, value in tlp_type_constants.items(): - assert value.startswith("7'b"), f"{name} should be 7-bit constant" - - # Verify specific bit patterns - assert ( - tlp_type_constants["TLP_MEM_RD_64"] == "7'b0100000" - ) # Bit 5 set for 64-bit - assert tlp_type_constants["TLP_MEM_WR_64"] == "7'b1100000" # Bits 6,5 set - - def test_byte_enable_handling(self): - """Test byte enable extraction and usage.""" - sv_code = """ - // Byte enable signals - logic [3:0] first_be; // First byte enable - logic [3:0] last_be; // Last byte enable - logic [3:0] current_be; // Current byte enable for write - - // Set initial byte enable based on length - if (tlp_length == 10'd1) begin - // Single DWORD - use first_be only - current_be <= first_be; - end else begin - // Multi-DWORD - use first_be for first DWORD - current_be <= first_be; - end - """ - - # Verify byte enable signal declarations - assert "logic [3:0] first_be" in sv_code - assert "logic [3:0] last_be" in sv_code - assert "logic [3:0] current_be" in sv_code - - # Verify length-based byte enable selection - assert "if (tlp_length == 10'd1)" in sv_code - assert "current_be <= first_be" in sv_code - - def test_multi_beat_header_reception(self): - """Test header reception over multiple beats.""" - sv_code = """ - TLP_HEADER: begin - if (pcie_rx_valid) begin - // Continue reading header DWs - if (tlp_header_count < 4) begin - tlp_header[tlp_header_count] <= pcie_rx_data[31:0]; - tlp_header[tlp_header_count + 1] <= pcie_rx_data[63:32]; - tlp_header_count <= tlp_header_count + 2; - end - end - end - """ - - # Verify incremental header capture - assert "if (tlp_header_count < 4)" in sv_code - assert "tlp_header_count <= tlp_header_count + 2" in sv_code - - def test_tlp_type_case_statement(self): - """Test case statement using full TLP type.""" - sv_code = """ - case (tlp_type) - TLP_MEM_RD_32, TLP_MEM_RD_64: begin - // Memory Read Request - end - - TLP_MEM_WR_32, TLP_MEM_WR_64: begin - // Memory Write Request - end - - TLP_CFG_RD_0: begin - // Configuration Read Type 0 - end - - TLP_CFG_WR_0: begin - // Configuration Write Type 0 - end - - default: begin - // Other TLP types not implemented yet - end - endcase - """ - - # Verify case statement structure - assert "case (tlp_type)" in sv_code - assert "TLP_MEM_RD_32, TLP_MEM_RD_64:" in sv_code - assert "TLP_MEM_WR_32, TLP_MEM_WR_64:" in sv_code - - def test_expected_beats_calculation(self): - """Test calculation of expected data beats.""" - sv_code = """ - // Calculate expected number of data beats (for 64-bit interface) - tlp_expected_beats <= (pcie_rx_data[9:0] + 1) >> 1; // DWORDs to 64-bit beats - """ - - # Verify beat calculation for 64-bit interface - assert "tlp_expected_beats <= (pcie_rx_data[9:0] + 1) >> 1" in sv_code - - def test_bar_hit_validation(self): - """Test BAR hit validation before processing.""" - sv_code = """ - // Validate BAR hit before processing - if (!bar_hit_valid || rx_err_fwd) begin - // Invalid BAR access or error - skip processing - tlp_state <= TLP_IDLE; - end else begin - // Valid BAR hit - continue processing - end - """ - - # Verify BAR validation - assert "if (!bar_hit_valid || rx_err_fwd)" in sv_code - assert "// Invalid BAR access or error" in sv_code - - def test_sideband_signal_extraction(self): - """Test extraction of sideband signals from m_axis_rx_tuser.""" - sv_code = """ - // Decode sideband signals from m_axis_rx_tuser - bar_hit <= m_axis_rx_tuser[2:0]; // Which BAR was hit - bar_hit_valid <= |m_axis_rx_tuser[6:3]; // BAR hit vector - rx_err_fwd <= m_axis_rx_tuser[21]; // Error forwarding - """ - - # Verify sideband signal decoding - assert "bar_hit <= m_axis_rx_tuser[2:0]" in sv_code - assert "bar_hit_valid <= |m_axis_rx_tuser[6:3]" in sv_code - assert "rx_err_fwd <= m_axis_rx_tuser[21]" in sv_code - - def test_header_field_positions(self, sample_3dw_header): - """Test that header fields are extracted from correct bit positions.""" - # Test DW0 fields - dw0 = sample_3dw_header["dw0"] - format_type_bits = "[31:24]" # Format[31:29] + Type[28:24] - length_bits = "[9:0]" - - sv_pattern = f""" - // DW0 extraction - tlp_fmt_type <= pcie_rx_data{format_type_bits}; - tlp_length <= pcie_rx_data{length_bits}; - """ - - # Test DW1 fields - dw1 = sample_3dw_header["dw1"] - req_id_bits = "[31:16]" - tag_bits = "[15:8]" - be_bits = "[7:0]" - - # These should match the actual implementation - assert True # Placeholder - actual test would verify bit positions - - def test_header_parsing_state_machine(self): - """Test state machine transitions for header parsing.""" - states = ["TLP_IDLE", "TLP_HEADER", "TLP_DATA", "TLP_PROCESSING"] - - sv_code = """ - case (tlp_state) - TLP_IDLE: begin - if (pcie_rx_valid) begin - tlp_state <= TLP_HEADER; - end - end - - TLP_HEADER: begin - if (header_complete) begin - if (has_data) begin - tlp_state <= TLP_DATA; - end else begin - tlp_state <= TLP_PROCESSING; - end - end - end - endcase - """ - - # Verify state transitions - assert "tlp_state <= TLP_HEADER" in sv_code - assert "tlp_state <= TLP_DATA" in sv_code - assert "tlp_state <= TLP_PROCESSING" in sv_code - - def test_address_alignment_checks(self): - """Test address alignment verification for different TLP types.""" - sv_code = """ - // Check address alignment based on first/last BE - logic addr_aligned; - - case (first_be) - 4'b1111: addr_aligned = (tlp_address[1:0] == 2'b00); // DWORD aligned - 4'b0111: addr_aligned = (tlp_address[1:0] == 2'b01); // Byte 1 start - 4'b0011: addr_aligned = (tlp_address[1:0] == 2'b10); // Byte 2 start - 4'b0001: addr_aligned = (tlp_address[1:0] == 2'b11); // Byte 3 start - default: addr_aligned = 1'b0; // Invalid BE - endcase - """ - - # This is more advanced - verify alignment checking exists - assert True # Placeholder - - def test_header_error_detection(self): - """Test detection of malformed headers.""" - sv_code = """ - // Detect invalid header conditions - logic header_error; - - header_error = (tlp_length == 10'h0) || // Zero length - (tlp_length > 10'h100) || // Too long - (first_be == 4'h0 && tlp_length == 10'h1) || // No bytes enabled - (tlp_type[6:5] == 2'b11 && !tlp_fmt_4dw); // 64-bit type needs 4DW - """ - - # Verify error detection logic - assert True # Placeholder - would check actual implementation - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_vfio_access_seam.py b/tests/test_vfio_access_seam.py new file mode 100644 index 00000000..294fcc89 --- /dev/null +++ b/tests/test_vfio_access_seam.py @@ -0,0 +1,84 @@ +"""Tests for the VFIOAccess domain seam. + +The domain (ConfigSpaceManager / VFIODeviceManager) depends on a VFIOAccess +Protocol it owns, not on the concrete CLI helpers. These tests verify the +default adapter resolves and that a fake adapter can be injected so domain VFIO +logic is exercisable without root or real hardware. +""" + +from pcileechfwgenerator.device_clone.config_space_manager import ConfigSpaceManager +from pcileechfwgenerator.device_clone.vfio_access import ( + VFIOAccess, + get_default_vfio_access, +) + + +class _FakeBinder: + def __init__(self): + self.bound = False + + @property + def is_bound(self): + return self.bound + + def bind(self): + self.bound = True + + def unbind(self): + self.bound = False + + +class _FakeVFIOAccess: + def __init__(self): + self.calls = [] + + def ensure_binding(self, bdf): + self.calls.append(("ensure_binding", bdf)) + return "42" + + def open_device(self, bdf): + self.calls.append(("open_device", bdf)) + return (3, 4) + + def bind_for_session(self, bdf, attach=True): + self.calls.append(("bind_for_session", bdf, attach)) + return _FakeBinder() + + +def test_default_adapter_satisfies_protocol(): + adapter = get_default_vfio_access() + assert isinstance(adapter, VFIOAccess) + + +def test_fake_adapter_satisfies_protocol(): + assert isinstance(_FakeVFIOAccess(), VFIOAccess) + + +def test_config_space_manager_accepts_injected_access(): + fake = _FakeVFIOAccess() + mgr = ConfigSpaceManager("0000:01:00.0", vfio_access=fake) + assert mgr._vfio_access is fake + + +def test_config_space_manager_defaults_to_cli_adapter(): + mgr = ConfigSpaceManager("0000:01:00.0") + assert isinstance(mgr._vfio_access, VFIOAccess) + + +def test_importing_vfio_access_does_not_import_cli(): + # The port module must not drag the CLI layer in at import time. + # Fresh-ish check: the adapter only imports CLI lazily inside its methods. + import importlib + import sys + + importlib.import_module("pcileechfwgenerator.device_clone.vfio_access") + # We don't assert cli is absent globally (other tests may import it), but + # constructing the default adapter must not require CLI to be importable + # at definition time — covered by the import succeeding above. + assert "pcileechfwgenerator.device_clone.vfio_access" in sys.modules + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"]) diff --git a/tests/test_visualize_coe.py b/tests/test_visualize_coe.py index f4706553..e9071974 100644 --- a/tests/test_visualize_coe.py +++ b/tests/test_visualize_coe.py @@ -5,13 +5,10 @@ Tests parsing, capability walking, and visualization logic. """ -import sys -from pathlib import Path import pytest # Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) from scripts.visualize_coe import ( BoxPrinter, diff --git a/tests/test_vivado_handling.py b/tests/test_vivado_handling.py new file mode 100644 index 00000000..cbdcb0d8 --- /dev/null +++ b/tests/test_vivado_handling.py @@ -0,0 +1,933 @@ +#!/usr/bin/env python3 +""" +Unit tests for the Vivado Stage 3 modules. + +These tests give coverage to ``vivado_utils``, ``vivado_runner`` and +``vivado_error_reporter`` WITHOUT requiring a real Vivado installation. + +Everything that would otherwise touch the filesystem, a subprocess or a +container is mocked. Patch targets reference the *imported* names inside the +module under test (e.g. ``...vivado_utils.subprocess`` / +``...vivado_utils.shutil``) so the real implementations are never invoked. + +Note: ``run_vivado_command`` and ``VivadoRunner.run`` use late/dynamic imports +of ``vivado_error_reporter`` / ``pcileech_build_integration``, so those module +attributes are patched directly rather than the importing module's namespace. +""" + +import subprocess +from pathlib import Path + +import pytest +from pcileechfwgenerator.vivado_handling import vivado_error_reporter as rep_mod +from pcileechfwgenerator.vivado_handling import vivado_runner as runner_mod +from pcileechfwgenerator.vivado_handling import vivado_utils +from pcileechfwgenerator.vivado_handling.vivado_error_reporter import ( + ColorFormatter, + ErrorSeverity, + VivadoErrorParser, + VivadoErrorReporter, + VivadoErrorType, +) +from pcileechfwgenerator.vivado_handling.vivado_runner import ( + VivadoIntegrationError, + VivadoRunner, + create_vivado_runner, +) +from pcileechfwgenerator.vivado_handling.vivado_utils import ( + _detect_version, + _iter_candidate_dirs, + _vivado_executable, + find_vivado_installation, + get_vivado_search_paths, + get_vivado_version, + run_vivado_command, +) + +# ────────────────────────── Test helpers ────────────────────────── + + +def _completed(returncode=0, stdout=""): + """Build a fake subprocess.CompletedProcess for subprocess.run mocks.""" + return subprocess.CompletedProcess( + args=["vivado", "-version"], + returncode=returncode, + stdout=stdout, + stderr="", + ) + + +class FakeStdout: + """Minimal stand-in for process.stdout supporting readline().""" + + def __init__(self, lines): + self._lines = list(lines) + self._idx = 0 + + def readline(self): + if self._idx < len(self._lines): + line = self._lines[self._idx] + self._idx += 1 + return line + return "" + + +class FakePopen: + """Fake subprocess.Popen yielding lines then a terminal poll() value.""" + + def __init__(self, lines, returncode=0): + self.stdout = FakeStdout(lines) + self._returncode = returncode + self._poll_calls = 0 + + def poll(self): + # Return None while there is still output to read, then the final code. + self._poll_calls += 1 + if self.stdout._idx < len(self.stdout._lines): + return None + return self._returncode + + def wait(self, timeout=None): # pragma: no cover - convenience + return self._returncode + + def kill(self): # pragma: no cover - convenience + pass + + +# ══════════════════════════════════════════════════════════════════ +# vivado_utils: find_vivado_installation +# ══════════════════════════════════════════════════════════════════ + + +class TestFindVivadoInstallation: + def test_manual_path_valid_returns_dict(self, tmp_path, monkeypatch): + vivado_root = tmp_path / "Vivado" + bin_dir = vivado_root / "bin" + bin_dir.mkdir(parents=True) + exe = bin_dir / "vivado" + exe.write_text("#!/bin/sh\n") + + monkeypatch.setattr(vivado_utils, "get_vivado_version", lambda _exe: "2025.1") + + info = find_vivado_installation(manual_path=str(vivado_root)) + assert info is not None + assert info["path"] == str(vivado_root) + assert info["bin_path"] == str(vivado_root / "bin") + assert info["executable"] == str(exe) + assert info["version"] == "2025.1" + + def test_manual_path_missing_directory_falls_through_to_none(self, monkeypatch): + # Manual path does not exist; auto-detection finds nothing. + monkeypatch.setattr(vivado_utils, "_iter_candidate_dirs", lambda: iter(())) + info = find_vivado_installation(manual_path="/definitely/not/here") + assert info is None + + def test_manual_path_dir_without_executable_falls_through( + self, tmp_path, monkeypatch + ): + # Directory exists but has no bin/vivado -> warning + fallthrough. + root = tmp_path / "Vivado" + root.mkdir() + monkeypatch.setattr(vivado_utils, "_iter_candidate_dirs", lambda: iter(())) + info = find_vivado_installation(manual_path=str(root)) + assert info is None + + def test_discovery_via_candidate_dirs(self, tmp_path, monkeypatch): + vivado_root = tmp_path / "2024.2" / "Vivado" + bin_dir = vivado_root / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "vivado").write_text("#!/bin/sh\n") + + monkeypatch.setattr( + vivado_utils, "_iter_candidate_dirs", lambda: iter([vivado_root]) + ) + monkeypatch.setattr(vivado_utils, "get_vivado_version", lambda _exe: "2024.2") + + info = find_vivado_installation() + assert info is not None + assert info["path"] == str(vivado_root) + assert info["version"] == "2024.2" + + def test_discovery_skips_dirs_without_executable(self, tmp_path, monkeypatch): + empty_root = tmp_path / "empty" / "Vivado" + empty_root.mkdir(parents=True) + good_root = tmp_path / "2023.1" / "Vivado" + (good_root / "bin").mkdir(parents=True) + (good_root / "bin" / "vivado").write_text("#!/bin/sh\n") + + monkeypatch.setattr( + vivado_utils, + "_iter_candidate_dirs", + lambda: iter([empty_root, good_root]), + ) + monkeypatch.setattr(vivado_utils, "get_vivado_version", lambda _exe: "2023.1") + + info = find_vivado_installation() + assert info is not None + assert info["path"] == str(good_root) + + def test_none_found_returns_none(self, monkeypatch): + monkeypatch.setattr(vivado_utils, "_iter_candidate_dirs", lambda: iter(())) + assert find_vivado_installation() is None + + def test_version_unknown_is_returned_verbatim(self, tmp_path, monkeypatch): + # Documents ACTUAL behavior: get_vivado_version returns the truthy + # string "unknown", so the `or _detect_version(...)` fallback never + # fires and the final version is "unknown" even when the dirname + # encodes a version. (See report: latent source quirk.) + vivado_root = tmp_path / "2025.1" / "Vivado" + (vivado_root / "bin").mkdir(parents=True) + (vivado_root / "bin" / "vivado").write_text("#!/bin/sh\n") + + monkeypatch.setattr( + vivado_utils, "_iter_candidate_dirs", lambda: iter([vivado_root]) + ) + monkeypatch.setattr(vivado_utils, "get_vivado_version", lambda _exe: "unknown") + + info = find_vivado_installation() + assert info is not None + assert info["version"] == "unknown" + + def test_version_falls_back_to_detect_when_query_returns_falsy( + self, tmp_path, monkeypatch + ): + # When get_vivado_version returns a falsy value, the `or` fallback + # to _detect_version(root) kicks in and pulls the version from the + # directory name. + vivado_root = tmp_path / "2022.2" / "Vivado" + (vivado_root / "bin").mkdir(parents=True) + (vivado_root / "bin" / "vivado").write_text("#!/bin/sh\n") + + monkeypatch.setattr( + vivado_utils, "_iter_candidate_dirs", lambda: iter([vivado_root]) + ) + monkeypatch.setattr(vivado_utils, "get_vivado_version", lambda _exe: "") + + info = find_vivado_installation() + assert info is not None + assert info["version"] == "2022.2" + + +# ══════════════════════════════════════════════════════════════════ +# vivado_utils: get_vivado_version +# ══════════════════════════════════════════════════════════════════ + + +class TestGetVivadoVersion: + def test_parses_standard_version(self, monkeypatch): + out = "vivado v2025.1 (64-bit)\nSW Build 1234" + monkeypatch.setattr( + vivado_utils.subprocess, + "run", + lambda *a, **k: _completed(0, out), + ) + assert get_vivado_version("/x/vivado") == "2025.1" + + def test_parses_version_with_suffix(self, monkeypatch): + out = "Vivado v2023.2.1 (64-bit)" + monkeypatch.setattr( + vivado_utils.subprocess, + "run", + lambda *a, **k: _completed(0, out), + ) + assert get_vivado_version("/x/vivado") == "2023.2.1" + + def test_malformed_output_returns_unknown(self, monkeypatch): + out = "this output has no version token at all" + monkeypatch.setattr( + vivado_utils.subprocess, + "run", + lambda *a, **k: _completed(0, out), + ) + assert get_vivado_version("/x/vivado") == "unknown" + + def test_nonzero_returncode_returns_unknown(self, monkeypatch): + monkeypatch.setattr( + vivado_utils.subprocess, + "run", + lambda *a, **k: _completed(1, "vivado v2025.1 (64-bit)"), + ) + assert get_vivado_version("/x/vivado") == "unknown" + + def test_filenotfound_returns_unknown(self, monkeypatch): + def _raise(*a, **k): + raise FileNotFoundError("no such file") + + monkeypatch.setattr(vivado_utils.subprocess, "run", _raise) + assert get_vivado_version("/x/vivado") == "unknown" + + def test_timeout_returns_unknown(self, monkeypatch): + def _raise(*a, **k): + raise subprocess.TimeoutExpired(cmd="vivado", timeout=5) + + monkeypatch.setattr(vivado_utils.subprocess, "run", _raise) + assert get_vivado_version("/x/vivado") == "unknown" + + def test_permission_error_returns_unknown(self, monkeypatch): + def _raise(*a, **k): + raise PermissionError("denied") + + monkeypatch.setattr(vivado_utils.subprocess, "run", _raise) + assert get_vivado_version("/x/vivado") == "unknown" + + +# ══════════════════════════════════════════════════════════════════ +# vivado_utils: get_vivado_search_paths +# ══════════════════════════════════════════════════════════════════ + + +class TestGetVivadoSearchPaths: + def test_includes_system_path_and_env_entry(self, monkeypatch): + monkeypatch.setenv("XILINX_VIVADO", "/custom/vivado") + paths = get_vivado_search_paths() + assert "System PATH" in paths + assert any("XILINX_VIVADO=/custom/vivado" in p for p in paths) + + def test_env_not_set_shows_placeholder(self, monkeypatch): + monkeypatch.delenv("XILINX_VIVADO", raising=False) + paths = get_vivado_search_paths() + assert any("XILINX_VIVADO=" in p for p in paths) + + def test_includes_default_bases(self): + paths = get_vivado_search_paths() + # DEFAULT_BASES entries are stringified into the list. + for base in vivado_utils.DEFAULT_BASES: + assert str(base) in paths + + +# ══════════════════════════════════════════════════════════════════ +# vivado_utils: internal helpers +# ══════════════════════════════════════════════════════════════════ + + +class TestUtilsInternals: + def test_detect_version_from_dirname(self): + assert _detect_version(Path("/tools/Xilinx/2025.1/Vivado")) == "2025.1" + + def test_detect_version_unknown(self): + assert _detect_version(Path("/opt/nope/Vivado")) == "unknown" + + def test_vivado_executable_present(self, tmp_path): + root = tmp_path / "Vivado" + (root / "bin").mkdir(parents=True) + exe = root / "bin" / "vivado" + exe.write_text("#!/bin/sh\n") + assert _vivado_executable(root) == exe + + def test_vivado_executable_absent(self, tmp_path): + root = tmp_path / "Vivado" + root.mkdir() + assert _vivado_executable(root) is None + + def test_iter_candidate_dirs_includes_path_hit(self, monkeypatch): + monkeypatch.setattr( + vivado_utils.shutil, "which", lambda name: "/opt/V/bin/vivado" + ) + monkeypatch.delenv("XILINX_VIVADO", raising=False) + # Avoid scanning real /tools/Xilinx by forcing Path.exists -> False. + monkeypatch.setattr(Path, "exists", lambda self: False) + dirs = list(_iter_candidate_dirs()) + # bin/ -> Vivado/ : parent.parent of the executable + assert Path("/opt/V") in dirs + + def test_iter_candidate_dirs_includes_env(self, monkeypatch): + monkeypatch.setattr(vivado_utils.shutil, "which", lambda name: None) + monkeypatch.setenv("XILINX_VIVADO", "/env/vivado") + monkeypatch.setattr(Path, "exists", lambda self: False) + dirs = list(_iter_candidate_dirs()) + assert Path("/env/vivado") in dirs + + +# ══════════════════════════════════════════════════════════════════ +# vivado_utils: run_vivado_command +# ══════════════════════════════════════════════════════════════════ + + +class TestRunVivadoCommand: + def test_raises_when_no_executable(self, monkeypatch): + monkeypatch.setattr( + vivado_utils, "find_vivado_installation", lambda *a, **k: None + ) + monkeypatch.setattr(vivado_utils.shutil, "which", lambda name: None) + with pytest.raises(FileNotFoundError): + run_vivado_command("-version", use_discovered=True) + + def test_builds_argv_for_string_args(self, monkeypatch): + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return _completed(0, "") + + monkeypatch.setattr( + vivado_utils, "find_vivado_installation", lambda *a, **k: None + ) + monkeypatch.setattr( + vivado_utils.shutil, "which", lambda name: "/opt/V/bin/vivado" + ) + monkeypatch.setattr(vivado_utils.subprocess, "run", fake_run) + + run_vivado_command( + "-mode batch", tcl_file="build.tcl", enable_error_reporting=False + ) + assert captured["cmd"] == [ + "/opt/V/bin/vivado", + "-mode", + "batch", + "-source", + "build.tcl", + ] + + def test_builds_argv_for_list_args(self, monkeypatch): + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return _completed(0, "") + + monkeypatch.setattr( + vivado_utils, "find_vivado_installation", lambda *a, **k: None + ) + monkeypatch.setattr( + vivado_utils.shutil, "which", lambda name: "/opt/V/bin/vivado" + ) + monkeypatch.setattr(vivado_utils.subprocess, "run", fake_run) + + run_vivado_command(["-mode", "batch"], enable_error_reporting=False) + assert captured["cmd"] == ["/opt/V/bin/vivado", "-mode", "batch"] + + def test_uses_discovered_executable(self, monkeypatch): + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return _completed(0, "") + + monkeypatch.setattr( + vivado_utils, + "find_vivado_installation", + lambda *a, **k: {"executable": "/disc/bin/vivado"}, + ) + monkeypatch.setattr(vivado_utils.subprocess, "run", fake_run) + + run_vivado_command(["-version"], enable_error_reporting=False) + assert captured["cmd"][0] == "/disc/bin/vivado" + + def test_error_reporting_path_returns_completed_process(self, monkeypatch): + monkeypatch.setattr( + vivado_utils, "find_vivado_installation", lambda *a, **k: None + ) + monkeypatch.setattr( + vivado_utils.shutil, "which", lambda name: "/opt/V/bin/vivado" + ) + monkeypatch.setattr( + vivado_utils.subprocess, + "Popen", + lambda *a, **k: FakePopen([], returncode=0), + ) + + # Patch the reporter class on the *vivado_error_reporter module* + # since run_vivado_command imports it dynamically. + class FakeReporter: + def __init__(self, *a, **k): + pass + + def monitor_vivado_process(self, process): + return 0, [], [] + + def generate_error_report(self, *a, **k): + return "" + + def print_summary(self, *a, **k): + pass + + monkeypatch.setattr(rep_mod, "VivadoErrorReporter", FakeReporter) + + result = run_vivado_command(["-version"], enable_error_reporting=True) + assert isinstance(result, subprocess.CompletedProcess) + assert result.returncode == 0 + + def test_error_reporting_nonzero_raises_calledprocesserror(self, monkeypatch): + monkeypatch.setattr( + vivado_utils, "find_vivado_installation", lambda *a, **k: None + ) + monkeypatch.setattr( + vivado_utils.shutil, "which", lambda name: "/opt/V/bin/vivado" + ) + monkeypatch.setattr( + vivado_utils.subprocess, + "Popen", + lambda *a, **k: FakePopen([], returncode=2), + ) + + fake_err = rep_mod.VivadoError( + error_type=VivadoErrorType.SYNTAX_ERROR, + severity=ErrorSeverity.ERROR, + message="boom", + ) + + class FakeReporter: + def __init__(self, *a, **k): + pass + + def monitor_vivado_process(self, process): + return 2, [fake_err], [] + + def generate_error_report(self, *a, **k): + return "" + + def print_summary(self, *a, **k): + pass + + monkeypatch.setattr(rep_mod, "VivadoErrorReporter", FakeReporter) + + with pytest.raises(subprocess.CalledProcessError): + run_vivado_command(["-version"], enable_error_reporting=True, cwd="/tmp") + + +# ══════════════════════════════════════════════════════════════════ +# vivado_error_reporter: VivadoErrorParser +# ══════════════════════════════════════════════════════════════════ + + +class TestVivadoErrorParser: + def test_syntax_error_classification(self): + p = VivadoErrorParser() + line = "ERROR: [Synth 8-2715] syntax error near foo [/tmp/mod.sv:42]" + errors, warnings = p.parse_output(line) + assert len(errors) == 1 + assert not warnings + err = errors[0] + assert err.error_type == VivadoErrorType.SYNTAX_ERROR + assert err.severity == ErrorSeverity.ERROR + + def test_syntax_error_extracts_location(self): + p = VivadoErrorParser() + line = "ERROR: [Synth 8-2715] bad token [/tmp/mod.sv:42]" + errors, _ = p.parse_output(line) + err = errors[0] + assert err.file_path == "/tmp/mod.sv" + assert err.line_number == 42 + + def test_timing_error_classification(self): + p = VivadoErrorParser() + line = "ERROR: [Timing 38-282] The design did not meet timing" + errors, warnings = p.parse_output(line) + assert len(errors) == 1 + assert errors[0].error_type == VivadoErrorType.TIMING_ERROR + assert errors[0].severity == ErrorSeverity.ERROR + + def test_timing_critical_warning_is_critical_and_in_errors(self): + p = VivadoErrorParser() + line = "CRITICAL WARNING: [Timing 38-282] timing not met" + errors, warnings = p.parse_output(line) + # CRITICAL routes to the errors list, not warnings. + assert len(errors) == 1 + assert not warnings + assert errors[0].severity == ErrorSeverity.CRITICAL + assert errors[0].error_type == VivadoErrorType.TIMING_ERROR + + def test_resource_error_classification(self): + p = VivadoErrorParser() + line = "ERROR: [Place 30-640] Placement failed for design" + errors, _ = p.parse_output(line) + assert errors[0].error_type == VivadoErrorType.RESOURCE_ERROR + assert errors[0].severity == ErrorSeverity.ERROR + + def test_licensing_error_classification(self): + p = VivadoErrorParser() + line = "ERROR: [Common 17-349] No license found for PCIe license" + errors, _ = p.parse_output(line) + assert errors[0].error_type == VivadoErrorType.LICENSING_ERROR + + def test_generic_warning_is_unknown_type_and_in_warnings(self): + p = VivadoErrorParser() + line = "WARNING: [Synth 8-1234] something to note here" + errors, warnings = p.parse_output(line) + assert not errors + assert len(warnings) == 1 + assert warnings[0].error_type == VivadoErrorType.UNKNOWN_ERROR + assert warnings[0].severity == ErrorSeverity.WARNING + + def test_info_line_is_info_severity_in_warnings(self): + p = VivadoErrorParser() + line = "INFO: [Synth 8-5544] processing module top" + errors, warnings = p.parse_output(line) + assert not errors + assert len(warnings) == 1 + assert warnings[0].severity == ErrorSeverity.INFO + + def test_generic_critical_warning_routes_to_errors(self): + p = VivadoErrorParser() + line = "CRITICAL WARNING: [Vivado 12-1234] something serious happened" + errors, warnings = p.parse_output(line) + assert len(errors) == 1 + assert not warnings + assert errors[0].severity == ErrorSeverity.CRITICAL + + def test_unmatched_line_produces_nothing(self): + p = VivadoErrorParser() + errors, warnings = p.parse_output("just some random log noise") + assert not errors + assert not warnings + + def test_parse_log_file_missing_returns_empty(self, tmp_path): + p = VivadoErrorParser() + errors, warnings = p.parse_log_file(tmp_path / "does_not_exist.log") + assert errors == [] + assert warnings == [] + + def test_parse_log_file_reads_real_file(self, tmp_path): + log = tmp_path / "vivado.log" + log.write_text( + "INFO: [Synth 8-1] starting\n" + "ERROR: [Synth 8-2715] syntax error [/tmp/x.sv:7]\n" + "WARNING: [Synth 8-99] minor warning\n" + ) + p = VivadoErrorParser() + errors, warnings = p.parse_log_file(log) + assert len(errors) == 1 + assert errors[0].error_type == VivadoErrorType.SYNTAX_ERROR + # INFO + WARNING both land in warnings list. + assert len(warnings) == 2 + + def test_parse_clears_state_between_calls(self): + p = VivadoErrorParser() + p.parse_output("ERROR: [Synth 8-2715] x [/a.sv:1]") + errors, _ = p.parse_output("nothing here") + assert errors == [] + + +# ══════════════════════════════════════════════════════════════════ +# vivado_error_reporter: ColorFormatter +# ══════════════════════════════════════════════════════════════════ + + +class TestColorFormatter: + def test_colors_disabled_passthrough(self): + f = ColorFormatter(use_colors=False) + assert f.error("boom") == "boom" + assert f.warning("w") == "w" + assert f.info("i") == "i" + assert f.success("s") == "s" + + def test_colors_enabled_wraps_ansi(self): + f = ColorFormatter(use_colors=True) + out = f.error("boom") + assert out != "boom" + assert out.startswith("\033[91m") + assert out.endswith("\033[0m") + assert "boom" in out + + def test_colorize_bold_and_underline(self): + f = ColorFormatter(use_colors=True) + assert f.bold("x").startswith("\033[1m") + assert f.underline("x").startswith("\033[4m") + + def test_strip_ansi_codes_removes_codes(self): + reporter = VivadoErrorReporter(use_colors=True) + colored = "\033[91mred\033[0m and \033[92mgreen\033[0m" + assert reporter._strip_ansi_codes(colored) == "red and green" + + +# ══════════════════════════════════════════════════════════════════ +# vivado_error_reporter: VivadoErrorReporter +# ══════════════════════════════════════════════════════════════════ + + +class TestVivadoErrorReporter: + def test_monitor_collects_errors_and_warnings(self): + lines = [ + "INFO: [Synth 8-1] starting\n", + "ERROR: [Synth 8-2715] bad [/tmp/x.sv:1]\n", + "WARNING: [Synth 8-2] heads up\n", + ] + reporter = VivadoErrorReporter(use_colors=False) + rc, errors, warnings = reporter.monitor_vivado_process( + FakePopen(lines, returncode=0) + ) + assert rc == 0 + assert len(errors) == 1 + # INFO + WARNING -> 2 warnings. + assert len(warnings) == 2 + + def test_monitor_returns_nonzero_code(self): + reporter = VivadoErrorReporter(use_colors=False) + rc, errors, warnings = reporter.monitor_vivado_process( + FakePopen(["ERROR: [Synth 8-2715] x [/a.sv:1]\n"], returncode=3) + ) + assert rc == 3 + assert len(errors) == 1 + + def test_generate_error_report_no_issues(self): + reporter = VivadoErrorReporter(use_colors=False) + report = reporter.generate_error_report([], [], "Build") + assert "No errors or warnings found" in report + assert "ERROR REPORT" in report + + def test_generate_error_report_with_errors_summarizes(self): + reporter = VivadoErrorReporter(use_colors=False) + err = rep_mod.VivadoError( + error_type=VivadoErrorType.SYNTAX_ERROR, + severity=ErrorSeverity.ERROR, + message="bad syntax", + file_path="/tmp/x.sv", + line_number=3, + ) + report = reporter.generate_error_report([err], [], "Build") + assert "SUMMARY" in report + assert "ERRORS:" in report + assert "bad syntax" in report + + def test_generate_error_report_writes_ansi_stripped_file(self, tmp_path): + reporter = VivadoErrorReporter(use_colors=True) + err = rep_mod.VivadoError( + error_type=VivadoErrorType.SYNTAX_ERROR, + severity=ErrorSeverity.ERROR, + message="bad syntax", + ) + out = tmp_path / "report.txt" + reporter.generate_error_report([err], [], "Build", output_file=out) + assert out.exists() + content = out.read_text() + # File must have ANSI codes stripped even though use_colors=True. + assert "\033[" not in content + assert "bad syntax" in content + + def test_print_summary_failure_path(self, capsys): + reporter = VivadoErrorReporter(use_colors=False) + err = rep_mod.VivadoError( + error_type=VivadoErrorType.SYNTAX_ERROR, + severity=ErrorSeverity.ERROR, + message="bad", + ) + reporter.print_summary([err], []) + captured = capsys.readouterr() + assert "1 error(s) found" in captured.out + assert "Build FAILED" in captured.out + + def test_print_summary_success_path(self, capsys): + reporter = VivadoErrorReporter(use_colors=False) + reporter.print_summary([], []) + captured = capsys.readouterr() + assert "Build completed successfully" in captured.out + + +# ══════════════════════════════════════════════════════════════════ +# vivado_runner: VivadoRunner +# ══════════════════════════════════════════════════════════════════ + + +class TestVivadoRunner: + def _make(self, tmp_path, **kwargs): + return VivadoRunner( + board=kwargs.get("board", "pcileech_35t325_x1"), + output_dir=kwargs.get("output_dir", tmp_path / "out"), + vivado_path=kwargs.get("vivado_path", "/tools/Xilinx/2025.1/Vivado"), + ) + + def test_extract_version_matching(self, tmp_path): + r = self._make(tmp_path) + assert r._extract_version_from_path("/tools/Xilinx/2025.1/Vivado") == ("2025.1") + + def test_extract_version_non_matching(self, tmp_path): + r = self._make(tmp_path) + assert r._extract_version_from_path("/no/version/here") == "unknown" + + def test_derived_paths(self, tmp_path): + r = self._make(tmp_path, vivado_path="/opt/Vivado") + assert r.vivado_executable == "/opt/Vivado/bin/vivado" + assert r.vivado_bin_dir == "/opt/Vivado/bin" + + def test_get_vivado_info_shape(self, tmp_path): + r = self._make(tmp_path) + info = r.get_vivado_info() + assert set(info) == { + "executable", + "bin_dir", + "version", + "installation_path", + } + assert info["version"] == "2025.1" + assert info["installation_path"] == "/tools/Xilinx/2025.1/Vivado" + + def test_is_running_in_container_dockerenv(self, tmp_path, monkeypatch): + r = self._make(tmp_path) + # /.dockerenv exists -> True (first indicator short-circuits). + monkeypatch.setattr(Path, "exists", lambda self: str(self) == "/.dockerenv") + assert r._is_running_in_container() is True + + def test_is_running_in_container_via_proc_environ(self, tmp_path, monkeypatch): + r = self._make(tmp_path) + # No indicator files exist. + monkeypatch.setattr(Path, "exists", lambda self: False) + + import builtins + + real_open = builtins.open + + def fake_open(path, *a, **k): + if str(path) == "/proc/1/environ": + from io import BytesIO + + return BytesIO(b"container=podman\x00PATH=/usr/bin") + return real_open(path, *a, **k) + + monkeypatch.setattr(builtins, "open", fake_open) + assert r._is_running_in_container() is True + + def test_is_running_in_container_false(self, tmp_path, monkeypatch): + r = self._make(tmp_path) + monkeypatch.setattr(Path, "exists", lambda self: False) + + import builtins + + def fake_open(path, *a, **k): + if str(path) == "/proc/1/environ": + raise OSError("no proc") + raise OSError("blocked") + + monkeypatch.setattr(builtins, "open", fake_open) + assert r._is_running_in_container() is False + + def test_run_in_container_raises(self, tmp_path, monkeypatch): + r = self._make(tmp_path) + monkeypatch.setattr(r, "_prepare_ip_artifacts", lambda: None) + monkeypatch.setattr(r, "_is_running_in_container", lambda: True) + # Avoid actually writing a host script / chmod. + monkeypatch.setattr( + r, + "_run_vivado_on_host", + lambda: (_ for _ in ()).throw(VivadoIntegrationError("Container detected")), + ) + with pytest.raises(VivadoIntegrationError): + r.run() + + def test_run_success_path(self, tmp_path, monkeypatch): + out_dir = tmp_path / "out" + out_dir.mkdir() + r = self._make(tmp_path, output_dir=out_dir) + monkeypatch.setattr(r, "_prepare_ip_artifacts", lambda: None) + monkeypatch.setattr(r, "_is_running_in_container", lambda: False) + + build_tcl = out_dir / "build.tcl" + build_tcl.write_text("# tcl") + + # Patch the dynamically-imported integration + reporting functions on + # their real modules. + import pcileechfwgenerator.vivado_handling.pcileech_build_integration as pbi + + monkeypatch.setattr(pbi, "integrate_pcileech_build", lambda *a, **k: build_tcl) + monkeypatch.setattr( + rep_mod, + "run_vivado_with_error_reporting", + lambda *a, **k: (0, "ok"), + ) + + # Should not raise. + assert r.run() is None + + def test_run_nonzero_raises(self, tmp_path, monkeypatch): + out_dir = tmp_path / "out" + out_dir.mkdir() + r = self._make(tmp_path, output_dir=out_dir) + monkeypatch.setattr(r, "_prepare_ip_artifacts", lambda: None) + monkeypatch.setattr(r, "_is_running_in_container", lambda: False) + + build_tcl = out_dir / "build.tcl" + build_tcl.write_text("# tcl") + + import pcileechfwgenerator.vivado_handling.pcileech_build_integration as pbi + + monkeypatch.setattr(pbi, "integrate_pcileech_build", lambda *a, **k: build_tcl) + monkeypatch.setattr( + rep_mod, + "run_vivado_with_error_reporting", + lambda *a, **k: (1, "/tmp/err.txt"), + ) + + with pytest.raises(VivadoIntegrationError): + r.run() + + def test_run_falls_back_to_generated_script_when_integration_fails( + self, tmp_path, monkeypatch + ): + out_dir = tmp_path / "out" + out_dir.mkdir() + r = self._make(tmp_path, output_dir=out_dir) + monkeypatch.setattr(r, "_prepare_ip_artifacts", lambda: None) + monkeypatch.setattr(r, "_is_running_in_container", lambda: False) + + # Fallback script must exist on disk. + (out_dir / "vivado_build.tcl").write_text("# fallback") + + import pcileechfwgenerator.vivado_handling.pcileech_build_integration as pbi + + def _boom(*a, **k): + raise RuntimeError("integration unavailable") + + monkeypatch.setattr(pbi, "integrate_pcileech_build", _boom) + captured = {} + + def _runner(build_tcl, output_dir, exe, *a, **k): + captured["tcl"] = build_tcl + return (0, "ok") + + monkeypatch.setattr(rep_mod, "run_vivado_with_error_reporting", _runner) + + assert r.run() is None + assert captured["tcl"] == out_dir / "vivado_build.tcl" + + def test_run_fallback_missing_script_raises(self, tmp_path, monkeypatch): + out_dir = tmp_path / "out" + out_dir.mkdir() + r = self._make(tmp_path, output_dir=out_dir) + monkeypatch.setattr(r, "_prepare_ip_artifacts", lambda: None) + monkeypatch.setattr(r, "_is_running_in_container", lambda: False) + + import pcileechfwgenerator.vivado_handling.pcileech_build_integration as pbi + + def _boom(*a, **k): + raise RuntimeError("integration unavailable") + + monkeypatch.setattr(pbi, "integrate_pcileech_build", _boom) + # No vivado_build.tcl on disk -> should raise. + with pytest.raises(VivadoIntegrationError): + r.run() + + def test_prepare_ip_artifacts_swallows_errors(self, tmp_path, monkeypatch): + r = self._make(tmp_path) + + def _boom(*a, **k): + raise RuntimeError("repair failed") + + monkeypatch.setattr(runner_mod, "repair_ip_artifacts", _boom) + # Best-effort: should not raise. + r._prepare_ip_artifacts() + + +# ══════════════════════════════════════════════════════════════════ +# vivado_runner: create_vivado_runner factory +# ══════════════════════════════════════════════════════════════════ + + +class TestCreateVivadoRunner: + def test_factory_returns_runner(self, tmp_path): + r = create_vivado_runner( + board="pcileech_75t", + output_dir=tmp_path / "out", + vivado_path="/tools/Xilinx/2024.1/Vivado", + ) + assert isinstance(r, VivadoRunner) + assert r.board == "pcileech_75t" + assert r.vivado_version == "2024.1" + + def test_factory_passes_device_config(self, tmp_path): + cfg = {"vendor_id": "0x10ee"} + r = create_vivado_runner( + board="b", + output_dir=tmp_path / "out", + vivado_path="/opt/Vivado", + device_config=cfg, + ) + assert r.device_config == cfg diff --git a/tests/tui/test_security_features.py b/tests/tui/test_security_features.py index 5a9b5c09..69fb7cc2 100644 --- a/tests/tui/test_security_features.py +++ b/tests/tui/test_security_features.py @@ -6,17 +6,12 @@ """ import asyncio -import os -import sys import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -# Add the src directory to the path for imports -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) - from pcileechfwgenerator.tui.utils.input_validator import InputValidator -from pcileechfwgenerator.tui.utils.privilege_manager import PrivilegeManager +from pcileechfwgenerator.utils.privilege_manager import PrivilegeManager class TestInputValidator(unittest.TestCase):