diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..937f5bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.tox +.venv +build +dist +crossplane.egg-info +tests +ext +__pycache__ +*.pyc +*.pyo +*.pyd + diff --git a/.github/workflows/crossplane-ci.yml b/.github/workflows/crossplane-ci.yml index 1a2db25..9e2366d 100644 --- a/.github/workflows/crossplane-ci.yml +++ b/.github/workflows/crossplane-ci.yml @@ -3,6 +3,13 @@ name: Crossplane CI on: workflow_dispatch: push: + branches: + - master + paths-ignore: + - '**.md' + pull_request: + branches: + - master paths-ignore: - '**.md' @@ -12,15 +19,40 @@ jobs: strategy: matrix: - python-version: ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10", pypy-3.6, pypy-3.7, pypy-3.8, pypy-3.9] + # Keep this matrix to runtimes supported by GitHub-hosted runners. + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Run tox under Python ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install pytest + - name: Run tests + run: | + python -m pytest -q + + test-py36: + name: Tests (Python 3.6) + # GitHub-hosted ubuntu-20.04 runners may be unavailable/deprecated. + # Run Py3.6 tests via a container on a supported runner instead. + runs-on: ubuntu-latest + container: + image: python:3.6-buster + + steps: + - uses: actions/checkout@v4 + - name: Install dependencies (Python 3.6) + run: | + python -m pip install --upgrade "pip<22" "setuptools<60" wheel + python -m pip install -e . + python -m pip install "pytest<8" + - name: Run tests (Python 3.6) run: | - pip install tox - tox -e py + python -m pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3f2bd6f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,179 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install pytest + - name: Run tests + run: | + python -m pytest -q + + test-py36: + name: Tests (Python 3.6) + # GitHub-hosted ubuntu-20.04 runners may be unavailable/deprecated. + # Run Py3.6 tests via a container on a supported runner instead. + runs-on: ubuntu-latest + container: + image: python:3.6-buster + + steps: + - uses: actions/checkout@v4 + - name: Install dependencies (Python 3.6) + run: | + python -m pip install --upgrade "pip<22" "setuptools<60" wheel + python -m pip install -e . + python -m pip install "pytest<8" + - name: Run tests (Python 3.6) + run: | + python -m pytest -q + + build: + name: Build distributions + runs-on: ubuntu-latest + needs: [test, test-py36] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build distributions + run: | + python -m pip install --upgrade pip build + python -m build + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [build] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [github-release] + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Upload to PyPI (twine) + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m pip install --upgrade twine + python -m twine upload --non-interactive dist/* + + docker: + name: Build & push Docker image + runs-on: ubuntu-latest + needs: [github-release] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + packages: write + + env: + DOCKERHUB_IMAGE_NAME: ngxparse + GHCR_IMAGE_NAME: ngxparse + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GitHub Container Registry (GHCR) + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKERHUB_IMAGE_NAME }} + ghcr.io/${{ github.repository_owner }}/${{ env.GHCR_IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7005561 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,67 @@ +# Pre-commit hooks for crossplane (mirrors ~/Projects/gixy style) +# See https://pre-commit.com for more information + +repos: + # Standard pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: debug-statements + + # Ruff for linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.9 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + # crossplane is a legacy codebase with Python 2 compatibility shims; + # limit ruff to scripts to avoid a massive reformat/lint-only migration. + files: ^scripts/.*\.py$ + - id: ruff-format + files: ^scripts/.*\.py$ + + # Bandit for security scanning + - repo: https://github.com/PyCQA/bandit + rev: 1.7.10 + hooks: + - id: bandit + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] + # Bandit is useful on our own scripts; the main package has known + # false positives (e.g. B105 on token buffers) and legacy patterns. + files: ^scripts/.*\.py$ + + # Local hooks + - repo: local + hooks: + # Check for hardcoded IPs (similar to SonarCloud S1313) + - id: check-hardcoded-ips + name: Check for hardcoded IP addresses + description: Catches hardcoded IPs that SonarCloud would flag + entry: python3 scripts/check_hardcoded_ips.py + language: system + types: [python] + + # Run full tests on Python 3.6 before commit (mirrors gixy) + - id: pytest-py36 + name: Run tests on Python 3.6 + description: Full test suite on minimum supported Python + # Use CROSSPLANE_PY36 to override the interpreter path if needed. + entry: bash -c '"${CROSSPLANE_PY36:-${PYENV_ROOT:-$HOME/.pyenv}/versions/3.6.15/bin/python}" -m pytest tests/ -q --tb=line' + language: system + pass_filenames: false + files: ^(crossplane/.*\.py|tests/.*\.py)$ + stages: [pre-commit] + + # Prevent committing hardcoded absolute local paths (e.g. /Users//...) + - id: check-absolute-paths + name: Check for hardcoded absolute paths + description: Prevent committing absolute dev-machine paths (macOS/Linux/Windows home dirs) + entry: python3 scripts/check_absolute_paths.py + language: system + files: \.(py|ya?ml|toml)$ diff --git a/AUTHORS.rst b/AUTHORS.rst index 37e8961..11946a8 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -14,4 +14,4 @@ Contributors * Ivan Poluyanov `@poluyanov `_ * Raymond Lau `@Raymond26 `_ * Luca Comellini `@lucacome `_ -* Ron Vider `@RonVider `_ \ No newline at end of file +* Ron Vider `@RonVider `_ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5403a6c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [0.5.15] - 2026-01-29 + +### Changed +- PyPI package renamed from `nginx-crossplane` to `ngxparse` for a shorter name that doesn't imply nginx is bundled. +- Docker image names changed to `ngxparse` on both Docker Hub and GHCR. + +### Migration +```bash +pip uninstall nginx-crossplane +pip install ngxparse +# import crossplane # unchanged +# crossplane parse # CLI unchanged +``` + +## [0.5.8] - 2025-12-26 + +### Added +- GitHub Actions release workflow gated on tests that: + - builds and attaches `sdist`/`wheel` artifacts to a GitHub Release + - publishes the distribution to PyPI + - builds and pushes a multi-arch Docker image running the `crossplane` CLI + +### Changed +- PyPI distribution name differs from the import/CLI name (`crossplane`). +- Packaging no longer imports `crossplane` during builds to avoid import-time side effects. + +## [0.5.9] - 2025-12-26 + +### Changed +- Renamed PyPI distribution to `nginx-crossplane` (because `crossplane-ng` is already taken by another fork: `https://github.com/qosmio/crossplane`). + +## [0.5.10] - 2025-12-26 + +### Fixed +- Packaging: fix metadata parsing in `setup.py` so editable installs work in CI (PEP 517/660). + +## [0.5.11] - 2025-12-26 + +### Fixed +- CI: run Python 3.6 tests in a `python:3.6` container on `ubuntu-latest` (avoid `ubuntu-20.04` runner queue/hangs). + +## [0.5.12] - 2025-12-26 + +### Fixed +- CI/Packaging: `pyproject.toml` build-system requirements now use env markers so Python 3.6 doesn't try to install `setuptools>=61`. + +## [0.5.13] - 2025-12-26 + +### Added +- Docker image publishing to GitHub Container Registry (GHCR) in addition to Docker Hub. + +## [0.5.14] - 2025-12-26 + +### Changed +- GHCR image name is now `ghcr.io/dvershinin/crossplane` (Docker Hub remains `${DOCKER_USERNAME}/nginx-crossplane`). + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2d9ce8c..bc3c7d3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -72,4 +72,4 @@ members of the project's leadership. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html -[homepage]: https://www.contributor-covenant.org \ No newline at end of file +[homepage]: https://www.contributor-covenant.org diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..906c523 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Install the package from this repository (at the tagged commit) +COPY pyproject.toml setup.py README.md /app/ +COPY crossplane /app/crossplane + +RUN python -m pip install --no-cache-dir --upgrade pip \ + && python -m pip install --no-cache-dir . + +ENTRYPOINT ["crossplane"] + + diff --git a/NOTICE b/NOTICE index dd859f0..ba54737 100644 --- a/NOTICE +++ b/NOTICE @@ -15,4 +15,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - diff --git a/README.md b/README.md index dd889b4..62c7c6f 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@

- - + +

- [Install](#install) @@ -18,8 +18,10 @@ - [crossplane minify](#crossplane-minify) - [Python Module](#python-module) - [crossplane.parse()](#crossplaneparse) + - [crossplane.parse_string()](#crossplaneparse_string) - [crossplane.build()](#crossplanebuild) - [crossplane.lex()](#crossplanelex) + - [crossplane.lex_string()](#crossplanelex_string) - [Other Languages](#other-languages) ## Install @@ -28,7 +30,13 @@ You can install both the [Command Line Interface](#command-line-interface) and [Python Module](#python-module) via: - pip install crossplane + pip install ngxparse + +The import name remains `crossplane`: + +```python +import crossplane +``` ## Command Line Interface @@ -469,8 +477,8 @@ optional arguments: ## Python Module In addition to the command line tool, you can import `crossplane` as a -python module. There are two basic functions that the module will -provide you: `parse` and `lex`. +python module. There are four basic functions that the module will +provide you: `parse`, `parse_string`, `lex`, and `lex_string`. ### crossplane.parse() @@ -483,6 +491,23 @@ This will return the same payload as described in the [crossplane parse](#crossplane-parse) section, except it will be Python dicts and not one giant JSON string. +### crossplane.parse_string() + +```python +import crossplane +text = """ +events {} +http { + include conf.d/*.conf; +} +""" +payload = crossplane.parse_string(text, filename='/etc/nginx/nginx.conf') +``` + +Parses configuration provided as a string. If you pass `filename`, relative include +patterns are resolved against its directory, and error messages reference it. Options +mirror `crossplane.parse`. + ### crossplane.build() ```python @@ -515,6 +540,17 @@ will result in a long list similar to what you can see in the is used, except it will obviously be a Python list of tuples and not one giant JSON string. +### crossplane.lex_string() + +```python +import crossplane +text = "events { worker_connections 1024; }" +tokens = list(crossplane.lex_string(text, filename='')) +``` + +Lexes tokens from a configuration string. The optional `filename` is used for error +reporting when brace-balance errors are detected. + ## Other Languages - Go port by [@aluttik](https://github.com/aluttik): diff --git a/crossplane/__init__.py b/crossplane/__init__.py index 6a69217..3d94ae4 100644 --- a/crossplane/__init__.py +++ b/crossplane/__init__.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- -from .parser import parse -from .lexer import lex +from .parser import parse, parse_string +from .lexer import lex, lex_string from .builder import build from .formatter import format from .ext.lua import LuaBlockPlugin -__all__ = ['parse', 'lex', 'build', 'format'] +__all__ = ['parse', 'parse_string', 'lex', 'lex_string', 'build', 'format'] __title__ = 'crossplane' __summary__ = 'Reliable and fast NGINX configuration file parser.' __url__ = 'https://github.com/nginxinc/crossplane' -__version__ = '0.5.7' +__version__ = '0.5.16' __author__ = 'Arie van Luttikhuizen' __email__ = 'aluttik@gmail.com' diff --git a/crossplane/analyzer.py b/crossplane/analyzer.py index e9d0e8e..f743f33 100644 --- a/crossplane/analyzer.py +++ b/crossplane/analyzer.py @@ -67,7 +67,7 @@ Since some directives can have different behaviors in different contexts, we use lists of bit masks, each describing a valid way to use the directive. -Definitions for directives that're available in the open source version of +Definitions for directives that're available in the open source version of nginx were taken directively from the source code. In fact, the variable names for the bit masks defined above were taken from the nginx source code. @@ -2110,6 +2110,9 @@ ('http', 'location', 'limit_except'): NGX_HTTP_LMT_CONF } +# contexts where arbitrary directive names are allowed (map keys, MIME types, etc.) +FREEFORM_CONTEXTS = {'map', 'types', 'charset_map', 'geo'} + def enter_block_ctx(stmt, ctx): # don't nest because NGX_HTTP_LOC_CONF just means "location block in http" @@ -2126,6 +2129,11 @@ def analyze(fname, stmt, term, ctx=(), strict=False, check_ctx=True, directive = stmt['directive'] line = stmt['line'] + # skip analysis for directives inside freeform contexts (map, types, etc.) + # where arbitrary directive names are allowed as mapping keys or MIME types + if ctx and ctx[-1] in FREEFORM_CONTEXTS: + return + # if strict and directive isn't recognized then throw error if strict and directive not in DIRECTIVES: reason = 'unknown directive "%s"' % directive diff --git a/crossplane/builder.py b/crossplane/builder.py index 049d224..11a9ea4 100644 --- a/crossplane/builder.py +++ b/crossplane/builder.py @@ -72,6 +72,41 @@ def _enquote(arg): def build(payload, indent=4, tabs=False, header=False): + """ + Builds an nginx config string from a parsed payload. + + Supported inputs: + - A list of Directive objects (the traditional API) + - The full payload returned by crossplane.parse()/parse_string() + - The payload['config'] list returned by crossplane.parse()/parse_string() + + Note: if a full parse payload contains multiple config files, build() will + only build the first config's parsed directives (i.e. payload['config'][0]['parsed']). + Use build_files() if you need to write multiple files. + """ + # Allow passing the full parse payload (issue #110) or payload['config'] + if isinstance(payload, dict) and 'config' in payload: + configs = payload.get('config') or [] + payload = (configs[0].get('parsed') or []) if configs else [] + elif (isinstance(payload, list) and payload and isinstance(payload[0], dict) and + 'parsed' in payload[0] and 'directive' not in payload[0]): + # Looks like payload['config'] (list of Config objects) + payload = payload[0].get('parsed') or [] + + payload = payload or [] + if not isinstance(payload, list): + raise TypeError( + 'crossplane.build() expects a list of directives, or a parse payload ' + '(dict with key "config").' + ) + + if payload and (not isinstance(payload[0], dict) or 'directive' not in payload[0]): + raise TypeError( + 'crossplane.build() expected a list of directive objects (dicts with at least a ' + '"directive" key, e.g. items from a "parsed" list), but found list elements of an ' + 'unexpected shape.' + ) + padding = '\t' if tabs else ' ' * indent head = '' diff --git a/crossplane/ext/lua.py b/crossplane/ext/lua.py index 0bdb367..8d0e9cb 100644 --- a/crossplane/ext/lua.py +++ b/crossplane/ext/lua.py @@ -110,11 +110,14 @@ def lex(self, char_iterator, directive): token += quote char, line = next(char_iterator) while char != quote: - token += quote if char == quote else char + if char == '\\': + token += char + char, line = next(char_iterator) + token += char char, line = next(char_iterator) if depth < 0: - reason = 'unxpected "}"' + reason = 'unexpected "}"' raise LuaBlockParserSyntaxError(reason, filename=None, lineno=line) if depth == 0: diff --git a/crossplane/lexer.py b/crossplane/lexer.py index 2db9c6e..093705e 100644 --- a/crossplane/lexer.py +++ b/crossplane/lexer.py @@ -27,7 +27,7 @@ def _iterlinecount(iterable): @fix_pep_479 -def _lex_file_object(file_obj): +def _lex_file_object(file_obj, filename=None): """ Generates token tuples from an nginx config file object @@ -57,13 +57,23 @@ def _lex_file_object(file_obj): # disregard until char isn't a whitespace character while char.isspace(): - char, line = next(it) + try: + char, line = next(it) + except StopIteration: + return # if starting comment if not token and char == '#': - while not char.endswith('\n'): + while True: + if char.endswith('\n'): + break token = token + char - char, _ = next(it) + try: + char, _ = next(it) + except StopIteration: + # comment ended at EOF without a trailing newline + yield (token, line, False) + return yield (token, line, False) token = '' continue @@ -76,7 +86,16 @@ def _lex_file_object(file_obj): next_token_is_directive = False while token[-1] != '}' and not char.isspace(): token += char - char, line = next(it) + try: + char, line = next(it) + except StopIteration: + reason = 'unexpected end of file, expecting "}"' + raise NgxParserSyntaxError(reason, filename, token_line) + # After closing brace, check if whitespace ends this token + if char.isspace(): + yield (token, token_line, False) + token = '' + continue # if a quote is found, add the whole string to the token buffer if char in ('"', "'"): @@ -86,10 +105,19 @@ def _lex_file_object(file_obj): continue quote = char - char, line = next(it) + try: + char, line = next(it) + except StopIteration: + reason = 'unexpected end of file, expecting "%s"' % quote + raise NgxParserSyntaxError(reason, filename, token_line) + while char != quote: token += quote if char == '\\' + quote else char - char, line = next(it) + try: + char, line = next(it) + except StopIteration: + reason = 'unexpected end of file, expecting "%s"' % quote + raise NgxParserSyntaxError(reason, filename, token_line) yield (token, token_line, True) # True because this is in quotes @@ -119,6 +147,10 @@ def _lex_file_object(file_obj): # append char to the token buffer token += char + # flush the final token at EOF (e.g. no trailing whitespace/newline) + if token: + yield (token, token_line, False) + def _balance_braces(tokens, filename=None): """Raises syntax errors if braces aren't balanced""" @@ -146,7 +178,7 @@ def _balance_braces(tokens, filename=None): def lex(filename): """Generates tokens from an nginx config file""" with io.open(filename, mode='r', encoding='utf-8', errors='replace') as f: - it = _lex_file_object(f) + it = _lex_file_object(f, filename=filename) it = _balance_braces(it, filename) for token, line, quoted in it: yield (token, line, quoted) @@ -155,3 +187,16 @@ def lex(filename): def register_external_lexer(directives, lexer): for directive in directives: EXTERNAL_LEXERS[directive] = lexer + + +def lex_string(text, filename=None): + """Generates tokens from an nginx config string. + + :param text: configuration text to lex + :param filename: optional filename to use in error reporting + """ + f = io.StringIO(text) + it = _lex_file_object(f, filename=filename) + it = _balance_braces(it, filename) + for token, line, quoted in it: + yield (token, line, quoted) diff --git a/crossplane/parser.py b/crossplane/parser.py index 09268c8..5433883 100644 --- a/crossplane/parser.py +++ b/crossplane/parser.py @@ -2,7 +2,7 @@ import glob import os -from .lexer import lex +from .lexer import lex, lex_string from .analyzer import analyze, enter_block_ctx from .errors import NgxParserDirectiveError @@ -22,38 +22,22 @@ def _prepare_if_args(stmt): args[:] = args[start:end] -def parse(filename, onerror=None, catch_errors=True, ignore=(), single=False, +def _parse_with_initial_tokens(initial_tokens, initial_file, config_dir, + onerror=None, catch_errors=True, ignore=(), single=False, comments=False, strict=False, combine=False, check_ctx=True, check_args=True): - """ - Parses an nginx config file and returns a nested dict payload - - :param filename: string contianing the name of the config file to parse - :param onerror: function that determines what's saved in "callback" - :param catch_errors: bool; if False, parse stops after first error - :param ignore: list or tuple of directives to exclude from the payload - :param combine: bool; if True, use includes to create a single config obj - :param single: bool; if True, including from other files doesn't happen - :param comments: bool; if True, including comments to json payload - :param strict: bool; if True, unrecognized directives raise errors - :param check_ctx: bool; if True, runs context analysis on directives - :param check_args: bool; if True, runs arg count analysis on directives - :returns: a payload that describes the parsed nginx config - """ - config_dir = os.path.dirname(filename) - payload = { 'status': 'ok', 'errors': [], 'config': [], } - # start with the main nginx config file/context - includes = [(filename, ())] # stores (filename, config context) tuples - included = {filename: 0} # stores {filename: array index} map + # start with the main nginx config context + includes = [(initial_file, ())] # stores (filename, config context) tuples + included = {initial_file: 0} # stores {filename: array index} map def _handle_error(parsing, e): - """Adds representaions of an error to the payload""" + """Adds representations of an error to the payload.""" file = parsing['file'] error = str(e) line = getattr(e, 'lineno', None) @@ -118,14 +102,22 @@ def _parse(parsing, tokens, ctx=(), consume=False): # parse arguments by reading tokens args = stmt['args'] - token, __, quoted = next(tokens) # disregard line numbers of args - while token not in ('{', ';', '}') or quoted: + try: + token, __, quoted = next(tokens) # disregard line numbers of args + except StopIteration: + token, quoted = None, False + + while token is not None and (token not in ('{', ';', '}') or quoted): if token.startswith('#') and not quoted: comments_in_args.append(token[1:]) else: stmt['args'].append(token) - token, __, quoted = next(tokens) + try: + token, __, quoted = next(tokens) + except StopIteration: + token, quoted = None, False + break # consume the directive if it is ignored and move on if stmt['directive'] in ignore: @@ -155,7 +147,7 @@ def _parse(parsing, tokens, ctx=(), consume=False): else: break - # keep on parsin' + # keep on parsing continue else: raise e @@ -176,7 +168,8 @@ def _parse(parsing, tokens, ctx=(), consume=False): try: # if the file pattern was explicit, nginx will check # that the included file can be opened and read - open(str(pattern)).close() + with open(str(pattern)): + pass fnames = [pattern] except Exception as e: fnames = [] @@ -186,13 +179,13 @@ def _parse(parsing, tokens, ctx=(), consume=False): else: raise e - for fname in fnames: + for include_file in fnames: # the included set keeps files from being parsed twice # TODO: handle files included from multiple contexts - if fname not in included: - included[fname] = len(includes) - includes.append((fname, ctx)) - index = included[fname] + if include_file not in included: + included[include_file] = len(includes) + includes.append((include_file, ctx)) + index = included[include_file] stmt['includes'].append(index) # if this statement terminated with '{' then it is a block @@ -215,10 +208,16 @@ def _parse(parsing, tokens, ctx=(), consume=False): return parsed # the includes list grows as "include" directives are found in _parse - for fname, ctx in includes: - tokens = lex(fname) + for index, (fname, ctx) in enumerate(includes): + if index == 0: + tokens = initial_tokens + parsing_file = initial_file + else: + tokens = lex(fname) + parsing_file = fname + parsing = { - 'file': fname, + 'file': parsing_file, 'status': 'ok', 'errors': [], 'parsed': [] @@ -236,6 +235,65 @@ def _parse(parsing, tokens, ctx=(), consume=False): return payload +def parse(filename, onerror=None, catch_errors=True, ignore=(), single=False, + comments=False, strict=False, combine=False, check_ctx=True, + check_args=True): + """ + Parses an nginx config file and returns a nested dict payload + + :param filename: string containing the name of the config file to parse + :param onerror: function that determines what's saved in "callback" + :param catch_errors: bool; if False, parse stops after first error + :param ignore: list or tuple of directives to exclude from the payload + :param combine: bool; if True, use includes to create a single config obj + :param single: bool; if True, including from other files doesn't happen + :param comments: bool; if True, including comments to json payload + :param strict: bool; if True, unrecognized directives raise errors + :param check_ctx: bool; if True, runs context analysis on directives + :param check_args: bool; if True, runs arg count analysis on directives + :returns: a payload that describes the parsed nginx config + """ + config_dir = os.path.dirname(filename) + initial_tokens = lex(filename) + return _parse_with_initial_tokens( + initial_tokens, filename, config_dir, + onerror=onerror, catch_errors=catch_errors, ignore=ignore, + single=single, comments=comments, strict=strict, combine=combine, + check_ctx=check_ctx, check_args=check_args + ) + + +def parse_string(text, filename=None, onerror=None, catch_errors=True, ignore=(), + single=False, comments=False, strict=False, combine=False, check_ctx=True, + check_args=True): + """ + Parses an nginx config provided as a string and returns a nested dict payload + + :param text: string containing the nginx config to parse + :param filename: optional filename used for error messages and include base + :param onerror: function that determines what's saved in "callback" + :param catch_errors: bool; if False, parse stops after first error + :param ignore: list or tuple of directives to exclude from the payload + :param combine: bool; if True, use includes to create a single config obj + :param single: bool; if True, including from other files doesn't happen + :param comments: bool; if True, including comments to json payload + :param strict: bool; if True, unrecognized directives raise errors + :param check_ctx: bool; if True, runs context analysis on directives + :param check_args: bool; if True, runs arg count analysis on directives + :returns: a payload that describes the parsed nginx config + """ + # resolve base directory for relative include paths + base_filename = filename if filename is not None else '' + config_dir = os.path.dirname(os.path.abspath(filename)) if filename else os.getcwd() + initial_tokens = lex_string(text, filename=filename) + return _parse_with_initial_tokens( + initial_tokens, base_filename, config_dir, + onerror=onerror, catch_errors=catch_errors, ignore=ignore, + single=single, comments=comments, strict=strict, combine=combine, + check_ctx=check_ctx, check_args=check_args + ) + + def _combine_parsed_configs(old_payload): """ Combines config files into one by using include directives. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..78b458a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +# Python 3.6 cannot install modern setuptools (>=61). Use env markers so +# PEP517/660 editable installs work across the supported test matrix. +requires = [ + "setuptools<60; python_version<'3.7'", + "setuptools>=61; python_version>='3.7'", + "wheel", +] +build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 88 +target-version = "py37" +extend-exclude = [ + ".venv", + ".tox", + "dist", + "build", +] + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.bandit] +exclude_dirs = ["tests"] +skips = ["B105"] diff --git a/scripts/check_absolute_paths.py b/scripts/check_absolute_paths.py new file mode 100644 index 0000000..23faf7c --- /dev/null +++ b/scripts/check_absolute_paths.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +Fail if files contain obvious hardcoded absolute local paths. + +This is intentionally conservative and only run on code/config files via +pre-commit's `files:` filter. +""" + +import io +import re +import sys + + +PATTERNS = [ + re.compile(r"/Users/[^\\s'\"]+"), + re.compile(r"\\b[A-Za-z]:\\\\Users\\\\[^\\s'\"]+"), +] + + +def main(argv): + paths = argv[1:] + violations = [] + + for path in paths: + try: + with io.open(path, "r", encoding="utf-8", errors="replace") as f: + for lineno, line in enumerate(f, 1): + # Don't flag examples in comments. + if line.lstrip().startswith("#"): + continue + for rx in PATTERNS: + m = rx.search(line) + if m: + violations.append((path, lineno, m.group(0).strip())) + except OSError: + continue + + if violations: + for path, lineno, match in violations: + sys.stderr.write("%s:%d: hardcoded absolute path: %s\n" % (path, lineno, match)) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) + + diff --git a/scripts/check_hardcoded_ips.py b/scripts/check_hardcoded_ips.py new file mode 100644 index 0000000..20c12f8 --- /dev/null +++ b/scripts/check_hardcoded_ips.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" +Fail if Python files contain hardcoded IPv4 addresses. + +Mirrors the intent of the gixy pre-commit hook (SonarCloud S1313-like). +""" + +import re +import sys + +IP_RE = re.compile(r"(?server but not in http directly + stmt = {'directive': 'listen', 'args': ['80'], 'line': 1} + ctx = ('http',) + with pytest.raises(NgxParserDirectiveContextError) as exc_info: + analyze(fname, stmt, term=';', ctx=ctx) + assert 'is not allowed here' in exc_info.value.strerror + + def test_directive_correct_context(self): + """Test that directive in correct context passes.""" + fname = '/path/to/nginx.conf' + stmt = {'directive': 'listen', 'args': ['80'], 'line': 1} + ctx = ('http', 'server') + # Should not raise + analyze(fname, stmt, term=';', ctx=ctx) + + def test_check_ctx_disabled(self): + """Test that context checking can be disabled.""" + fname = '/path/to/nginx.conf' + # 'listen' in wrong context, but check_ctx=False + stmt = {'directive': 'listen', 'args': ['80'], 'line': 1} + ctx = ('http',) + # Should not raise when check_ctx=False + analyze(fname, stmt, term=';', ctx=ctx, check_ctx=False) + + def test_check_args_disabled(self): + """Test that argument checking can be disabled.""" + fname = '/path/to/nginx.conf' + stmt = {'directive': 'listen', 'args': [], 'line': 1} # Missing required arg + ctx = ('http', 'server') + # Should not raise when check_args=False + analyze(fname, stmt, term=';', ctx=ctx, check_args=False) diff --git a/tests/test_build.py b/tests/test_build.py index eff3d79..2b3e72b 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -226,6 +226,37 @@ def test_build_multiple_comments_on_one_line(): assert built == '#comment1\nuser root; #comment2 #comment3' +def test_build_accepts_parse_payload_dict(): + parsed_payload = crossplane.parse_string('user nginx;', filename='nginx.conf') + built = crossplane.build(parsed_payload, indent=4, tabs=False) + assert built == 'user nginx;' + + +def test_build_accepts_config_list(): + parsed_payload = crossplane.parse_string('user nginx;', filename='nginx.conf') + built = crossplane.build(parsed_payload['config'], indent=4, tabs=False) + assert built == 'user nginx;' + + +def test_build_accepts_empty_parse_payload_dict(): + built = crossplane.build({'config': []}, indent=4, tabs=False) + assert built == '' + + +def test_build_accepts_config_list_with_none_parsed(): + built = crossplane.build([{'file': 'nginx.conf', 'parsed': None}], indent=4, tabs=False) + assert built == '' + + +def test_build_rejects_list_with_wrong_shape(): + try: + crossplane.build([{'foo': 'bar'}], indent=4, tabs=False) + except TypeError as e: + assert 'unexpected shape' in str(e) + else: + assert False, 'expected TypeError' + + def test_build_files_with_missing_status_and_errors(tmpdir): assert len(tmpdir.listdir()) == 0 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a8f8a5e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +"""Tests for crossplane CLI (__main__.py).""" +import io +import os +import sys + +import pytest + +from crossplane.__main__ import ( + parse_args, + parse, + build, + lex, + minify, + format, + _dump_payload, + _prompt_yes, +) + +here = os.path.dirname(__file__) + + +class TestParseArgs: + """Tests for argument parsing.""" + + def test_parse_command(self): + """Test parse subcommand argument parsing.""" + args = parse_args(['parse', 'nginx.conf']) + assert args._subcommand.__name__ == 'parse' + assert args.filename == 'nginx.conf' + assert args.out is None + assert args.indent is None + assert args.catch is True + assert args.single is False + assert args.comments is False + assert args.strict is False + assert args.combine is False + + def test_parse_command_with_options(self): + """Test parse subcommand with various options.""" + args = parse_args([ + 'parse', 'nginx.conf', + '--out', 'output.json', + '--indent', '2', + '--no-catch', + '--single-file', + '--include-comments', + '--strict', + '--combine', + '--ignore', 'ssl_certificate,ssl_key' + ]) + assert args.out == 'output.json' + assert args.indent == 2 + assert args.catch is False + assert args.single is True + assert args.comments is True + assert args.strict is True + assert args.combine is True + assert args.ignore == 'ssl_certificate,ssl_key' + + def test_build_command(self): + """Test build subcommand argument parsing.""" + args = parse_args(['build', 'payload.json']) + assert args._subcommand.__name__ == 'build' + assert args.filename == 'payload.json' + assert args.dirname is None + assert args.force is False + assert args.indent == 4 + assert args.tabs is False + assert args.header is True + assert args.stdout is False + assert args.verbose is False + + def test_build_command_with_options(self): + """Test build subcommand with various options.""" + args = parse_args([ + 'build', 'payload.json', + '--dir', '/tmp/nginx', + '--force', + '--indent', '2', + '--no-headers', + '--stdout', + '--verbose' + ]) + assert args.dirname == '/tmp/nginx' + assert args.force is True + assert args.indent == 2 + assert args.header is False + assert args.stdout is True + assert args.verbose is True + + def test_build_command_tabs_option(self): + """Test build subcommand with tabs option.""" + args = parse_args(['build', 'payload.json', '--tabs']) + assert args.tabs is True + + def test_lex_command(self): + """Test lex subcommand argument parsing.""" + args = parse_args(['lex', 'nginx.conf']) + assert args._subcommand.__name__ == 'lex' + assert args.filename == 'nginx.conf' + assert args.out is None + assert args.indent is None + assert args.line_numbers is False + + def test_lex_command_with_options(self): + """Test lex subcommand with various options.""" + args = parse_args([ + 'lex', 'nginx.conf', + '--out', 'tokens.json', + '--indent', '4', + '--line-numbers' + ]) + assert args.out == 'tokens.json' + assert args.indent == 4 + assert args.line_numbers is True + + def test_minify_command(self): + """Test minify subcommand argument parsing.""" + args = parse_args(['minify', 'nginx.conf']) + assert args._subcommand.__name__ == 'minify' + assert args.filename == 'nginx.conf' + assert args.out is None + + def test_minify_command_with_output(self): + """Test minify subcommand with output option.""" + args = parse_args(['minify', 'nginx.conf', '--out', 'minified.conf']) + assert args.out == 'minified.conf' + + def test_format_command(self): + """Test format subcommand argument parsing.""" + args = parse_args(['format', 'nginx.conf']) + assert args._subcommand.__name__ == 'format' + assert args.filename == 'nginx.conf' + assert args.out is None + assert args.indent == 4 + assert args.tabs is False + + def test_format_command_with_options(self): + """Test format subcommand with various options.""" + args = parse_args([ + 'format', 'nginx.conf', + '--out', 'formatted.conf', + '--indent', '2' + ]) + assert args.out == 'formatted.conf' + assert args.indent == 2 + + def test_format_command_with_tabs(self): + """Test format subcommand with tabs option.""" + args = parse_args(['format', 'nginx.conf', '--tabs']) + assert args.tabs is True + + def test_help_command(self): + """Test help subcommand argument parsing.""" + args = parse_args(['help', 'parse']) + assert args._subcommand.__name__ == 'help' + assert args.command == 'parse' + + def test_version_flag(self): + """Test --version flag.""" + with pytest.raises(SystemExit) as exc_info: + parse_args(['--version']) + assert exc_info.value.code == 0 + + def test_no_subcommand_error(self): + """Test error when no subcommand is provided.""" + with pytest.raises(SystemExit) as exc_info: + parse_args([]) + assert exc_info.value.code != 0 + + +class TestDumpPayload: + """Tests for _dump_payload function.""" + + def test_dump_payload_with_indent(self): + """Test JSON output with indentation.""" + fp = io.StringIO() + obj = {'key': 'value', 'list': [1, 2, 3]} + _dump_payload(obj, fp, indent=2) + output = fp.getvalue() + assert ' "key"' in output + assert output.endswith('\n') + + def test_dump_payload_without_indent(self): + """Test JSON output without indentation (minified).""" + fp = io.StringIO() + obj = {'key': 'value', 'list': [1, 2, 3]} + _dump_payload(obj, fp, indent=None) + output = fp.getvalue() + # Should be compact (no spaces after separators) + assert '{"key":"value"' in output + assert output.endswith('\n') + + +class TestCliParse: + """Tests for CLI parse command.""" + + def test_parse_to_file(self, tmp_path): + """Test parse writes to file when --out specified.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'output.json') + parse(config, out=output_file, catch=True) + assert os.path.exists(output_file) + with open(output_file) as f: + content = f.read() + assert 'status' in content + + def test_parse_with_indent(self, tmp_path): + """Test parse with indentation.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'output.json') + parse(config, out=output_file, indent=2, catch=True) + with open(output_file) as f: + content = f.read() + # Indented output should have newlines and spaces + assert '\n' in content + assert ' ' in content + + def test_parse_with_comments(self, tmp_path): + """Test parse with comments included.""" + config = os.path.join(here, 'configs', 'with-comments', 'nginx.conf') + output_file = str(tmp_path / 'output.json') + parse(config, out=output_file, catch=True, comments=True) + with open(output_file) as f: + content = f.read() + assert 'comment' in content + + +class TestCliLex: + """Tests for CLI lex command.""" + + def test_lex_to_file(self, tmp_path): + """Test lex writes to file.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'tokens.json') + lex(config, out=output_file, indent=None, line_numbers=False) + assert os.path.exists(output_file) + with open(output_file) as f: + content = f.read() + assert 'events' in content + assert 'http' in content + + def test_lex_with_line_numbers(self, tmp_path): + """Test lex command with line numbers.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'tokens.json') + lex(config, out=output_file, indent=None, line_numbers=True) + with open(output_file) as f: + content = f.read() + # With line numbers, output should be tuples like ["events", 1] + assert '[' in content + + +class TestCliMinify: + """Tests for CLI minify command.""" + + def test_minify_to_file(self, tmp_path): + """Test minify writes to file.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'minified.conf') + minify(config, out=output_file) + assert os.path.exists(output_file) + with open(output_file) as f: + content = f.read() + # Minified output should contain the directives + assert 'events' in content + + +class TestCliFormat: + """Tests for CLI format command.""" + + def test_format_to_file(self, tmp_path): + """Test format writes to file.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'formatted.conf') + format(config, out=output_file, indent=4, tabs=False) + assert os.path.exists(output_file) + with open(output_file) as f: + content = f.read() + assert 'events' in content + assert 'http' in content + + def test_format_with_tabs(self, tmp_path): + """Test format with tabs.""" + config = os.path.join(here, 'configs', 'cli-test', 'nginx.conf') + output_file = str(tmp_path / 'formatted.conf') + format(config, out=output_file, indent=4, tabs=True) + with open(output_file) as f: + content = f.read() + assert '\t' in content + + +class TestCliBuild: + """Tests for CLI build command.""" + + def test_build_creates_files(self, tmp_path): + """Test build creates nginx config files.""" + payload_file = os.path.join(here, 'configs', 'cli-test', 'payload.json') + build(payload_file, dirname=str(tmp_path), force=True, stdout=False) + assert os.path.exists(tmp_path / 'nginx.conf') + + def test_build_verbose(self, capsys, tmp_path): + """Test build verbose output.""" + payload_file = os.path.join(here, 'configs', 'cli-test', 'payload.json') + build(payload_file, dirname=str(tmp_path), force=True, verbose=True) + captured = capsys.readouterr() + assert 'wrote to' in captured.out + + +class TestPromptYes: + """Tests for _prompt_yes function.""" + + def test_prompt_yes_keyboard_interrupt(self, monkeypatch): + """Test that KeyboardInterrupt causes sys.exit(1).""" + def mock_input(prompt): + raise KeyboardInterrupt() + + monkeypatch.setattr('crossplane.__main__.input', mock_input) + with pytest.raises(SystemExit) as exc_info: + _prompt_yes() + assert exc_info.value.code == 1 + + def test_prompt_yes_eof_error(self, monkeypatch): + """Test that EOFError causes sys.exit(1).""" + def mock_input(prompt): + raise EOFError() + + monkeypatch.setattr('crossplane.__main__.input', mock_input) + with pytest.raises(SystemExit) as exc_info: + _prompt_yes() + assert exc_info.value.code == 1 + + def test_prompt_yes_returns_true(self, monkeypatch): + """Test that 'y' input returns True.""" + monkeypatch.setattr('crossplane.__main__.input', lambda prompt: 'y') + assert _prompt_yes() is True + + def test_prompt_yes_returns_true_uppercase(self, monkeypatch): + """Test that 'Y' input returns True.""" + monkeypatch.setattr('crossplane.__main__.input', lambda prompt: 'Y') + assert _prompt_yes() is True + + def test_prompt_yes_returns_true_with_extra(self, monkeypatch): + """Test that 'yes' input returns True.""" + monkeypatch.setattr('crossplane.__main__.input', lambda prompt: 'yes') + assert _prompt_yes() is True + + def test_prompt_no_returns_false(self, monkeypatch): + """Test that 'n' input returns False.""" + monkeypatch.setattr('crossplane.__main__.input', lambda prompt: 'n') + assert _prompt_yes() is False + + def test_prompt_empty_returns_false(self, monkeypatch): + """Test that empty input returns False.""" + monkeypatch.setattr('crossplane.__main__.input', lambda prompt: '') + assert _prompt_yes() is False diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..091fefd --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +"""Tests for crossplane exception classes (errors.py).""" +import pytest + +from crossplane.errors import ( + NgxParserBaseException, + NgxParserSyntaxError, + NgxParserDirectiveError, + NgxParserDirectiveArgumentsError, + NgxParserDirectiveContextError, + NgxParserDirectiveUnknownError, +) + + +class TestNgxParserBaseException: + """Tests for the base exception class.""" + + def test_str_with_lineno(self): + """Test string representation with line number.""" + exc = NgxParserBaseException( + strerror='unexpected character', + filename='/etc/nginx/nginx.conf', + lineno=42 + ) + result = str(exc) + assert result == 'unexpected character in /etc/nginx/nginx.conf:42' + + def test_str_without_lineno(self): + """Test string representation without line number. + + Note: There is a known issue in the error class where lineno=None + causes a TypeError due to format string mismatch. This test documents + the current behavior. + """ + exc = NgxParserBaseException( + strerror='file not found', + filename='/etc/nginx/nginx.conf', + lineno=None + ) + # The current implementation has a bug: args is a 3-tuple but + # the format string for lineno=None only has 2 placeholders. + # This test documents the bug rather than the intended behavior. + with pytest.raises(TypeError): + str(exc) + + def test_attributes(self): + """Test that exception attributes are set correctly.""" + exc = NgxParserBaseException( + strerror='test error', + filename='/path/to/file.conf', + lineno=10 + ) + assert exc.strerror == 'test error' + assert exc.filename == '/path/to/file.conf' + assert exc.lineno == 10 + assert exc.args == ('test error', '/path/to/file.conf', 10) + + def test_args_tuple(self): + """Test that args tuple is properly set for exception chaining.""" + exc = NgxParserBaseException( + strerror='error message', + filename='config.conf', + lineno=5 + ) + assert len(exc.args) == 3 + assert exc.args[0] == 'error message' + assert exc.args[1] == 'config.conf' + assert exc.args[2] == 5 + + +class TestExceptionInheritance: + """Tests for exception class hierarchy.""" + + def test_syntax_error_inheritance(self): + """Test NgxParserSyntaxError inherits from base.""" + exc = NgxParserSyntaxError('syntax error', 'file.conf', 1) + assert isinstance(exc, NgxParserBaseException) + assert isinstance(exc, Exception) + + def test_directive_error_inheritance(self): + """Test NgxParserDirectiveError inherits from base.""" + exc = NgxParserDirectiveError('directive error', 'file.conf', 1) + assert isinstance(exc, NgxParserBaseException) + assert isinstance(exc, Exception) + + def test_arguments_error_inheritance(self): + """Test NgxParserDirectiveArgumentsError inherits from DirectiveError.""" + exc = NgxParserDirectiveArgumentsError('args error', 'file.conf', 1) + assert isinstance(exc, NgxParserDirectiveError) + assert isinstance(exc, NgxParserBaseException) + assert isinstance(exc, Exception) + + def test_context_error_inheritance(self): + """Test NgxParserDirectiveContextError inherits from DirectiveError.""" + exc = NgxParserDirectiveContextError('context error', 'file.conf', 1) + assert isinstance(exc, NgxParserDirectiveError) + assert isinstance(exc, NgxParserBaseException) + assert isinstance(exc, Exception) + + def test_unknown_error_inheritance(self): + """Test NgxParserDirectiveUnknownError inherits from DirectiveError.""" + exc = NgxParserDirectiveUnknownError('unknown directive', 'file.conf', 1) + assert isinstance(exc, NgxParserDirectiveError) + assert isinstance(exc, NgxParserBaseException) + assert isinstance(exc, Exception) + + +class TestExceptionCatching: + """Tests for exception catching behavior.""" + + def test_catching_base_catches_syntax_error(self): + """Test that catching base exception catches syntax errors.""" + with pytest.raises(NgxParserBaseException): + raise NgxParserSyntaxError('syntax error', 'file.conf', 1) + + def test_catching_base_catches_directive_error(self): + """Test that catching base exception catches directive errors.""" + with pytest.raises(NgxParserBaseException): + raise NgxParserDirectiveError('directive error', 'file.conf', 1) + + def test_catching_base_catches_arguments_error(self): + """Test that catching base exception catches arguments errors.""" + with pytest.raises(NgxParserBaseException): + raise NgxParserDirectiveArgumentsError('args error', 'file.conf', 1) + + def test_catching_base_catches_context_error(self): + """Test that catching base exception catches context errors.""" + with pytest.raises(NgxParserBaseException): + raise NgxParserDirectiveContextError('context error', 'file.conf', 1) + + def test_catching_base_catches_unknown_error(self): + """Test that catching base exception catches unknown errors.""" + with pytest.raises(NgxParserBaseException): + raise NgxParserDirectiveUnknownError('unknown error', 'file.conf', 1) + + def test_catching_directive_catches_subclasses(self): + """Test that catching DirectiveError catches its subclasses.""" + with pytest.raises(NgxParserDirectiveError): + raise NgxParserDirectiveArgumentsError('args error', 'file.conf', 1) + + with pytest.raises(NgxParserDirectiveError): + raise NgxParserDirectiveContextError('context error', 'file.conf', 1) + + with pytest.raises(NgxParserDirectiveError): + raise NgxParserDirectiveUnknownError('unknown error', 'file.conf', 1) + + def test_catching_syntax_does_not_catch_directive(self): + """Test that SyntaxError and DirectiveError are distinct.""" + with pytest.raises(NgxParserDirectiveError): + try: + raise NgxParserDirectiveError('directive error', 'file.conf', 1) + except NgxParserSyntaxError: + pytest.fail('Should not catch DirectiveError as SyntaxError') + + +class TestExceptionUsage: + """Tests for typical exception usage patterns.""" + + def test_raise_and_access_attributes(self): + """Test raising exception and accessing attributes in handler.""" + try: + raise NgxParserSyntaxError( + strerror='unexpected }', + filename='/etc/nginx/nginx.conf', + lineno=100 + ) + except NgxParserSyntaxError as e: + assert e.strerror == 'unexpected }' + assert e.filename == '/etc/nginx/nginx.conf' + assert e.lineno == 100 + assert 'unexpected }' in str(e) + assert '/etc/nginx/nginx.conf' in str(e) + assert '100' in str(e) + + def test_exception_with_none_filename(self): + """Test exception with None filename.""" + exc = NgxParserBaseException('error', None, 5) + result = str(exc) + assert result == 'error in None:5' + + def test_exception_with_unicode_message(self): + """Test exception with unicode characters in message.""" + exc = NgxParserSyntaxError( + strerror='invalid character: \u00e9', + filename='/path/to/file.conf', + lineno=1 + ) + result = str(exc) + assert 'invalid character: \u00e9' in result diff --git a/tests/test_format.py b/tests/test_format.py index d116572..4dc2ea6 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -39,7 +39,7 @@ def test_format_messy_config(): ' if ($request_method = P\{O\)\###\;ST) {', ' }', ' location /status.html {', - " try_files '/abc/${uri} /abc/${uri}.html' =404;", + " try_files /abc/${uri} /abc/${uri}.html =404;", ' }', r" location '/sta;\n tus' {", ' return 302 /status.html;', diff --git a/tests/test_lex.py b/tests/test_lex.py index 0545753..23afbff 100644 --- a/tests/test_lex.py +++ b/tests/test_lex.py @@ -58,7 +58,7 @@ def test_messy_config(): ('{', 16), ('}', 16), ('# hello', 16), ('if', 17), ('($request_method', 17), ('=', 17), ('P\\{O\\)\\###\\;ST', 17), (')', 17), ('{', 17), ('}', 17), ('location', 18), ('/status.html', 18), - ('{', 18), ('try_files', 19), ('/abc/${uri} /abc/${uri}.html', 19), + ('{', 18), ('try_files', 19), ('/abc/${uri}', 19), ('/abc/${uri}.html', 19), ('=404', 19), (';', 19), ('}', 20), ('location', 21), ('/sta;\n tus', 21), ('{', 22), ('return', 22), ('302', 22), ('/status.html', 22), (';', 22), ('}', 22), @@ -92,3 +92,50 @@ def test_quoted_right_brace(): '"referer": "$http_referer", ', '"agent": "$http_user_agent"', '}', ';', '}' ] + + +def test_lex_string_flushes_last_token_at_eof(): + tokens = list(crossplane.lex_string('events', filename='')) + assert list((token, line) for token, line, quoted in tokens) == [('events', 1)] + + +def test_lex_string_comment_without_trailing_newline(): + tokens = list(crossplane.lex_string('#comment', filename='')) + assert list((token, line) for token, line, quoted in tokens) == [('#comment', 1)] + + +def test_braced_variable_whitespace_split(): + """Test that whitespace after closing brace correctly ends the token.""" + # Two braced variables followed by space and another variable + tokens = list(crossplane.lex_string('map ${var1}${var2} $result { }')) + token_values = [token for token, line, quoted in tokens] + assert token_values == ['map', '${var1}${var2}', '$result', '{', '}'] + + +def test_braced_variable_space_between(): + """Test that space between braced variables splits them.""" + tokens = list(crossplane.lex_string('set $x ${var1} ${var2};')) + token_values = [token for token, line, quoted in tokens] + assert token_values == ['set', '$x', '${var1}', '${var2}', ';'] + + +def test_braced_variable_no_space(): + """Test that adjacent braced variables stay as one token.""" + tokens = list(crossplane.lex_string('set $x ${var1}${var2};')) + token_values = [token for token, line, quoted in tokens] + assert token_values == ['set', '$x', '${var1}${var2}', ';'] + + +def test_braced_variable_text_after(): + """Test that text directly after closing brace stays as one token.""" + tokens = list(crossplane.lex_string('set $x ${var}text;')) + token_values = [token for token, line, quoted in tokens] + assert token_values == ['set', '$x', '${var}text', ';'] + + +def test_braced_variable_in_map_directive(): + """Test braced variables in a realistic map directive context.""" + config = 'map ${detect_bot}${geo_list} $intermed { default 0; }' + tokens = list(crossplane.lex_string(config)) + token_values = [token for token, line, quoted in tokens] + assert token_values == ['map', '${detect_bot}${geo_list}', '$intermed', '{', 'default', '0', ';', '}'] diff --git a/tests/test_parse.py b/tests/test_parse.py index c3d8e7d..5f1259f 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -109,6 +109,36 @@ def test_includes_regular(): } +def test_parse_string_simple_matches_file(): + dirname = os.path.join(here, 'configs', 'simple') + config_path = os.path.join(dirname, 'nginx.conf') + with open(config_path, 'r') as f: + text = f.read() + payload_file = crossplane.parse(config_path) + payload_string = crossplane.parse_string(text, filename=config_path) + assert payload_file == payload_string + + +def test_parse_string_includes_regular_matches_file(): + dirname = os.path.join(here, 'configs', 'includes-regular') + config_path = os.path.join(dirname, 'nginx.conf') + with open(config_path, 'r') as f: + text = f.read() + payload_file = crossplane.parse(config_path) + payload_string = crossplane.parse_string(text, filename=config_path) + assert payload_file == payload_string + + +def test_parse_string_includes_globbed_combined_matches_file(): + dirname = os.path.join(here, 'configs', 'includes-globbed') + config_path = os.path.join(dirname, 'nginx.conf') + with open(config_path, 'r') as f: + text = f.read() + payload_file = crossplane.parse(config_path, combine=True) + payload_string = crossplane.parse_string(text, filename=config_path, combine=True) + assert payload_file == payload_string + + def test_includes_globbed(): dirname = os.path.join(here, 'configs', 'includes-globbed') config = os.path.join(dirname, 'nginx.conf') @@ -853,6 +883,19 @@ def test_parse_missing_semicolon(): } +def test_parse_string_unterminated_directive_at_eof(tmp_path): + filename = str(tmp_path / 'nginx.conf') + payload = crossplane.parse_string('user nobody', filename=filename) + assert payload['status'] == 'failed' + assert payload['errors'] == [ + { + 'file': filename, + 'error': 'directive "user" is not terminated by ";" in %s:1' % filename, + 'line': 1 + } + ] + + def test_combine_parsed_missing_values(): dirname = os.path.join(here, 'configs', 'includes-regular') config = os.path.join(dirname, 'nginx.conf') @@ -1032,3 +1075,111 @@ def test_non_unicode(): } ] } + + +def test_includes_regular_combined(): + """Test that directives after includes have correct file attribute when combine=True. + + This tests for a variable scope leak bug where the `fname` loop variable in the + include-handling block would shadow the outer `fname`, causing directives after + includes to have incorrect `file` attributes. + """ + dirname = os.path.join(here, 'configs', 'includes-regular-combined') + config = os.path.join(dirname, 'nginx.conf') + payload = crossplane.parse(config, combine=True) + assert payload == { + 'status': 'ok', + 'errors': [], + 'config': [ + { + 'file': os.path.join(dirname, 'nginx.conf'), + 'status': 'ok', + 'errors': [], + 'parsed': [ + { + 'directive': 'events', + 'file': os.path.join(dirname, 'nginx.conf'), + 'line': 1, + 'args': [], + 'block': [] + }, + { + 'directive': 'http', + 'file': os.path.join(dirname, 'nginx.conf'), + 'line': 2, + 'args': [], + 'block': [ + { + 'directive': 'server', + 'file': os.path.join(dirname, 'server.conf'), + 'line': 1, + 'args': [], + 'block': [ + { + 'directive': 'listen', + 'file': os.path.join(dirname, 'server.conf'), + 'line': 2, + 'args': ['8080'] + } + ] + }, + { + 'directive': 'default_type', + 'file': os.path.join(dirname, 'nginx.conf'), + 'line': 4, + 'args': ['text/plain'] + } + ] + } + ] + } + ] + } + + +def test_parse_map_strict(): + """Test that map blocks parse correctly in strict mode.""" + dirname = os.path.join(here, 'configs', 'map') + config = os.path.join(dirname, 'nginx.conf') + payload = crossplane.parse(config, strict=True) + assert payload['status'] == 'ok' + assert payload['errors'] == [] + + # verify the structure + http_block = payload['config'][0]['parsed'][1] + assert http_block['directive'] == 'http' + + # check first map block + map1 = http_block['block'][0] + assert map1['directive'] == 'map' + assert map1['args'] == ['$uri', '$new'] + assert len(map1['block']) == 3 # default, ~^/news, ~^/blog + + # check second map block has hostnames + map2 = http_block['block'][1] + assert map2['directive'] == 'map' + assert map2['args'] == ['$http_host', '$backend'] + assert len(map2['block']) == 3 # hostnames, default, *.example.com + + +def test_parse_types_strict(): + """Test that types blocks parse correctly in strict mode.""" + dirname = os.path.join(here, 'configs', 'types') + config = os.path.join(dirname, 'nginx.conf') + payload = crossplane.parse(config, strict=True) + assert payload['status'] == 'ok' + assert payload['errors'] == [] + + # verify the structure + http_block = payload['config'][0]['parsed'][1] + assert http_block['directive'] == 'http' + + # check types block + types_block = http_block['block'][0] + assert types_block['directive'] == 'types' + assert len(types_block['block']) == 4 # text/html, text/css, application/javascript, image/png + + # verify some MIME type entries + mime_entries = {stmt['directive']: stmt['args'] for stmt in types_block['block']} + assert mime_entries['text/html'] == ['html', 'htm'] + assert mime_entries['text/css'] == ['css']