diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..24655f3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +charset = utf-8 +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d7a51d3..8aebead 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,29 +1,22 @@ --- -name: Linting +name: Lint on: [push, pull_request] jobs: - build: - + lint: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8'] - - name: Linting steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: ${{ matrix.python-version }} - architecture: x64 + activate-environment: true + - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r development.txt + run: uv sync --all-groups - name: Lint run: make lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf8a86e..4931c3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,51 +1,34 @@ -name: Build wheels +--- +name: Build on: release: types: [published] - pull_request: - types: [opened, reopened, edited, synchronize] -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" build: - # The type of runner that the job will run on runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.8] - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - # Set up Python environment - - name: Set up Python 3.8 - uses: actions/setup-python@v2 - with: - python-version: 3.8 + - name: Install uv + uses: astral-sh/setup-uv@v7 - # Install dependencies - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r development.txt + run: uv sync - # Build wheels - - name: Build wheels - run: python setup.py sdist bdist_wheel + - name: Build package + run: uv build - - uses: actions/upload-artifact@v2 + - name: Upload artifacts + uses: actions/upload-artifact@v6 with: - path: | - ./dist/*.tar.gz + path: ./dist/ - # Publish to pypi - - name: Publish to pypi + - name: Publish + if: ${{ github.event_name == 'release' }} + run: uv publish env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - if: ${{ github.event_name == 'release' && env.TWINE_USERNAME != null }} - run: twine upload --repository pypi dist/* + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_PASSWORD }} + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bfd5569 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +--- +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-groups + + - name: Test + run: make test diff --git a/.gitignore b/.gitignore index 343d861..1a71411 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,6 @@ db.sqlite3 db.sqlite3-journal # Flask stuff: -instance/ .webassets-cache # Scrapy stuff: @@ -131,5 +130,7 @@ dmypy.json # Miscellaneous .DS_Store .vscode/ +.idea/ .ctf/ +!tests/fixtures/challenges/.ctf diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 428e81a..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,11 +0,0 @@ -image: python:3.7 - -before_script: - - python -V - - mkdir -p .pip - - pip install --upgrade pip - - pip install --cache-dir='.pip' -r development.txt - -test: - script: - - make lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eebeba..b8168e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,157 @@ # Changelog +# 0.1.7 / 2026-02-26 + +### Added + +- Add support for branches to challenge repo URLs +- Add support for solutions based on writeups in the challenge repo + +# 0.1.6 / 2026-01-06 + +### Added + +- Add `sha1sum` to `--ignore` as part of `ctf challenge sync` to allow syncing files when the remote checksum or local checksum is corrupted + +### Fixed + +- Fix an issue where if deployment returned updated connection info we wouldn't update it in challenge.yml +- Fix an issue where relative paths would not deploy due to a logging error + +### Changed + +- Challenges without an image will be considered a skipped deploy instead of a failed deploy +- Switch from poetry to uv +- Switch from to ruff + +# 0.1.5 / 2025-09-04 + +### Added + +- Support for hint titles +- Support `logic` key for challenges + +### Fixed + +- Fix issue with resolving relative challenge paths during install + +# 0.1.4 / 2025-04-29 + +### Added + +- Added support for `ctf instance` with the `ctf instance config` command which can be used to `get` and `set` configuration on CTFd +- Added `ctf media add`, `ctf media rm`, `ctf media url` + - Allows ctfcli repos to manage files locally and reference the actual server URLs of media files in Pages + - Adds concept of replacing placeholders like `{{ media/ctfd.png }}` with the actual URL on the server +- Added the `attribution` field to challenge.yml +- Added the `next` field to challenge.yml +- Added ability to anoymize challenges while specifying prerequisites +- Added specifying CTFd instance URL and access token via envvars: `CTFCLI_URL`, `CTFCLI_ACCESS_TOKEN` + +### Fixed + +- Fix issue with managing challenges with an empty files section +- Fix issue where images could not be deployed due to being named incorrectly + +# 0.1.3 / 2024-08-20 + +### Added + +- Added support for `git subrepo` instead of only `git subtree` +- Added the `--create` switch to `ctf challenge mirror` to create local copies of challenges that exist on a remote CTFd instance + +### Fixed + +- `ctf challenge {push, pull}` will now push / pull all challenges instead of the challenge in the current working directory. + +### Changed + +- Use `--load` switch as part of docker build to support alternate build drivers + +# 0.1.2 / 2023-02-26 + +### Added + +- Before uploading files to CTFd, ctfcli will check for CTFd's SHA1 hash of the previously uploaded file and skip uploading if it is the same +- Support using remote Docker images instead of having to build and push local images + +# 0.1.1 / 2023-12-11 + +### Added + +- Added `ctf challenge mirror` command to pull changes from the remote CTFd instance into the local project + +### Fixed + +- Properly include challenge.yml when generating a challenge from a template + +### Changed + +- No longer require a ctfcli project to run all `ctf challenge` (e.g. `new`, `format`, `lint`) + +# 0.1.0 / 2023-10-03 + +### Added + +- ctfcli has been separated into two main modules `cli` and `core`. The `core` module now packages logic previously found inside `utils`, wrapped into classes. +- The classes in the `core` module will only print out warnings instead of interrupting the whole process. Everything else will throw exceptions which can be caught and handled however desired +- `cli` and `core` internal modules have type hints +- Improved error messages +- Unit tests have been added for the entire `core` module +- ctfcli will now ask to initialize a new project if one does not exist +- Added `--hidden` to `ctf challenge install` which will deploy the challenge / challenges in a hidden state regardless of their `challenge.yml` value. +- Added `ctf challenge edit ` and `ctf challenge edit --dockerfile` to open challenge.yml or Dockerfile for that challenge +- Added aliases under `ctf templates` and `ctf plugins` for `dir` (`path`) and for `view` (`show`) +- Progress bars for `ctf challenge deploy` / `ctf challenge install` / `ctf challenge sync` +- `ctf challenge deploy` will now deploy ALL deployable challenges if a specific challenge is not specified + - For the SSH and Registry deployments, to facilitate this behaviour the challenge name will be automatically appended. So the host should be for example: `registry://registry.example.com/example-project` and the challenge name will be appended for a full location. +- `ctf challenge deploy` will now also automatically login to the registry with Cloud and Registry deployments. + - For cloud deployments the instance url must be ctfd assigned (e.g. example.ctfd.io) - this is because to log-in to the registry we require a username like `admin@example.ctfd.io`. The deployment will use the access token as the password. + - For registry deployment it will look for the `username` and `password` keys inside a `[registry]` section in the project config file. +- ctfcli will read a `LOGLEVEL` environment variable to enable DEBUG logging has been added + +### Fixed + +- When syncing a challenge to a remote instance, state specified in challenge.yml will now be ignored to prevent accidental challenge leaking +- The CLI will now exit with a 0 if everything went right, and 1 if something went wrong. + - With `install`/`sync`/`deploy` - exit code will be 1 if ANY of the challenges failed to `install`/`sync`/`deploy`. + +### Changed + +- Built using poetry and `pyproject.toml` +- `python-fire` has been updated to 0.5.0 + +### Removed + +- Removed the `ctf challenge finalize` command + +# 0.0.13 / 2023-07-29 + +### Added + +- Add env variable `CTFCLI_PLUGIN_DIR` to override the default plugin dir for development. +- Add `--directory` argument to `ctfcli challenge add` + - Can also be called as `ctf challenge add git@github.com:repo.git directory` + - Useful for grouping challenges into separate directories like: `web/challenge1`. +- `connection_info` specified in challenge.yml will be used instead of details generated by a deploy handler + +### Fixed + +- Bump PyYAML version to 6.0.1 + +# 0.0.12 / 2023-06-25 + +### Added + +- Add cloud deploy for hosted CTFd instances +- Add the `protocol` field in the challenge.yml spec +- Further define what other deployment methods should provide & return +- Add the ability to add HTTP cookies to ctfcli requests via the config file + +### Fixed + +- Allow ignoring category during challenge sync + # 0.0.11 / 2022-11-09 ### Added diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 75819ca..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include LICENSE -include README.md -recursive-include ctfcli/templates * -recursive-include ctfcli/utils * -recursive-include ctfcli/spec * diff --git a/Makefile b/Makefile index 711704d..cd23b00 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,19 @@ +.PHONY: all +.IGNORE: lint format + lint: - flake8 --ignore=E402,E501,E712,W503,E203,I002 --exclude=ctfcli/templates **/*.py - black --check --exclude=ctfcli/templates . + ruff format --check . + ruff check . format: - black --exclude=ctfcli/templates . - -install: - python3 setup.py install + ruff check --select F401 --select TID252 --select I --fix . + ruff format . -build: - python3 setup.py sdist bdist_wheel +test: + pytest --cov=ctfcli tests clean: - rm -rf build/ rm -rf dist/ - rm -rf ctfcli.egg-info/ - -publish-test: - @echo "Publishing to TestPyPI" - @echo "Are you sure? [y/N] " && read ans && [ $${ans:-N} == y ] - python3 setup.py sdist bdist_wheel - twine upload --repository test dist/* - -publish-pypi: - @echo "Publishing to PyPI" - @echo "ARE YOU ABSOLUTELY SURE? [y/N] " && read ans && [ $${ans:-N} == y ] - python3 setup.py sdist bdist_wheel - twine upload --repository pypi dist/* \ No newline at end of file + rm -rf .ruff_cache + rm -rf .pytest_cache + rm -f .coverage diff --git a/README.md b/README.md index a3e08d3..3479ff0 100644 --- a/README.md +++ b/README.md @@ -10,52 +10,69 @@ # Installation and Usage -ctfcli can be installed with `pip install ctfcli` +ctfcli can be installed with [`pipx`](https://github.com/pypa/pipx) as an executable command: + +`pipx install ctfcli` + +Alternatively, you can always install it with `pip` as a python module: + +`pip install ctfcli` + +To install the development version of ctfcli directly from the repository you can use: + +`pip install git+https://github.com/CTFd/ctfcli.git` ## 1. Create an Event -ctfcli turns the current folder into a CTF event git repo. It asks for the base url of the CTFd instance you're working with and an access token. +Ctfcli turns the current folder into a CTF event git repo. +It asks for the base url of the CTFd instance you're working with and an access token. ``` ❯ ctf init Please enter CTFd instance URL: https://demo.ctfd.io Please enter CTFd Admin Access Token: d41d8cd98f00b204e9800998ecf8427e -Do you want to continue with https://demo.ctfd.io and d41d8cd98f00b204e9800998ecf8427e [y/N]: y +Do you want to continue with https://demo.ctfd.io and d41d8cd98f00b204e9800998ecf8427e [Y/n]: y Initialized empty Git repository in /Users/user/Downloads/event/.git/ ``` -This will create the `.ctf` folder with the `config` file that will specify the URL, access token, and keep a record of all the challenges dedicated for this event. +This will create the `.ctf` folder with the `config` file that will specify the URL, access token, and keep a record of +all the challenges dedicated for this event. ## 2. Add challenges -Events are made up of challenges. Challenges can be made from a subdirectory or pulled from another repository. Remote challenges are pulled into the event repo and a reference is kept in the `.ctf/config` file. +Events are made up of challenges. +Challenges can be made from a subdirectory or pulled from another repository. +GIT-enabled challenges are pulled into the event repo, and a reference is kept in the `.ctf/config` file. ``` ❯ ctf challenge add [REPO | FOLDER] ``` +##### Local folder: ``` ❯ ctf challenge add crypto/stuff ``` +##### GIT repository: ``` ❯ ctf challenge add https://github.com/challenge.git -challenge Cloning into 'challenge'... -remote: Enumerating objects: 624, done. -remote: Counting objects: 100% (624/624), done. -remote: Compressing objects: 100% (540/540), done. -remote: Total 624 (delta 109), reused 335 (delta 45), pack-reused 0 -Receiving objects: 100% (624/624), 6.49 MiB | 21.31 MiB/s, done. -Resolving deltas: 100% (109/109), done. +[...] +``` + +##### GIT repository to a specific subfolder: +``` +❯ ctf challenge add https://github.com/challenge.git crypto +Cloning into 'crypto/challenge'... +[...] ``` ## 3. Install challenges -Installing a challenge will automatically create the challenge in your CTFd instance using the API. +Installing a challenge will create the challenge in your CTFd instance using the API. ``` -❯ ctf challenge install [challenge.yml | DIRECTORY] +❯ ctf challenge install [challenge] ``` ``` @@ -66,12 +83,13 @@ Installing buffer_overflow Success! ``` -## 4. Update challenges +## 4. Sync challenges -Syncing a challenge will automatically update the challenge in your CTFd instance using the API. Any changes made in the `challenge.yml` file will be reflected in your instance. +Syncing a challenge will update the challenge in your CTFd instance using the API. +Any changes made in the `challenge.yml` file will be reflected in your instance. ``` -❯ ctf challenge sync [challenge.yml | DIRECTORY] +❯ ctf challenge sync [challenge] ``` ``` @@ -82,6 +100,70 @@ Syncing buffer_overflow Success! ``` +## 5. Deploy services + +Deploying a challenge will automatically create the challenge service (by default in your CTFd instance). +You can also use a different deployment handler to deploy the service via SSH to your own server, +or a separate docker registry. + +The challenge will also be automatically installed or synced. +Obtained connection info will be added to your `challenge.yml` file. +``` +❯ ctf challenge deploy [challenge] +``` + +``` +❯ ctf challenge deploy web-1 +Deploying challenge service 'web-1' (web-1/challenge.yml) with CloudDeploymentHandler ... +Challenge service deployed at: https://web-1-example-instance.chals.io +Updating challenge 'web-1' +Success! +``` + +## 6. Verify challenges + +Verifying a challenge will check if the local version of the challenge is the same as one installed in your CTFd instance. + +``` +❯ ctf challenge verify [challenge] +``` + +``` +❯ ctf challenge verify buffer_overflow +Verifying challenges [------------------------------------] 0% +Verifying challenges [####################################] 100% +Success! All challenges verified! +Challenges in sync: + - buffer_overflow +``` + +## 7. Mirror changes + +Mirroring a challenge is the reverse operation to syncing. +It will update the local version of the challenge with details of the one installed in your CTFd instance. +It will also issue a warning if you have any remote challenges that are not tracked locally. + +``` +❯ ctf challenge mirror [challenge] +``` + +``` +❯ ctf challenge verify buffer_overflow +Mirorring challenges [------------------------------------] 0% +Mirorring challenges [####################################] 100% +Success! All challenges mirrored! +``` + +## Operations on all challenges + +You can perform operations on all challenges defined in your config by simply skipping the challenge parameter. + +- `ctf challenge install` +- `ctf challenge sync` +- `ctf challenge deploy` +- `ctf challenge verify` +- `ctf challenge mirror` + # Challenge Templates `ctfcli` contains pre-made challenge templates to make it faster to create CTF challenges with safe defaults. @@ -120,6 +202,17 @@ The specification format has already been tested and used with CTFd in productio # Plugins -`ctfcli` plugins are essentially additions to to the command line interface via dynamic class modifications. See the [plugin documentation page](docs/plugins.md) for a simple example. +`ctfcli` plugins are essentially additions to the command line interface via dynamic class modifications. See the [plugin documentation page](docs/plugins.md) for a simple example. *`ctfcli` is an alpha project! The plugin interface is likely to change!* + +# Sub-Repos as alternative to Sub-Trees + +`ctfcli` manages git-based challenges by using the built-in git `subtree` mechanism. While it works most of the time, it's been proven to have disadvantages and tends to create problems and merge conflicts. + +As an alternative, we're currently experimenting with the git [`git subrepo`](https://github.com/ingydotnet/git-subrepo) extension. +This functionality can be enabled by adding a `use_subrepo = True` property to the `[config]` section inside a ctfcli project config. + +Subrepo has to be installed separately, and is not backwards compatible with the default `subtree`. +Once challenges have been added by using either method, they will not work properly if you change it, and you will have to add the challenges again. + diff --git a/ctfcli/__init__.py b/ctfcli/__init__.py index 50db71e..b8acfd3 100644 --- a/ctfcli/__init__.py +++ b/ctfcli/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.0.11" +__version__ = "0.1.7" __name__ = "ctfcli" diff --git a/ctfcli/__main__.py b/ctfcli/__main__.py index a6e061e..76211c1 100644 --- a/ctfcli/__main__.py +++ b/ctfcli/__main__.py @@ -1,5 +1,5 @@ import configparser -import importlib +import logging import os import subprocess import sys @@ -8,73 +8,116 @@ import click import fire -from ctfcli.cli.challenges import Challenge -from ctfcli.cli.config import Config -from ctfcli.cli.plugins import Plugins -from ctfcli.cli.templates import Templates -from ctfcli.cli.pages import Pages -from ctfcli.utils.plugins import get_plugin_dir +from ctfcli.cli.challenges import ChallengeCommand +from ctfcli.cli.config import ConfigCommand +from ctfcli.cli.instance import InstanceCommand +from ctfcli.cli.media import MediaCommand +from ctfcli.cli.pages import PagesCommand +from ctfcli.cli.plugins import PluginsCommand +from ctfcli.cli.templates import TemplatesCommand +from ctfcli.core.exceptions import ( + MissingAPIKey, + MissingInstanceURL, + ProjectNotInitialized, +) +from ctfcli.core.plugins import load_plugins from ctfcli.utils.git import check_if_dir_is_inside_git_repo +# Init logging +logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO").upper()) -class CTFCLI(object): - def init(self, directory=None, no_config=False, no_git=False): - # Create our event directory if requested and use it as our base directory +log = logging.getLogger("ctfcli.main") + + +class CTFCLI: + @staticmethod + def init( + directory: str | os.PathLike | None = None, + no_git: bool = False, + no_commit: bool = False, + ): + log.debug(f"init: (directory={directory}, no_git={no_git}, no_commit={no_commit})") + project_path = Path.cwd() + + # Create our project directory if requested if directory: - path = Path(directory) - path.mkdir() - click.secho(f"Created empty directory in {path.absolute()}", fg="green") - else: - path = Path(".") + project_path = Path(directory) - # Get variables from user - ctf_url = click.prompt( - "Please enter CTFd instance URL", default="", show_default=False - ) - ctf_token = click.prompt( - "Please enter CTFd Admin Access Token", default="", show_default=False - ) - # Confirm information with user - if ( - click.confirm(f"Do you want to continue with {ctf_url} and {ctf_token}") - is False - ): - click.echo("Aborted!") - return + if not project_path.exists(): + project_path.mkdir(parents=True) + click.secho(f"Created empty directory in {project_path.absolute()}", fg="green") # Avoid colliding with existing .ctf directory - if (path / ".ctf").exists(): + if (project_path / ".ctf").exists(): click.secho(".ctf/ folder already exists. Aborting!", fg="red") return + log.debug(f"project_path: {project_path}") + # Create .ctf directory - (path / ".ctf").mkdir() + (project_path / ".ctf").mkdir() + + # Get variables from user + ctf_url = click.prompt("Please enter CTFd instance URL", default="", show_default=False) + + ctf_token = click.prompt("Please enter CTFd Admin Access Token", default="", show_default=False) + + # Confirm information with user + if not click.confirm(f"Do you want to continue with {ctf_url} and {ctf_token}", default=True): + click.echo("Aborted!") + return # Create initial .ctf/config file config = configparser.ConfigParser() config["config"] = {"url": ctf_url, "access_token": ctf_token} config["challenges"] = {} - with (path / ".ctf" / "config").open(mode="a+") as f: - config.write(f) + with (project_path / ".ctf" / "config").open(mode="a+") as config_file: + config.write(config_file) - # Create a git repo in the event folder - if check_if_dir_is_inside_git_repo(dir=path.absolute()) is True: - click.secho("Already in git repo. Skipping git init.", fg="yellow") - elif no_git is True: + # if git init is to be skipped we can return + if no_git: click.secho("Skipping git init.", fg="yellow") - else: - click.secho(f"Creating git repo in {path.absolute()}", fg="green") - subprocess.call(["git", "init", str(path)]) + return + + # also skip git init if git is already initialized + if check_if_dir_is_inside_git_repo(cwd=project_path): + click.secho("Already in a git repo. Skipping git init.", fg="yellow") + + # is git commit is to be skipped we can return + if no_commit: + click.secho("Skipping git commit.", fg="yellow") + return + + subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + subprocess.call(["git", "commit", "-m", "init ctfcli project"], cwd=project_path) + return + + # Create a git repo in the project folder + click.secho(f"Creating a git repo in {project_path}", fg="green") + subprocess.call(["git", "init", str(project_path)]) + + if no_commit: + click.secho("Skipping git commit.", fg="yellow") + return + + subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + subprocess.call(["git", "commit", "-m", "init ctfcli project"], cwd=project_path) def config(self): return COMMANDS.get("config") + def instance(self): + return COMMANDS.get("instance") + def challenge(self): return COMMANDS.get("challenge") def pages(self): return COMMANDS.get("pages") + def media(self): + return COMMANDS.get("media") + def plugins(self): return COMMANDS.get("plugins") @@ -83,28 +126,48 @@ def templates(self): COMMANDS = { - "challenge": Challenge(), - "config": Config(), - "pages": Pages(), - "plugins": Plugins(), - "templates": Templates(), + "challenge": ChallengeCommand(), + "config": ConfigCommand(), + "pages": PagesCommand(), + "plugins": PluginsCommand(), + "templates": TemplatesCommand(), + "instance": InstanceCommand(), + "media": MediaCommand(), "cli": CTFCLI(), } def main(): # Load plugins - plugin_dir = get_plugin_dir() - sys.path.insert(0, plugin_dir) - for plugin in sorted(os.listdir(plugin_dir)): - plugin_path = os.path.join(plugin_dir, plugin, "__init__.py") - print("Loading", plugin_path, "as", plugin) - loaded = importlib.import_module(plugin) - loaded.load(COMMANDS) - sys.path.remove(plugin_dir) + load_plugins(COMMANDS) # Load CLI - fire.Fire(CTFCLI) + try: + # if the command returns an int, then we serialize it as none to prevent fire from printing it + # (this does not change the actual return value, so it's still good to use as an exit code) + # everything else is returned as is, so fire can print help messages + ret = fire.Fire(COMMANDS["cli"], serialize=lambda r: None if isinstance(r, int) else r) + + if isinstance(ret, int): + sys.exit(ret) + + except MissingInstanceURL as e: + click.secho(e, fg="red") + sys.exit(1) + + except MissingAPIKey as e: + click.secho(e, fg="red") + sys.exit(1) + + except ProjectNotInitialized: + if click.confirm( + "Outside of a ctfcli project, would you like to start a new project in this directory?", + default=False, + ): + CTFCLI.init() + except KeyboardInterrupt: + click.secho("\n[Ctrl-C] Aborting.", fg="red") + sys.exit(2) if __name__ == "__main__": diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index fd960cb..6b26b63 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -1,416 +1,1138 @@ +import contextlib +import logging import os import subprocess -import sys from pathlib import Path from urllib.parse import urlparse import click -import yaml from cookiecutter.main import cookiecutter +from pygments import highlight +from pygments.formatters.terminal import TerminalFormatter +from pygments.lexers.data import YamlLexer -from ctfcli.utils.challenge import ( - create_challenge, - lint_challenge, - load_challenge, - load_installed_challenge, - load_installed_challenges, - sync_challenge, +from ctfcli.core.challenge import Challenge +from ctfcli.core.config import Config +from ctfcli.core.deployment import get_deployment_handler +from ctfcli.core.exceptions import ( + ChallengeException, + LintException, + RemoteChallengeNotFound, ) -from ctfcli.utils.config import ( - get_base_path, - get_config_path, - get_project_path, - load_config, -) -from ctfcli.utils.deploy import DEPLOY_HANDLERS -from ctfcli.utils.spec import CHALLENGE_SPEC_DOCS, blank_challenge_spec -from ctfcli.utils.templates import get_template_dir -from ctfcli.utils.git import get_git_repo_head_branch +from ctfcli.utils.git import check_if_git_subrepo_is_installed, resolve_repo_url + +log = logging.getLogger("ctfcli.cli.challenges") + +class ChallengeCommand: + def new(self, type: str = "blank") -> int: + log.debug(f"new: (type={type})") -class Challenge(object): - def new(self, type="blank"): + # If the type is blank, use the built-in default template if type == "blank": - path = Path(get_base_path()) - path = path / "templates" / type / "default" - cookiecutter(str(path)) - else: - # Check if we're referencing an installed template - template_dir = Path(get_template_dir()) - template_path = template_dir / type + template_path = Config.get_base_path() / "templates" / type / "default" + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + # If the type is not the default 'blank' - check if it's installed + template_path = Config.get_templates_path() / type + if template_path.is_dir(): # If we found a template directory, use it + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + # If it's not installed, check if it's built-in + # Without a specified variant + if os.sep not in type: + template_path = Config.get_base_path() / "templates" / type / "default" + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + # With a specified variant + template_path = Config.get_base_path() / "templates" / type + if template_path.is_dir(): + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + click.secho( + f"Could not locate template '{type}' in either installed or built-in templates", + fg="red", + ) + return 1 + + def edit(self, challenge: str, dockerfile: bool = False) -> int: + log.debug(f"edit: {challenge} (dockerfile={dockerfile})") + + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + edited_file_path = challenge_instance.challenge_file_path + if dockerfile: + dockerfile_path = challenge_instance.challenge_directory / challenge_instance.get("image", ".") + + if not str(dockerfile_path).endswith("Dockerfile"): + dockerfile_path = dockerfile_path / "Dockerfile" + + if not dockerfile_path.exists(): + click.secho( + f"Could not open Dockerfile for editing, because it could not be found at: {dockerfile_path}", + fg="red", + ) + return 1 + + edited_file_path = dockerfile_path + + editor = os.getenv("EDITOR", "vi") + log.debug(f"call(['{editor}', '{edited_file_path}'])") + subprocess.call([editor, edited_file_path]) + return 0 + + def show(self, challenge: str, color=True) -> int: + log.debug(f"show: {challenge} (color={color})") + return self.view(challenge, color=color) + + def view(self, challenge: str, color=True) -> int: + log.debug(f"view: {challenge} (color={color})") - if template_path.is_dir(): # If we found a template directory, use it - cookiecutter(str(template_path)) - else: # If we didn't, use a built in template - path = Path(get_base_path()) - if os.sep not in type: - type += os.sep + "default" - path = path / "templates" / type - cookiecutter(str(path)) + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 - def templates(self): - from ctfcli.cli.templates import Templates + with open(challenge_instance.challenge_file_path) as challenge_yml_file: + challenge_yml = challenge_yml_file.read() - Templates().list() + if color: + click.echo(highlight(challenge_yml, YamlLexer(), TerminalFormatter())) + return 0 - def add(self, repo, yaml_path=None): - config = load_config() + click.echo(challenge_yml) + return 0 + def templates(self) -> int: + log.debug("templates") + from ctfcli.cli.templates import TemplatesCommand + + return TemplatesCommand.list() + + def add( + self, + repo: str, + directory: str | None = None, + branch: str | None = None, + force: bool = False, + yaml_path: str | None = None, + ) -> int: + log.debug(f"add: {repo} (directory={directory}, branch={branch}, force={force}, yaml_path={yaml_path})") + config = Config() + + # Check if we're working with a remote challenge which has to be pulled first if repo.endswith(".git"): - # Get relative path from project root to current directory - challenge_path = Path(os.path.relpath(os.getcwd(), get_project_path())) + use_subrepo = config["config"].getboolean("use_subrepo", fallback=False) + if use_subrepo and not check_if_git_subrepo_is_installed(): + click.secho("This project is configured to use git subrepo, but it's not installed.") + return 1 - # Get new directory that will add the git subtree - base_repo_path = Path(os.path.basename(repo).rsplit(".", maxsplit=1)[0]) + # Get a relative path from project root to current directory + project_path = config.project_path + project_relative_cwd = Path.cwd().relative_to(project_path) + + # Get a new directory that will add the git subtree / git subrepo + repository_basename = Path(repo).stem + + # Use the custom subdirectory for the challenge if one was provided + repository_path = repository_basename + if directory: + custom_directory_path = Path(directory) + repository_path = custom_directory_path / repository_basename # Join targets - challenge_path = challenge_path / base_repo_path - print(challenge_path) + challenge_path = project_relative_cwd / repository_path - # If a custom yaml_path is specified we add it to our challenge_key + # If a custom yaml_path is specified, we add it to our challenge_key + challenge_key = challenge_path if yaml_path: - challenge_key = str(challenge_path / yaml_path) + challenge_key = challenge_key / yaml_path + + # Add a new challenge to the config, with the branch if specified + config["challenges"][str(challenge_key)] = f"{repo}@{branch}" if branch else repo + + if use_subrepo: + # Clone with subrepo if configured + cmd = ["git", "subrepo", "clone", repo, challenge_path] + + if branch is not None: + cmd += ["-b", branch] + + if force: + cmd += ["-f"] else: - challenge_key = str(challenge_path) + # Otherwise default to the built-in subtree + _, target_branch = resolve_repo_url(repo, branch=branch) + cmd = ["git", "subtree", "add", "--prefix", challenge_path, repo, target_branch, "--squash"] - config["challenges"][challenge_key] = repo + log.debug(f"call({cmd}, cwd='{project_path}')") + if subprocess.call(cmd, cwd=project_path) != 0: + click.secho( + "Could not add the challenge repository. Please check git error messages above.", + fg="red", + ) + return 1 - head_branch = get_git_repo_head_branch(repo) - subprocess.call( - [ - "git", - "subtree", - "add", - "--prefix", - challenge_path, - repo, - head_branch, - "--squash", - ], - cwd=get_project_path(), - ) - with open(get_config_path(), "w+") as f: - config.write(f) + with open(config.config_path, "w+") as config_file: + config.write(config_file) - subprocess.call( - ["git", "add", ".ctf/config"], cwd=get_project_path(), - ) - subprocess.call( - ["git", "commit", "-m", f"Added {str(challenge_path)}"], - cwd=get_project_path(), - ) + log.debug(f"call(['git', 'add', '.ctf/config'], cwd='{project_path}')") + git_add = subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + + log.debug(f"call(['git', 'commit', '-m', 'Added {challenge_path}'], cwd='{project_path}')") + git_commit = subprocess.call(["git", "commit", "-m", f"Added {challenge_path}"], cwd=project_path) - elif Path(repo).exists(): + if any(r != 0 for r in [git_add, git_commit]): + click.secho( + "Could not commit the challenge repository. Please check git error messages above.", + fg="red", + ) + return 1 + + return 0 + + # otherwise - we're working with a folder path + if Path(repo).exists(): config["challenges"][repo] = repo - with open(get_config_path(), "w+") as f: + with open(config.config_path, "w+") as f: config.write(f) + + return 0 + + click.secho(f"Could not process the challenge path: '{repo}'", fg="red") + return 1 + + def push(self, challenge: str | None = None, quiet=False) -> int: + log.debug(f"push: (challenge={challenge}, quiet={quiet})") + config = Config() + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + challenges = [challenge_instance] else: + challenges = self._resolve_all_challenges() + + failed_pushes = [] + + use_subrepo = config["config"].getboolean("use_subrepo", fallback=False) + if use_subrepo and not check_if_git_subrepo_is_installed(): + click.secho("This project is configured to use git subrepo, but it's not installed.") + return 1 + + # Validate all challenges and check for uncommitted changes upfront + challenges_to_push = [] + challenges_with_uncommitted_changes = [] + + for challenge_instance in challenges: + # Get a relative path from project root to the challenge + challenge_path = challenge_instance.challenge_directory.resolve().relative_to(config.project_path) + challenge_repo = config.challenges.get(str(challenge_path), None) + + # if we don't find the challenge by the directory, + # check if it's saved with a direct path to challenge.yml + if not challenge_repo: + challenge_repo = config.challenges.get(str(challenge_path / "challenge.yml"), None) + + if not challenge_repo: + click.secho( + f"Could not find added challenge '{challenge_path}' " + "Please check that the challenge is added to .ctf/config and that your path matches", + fg="red", + ) + failed_pushes.append(challenge_instance) + continue + + challenge_repo, challenge_branch = resolve_repo_url(challenge_repo) + + if not challenge_repo.endswith(".git"): + click.secho( + f"Cannot push challenge '{challenge_path}', as it's not a git-based challenge", + fg="yellow", + ) + failed_pushes.append(challenge_instance) + continue + + # Check for uncommitted changes + log.debug( + f"call(['git', 'status', '--porcelain'], cwd='{config.project_path / challenge_path}'," + f" stdout=subprocess.PIPE, text=True)" + ) + git_status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=config.project_path / challenge_path, + stdout=subprocess.PIPE, + text=True, + ) + + if git_status.stdout.strip() != "" and git_status.returncode == 0: + challenges_with_uncommitted_changes.append(challenge_path) + + challenges_to_push.append((challenge_instance, challenge_path, challenge_repo, challenge_branch)) + + # If any challenges have uncommitted changes, error out + if challenges_with_uncommitted_changes: click.secho( - "Couldn't process that challenge path. Please check it for errors.", + "Cannot push: the following challenges have uncommitted changes:", fg="red", ) + for challenge_path in challenges_with_uncommitted_changes: + click.echo(f" - {challenge_path}") + + click.echo() + click.secho("Please commit your changes before pushing.", fg="yellow") + return 1 + + # Push all challenges (working directory is clean) + if not quiet and len(challenges_to_push) > 1: + push_context = click.progressbar(challenges_to_push, label="Pushing challenges") + else: + push_context = contextlib.nullcontext(challenges_to_push) + + with push_context as challenges_iterator: + for challenge_instance, challenge_path, challenge_repo, challenge_branch in challenges_iterator: + click.echo() + click.secho(f"Pushing '{challenge_path}' to '{challenge_repo}'", fg="blue") + + if use_subrepo: + cmd = ["git", "subrepo", "push", challenge_path] + else: + cmd = ["git", "subtree", "push", "--prefix", challenge_path, challenge_repo, challenge_branch] - def restore(self, challenge=None): - config = load_config() - challenges = dict(config["challenges"]) - for folder, url in challenges.items(): - if url.endswith(".git"): - if challenge is not None and folder != challenge: + log.debug(f"call({cmd}, cwd='{config.project_path}')") + if subprocess.call(cmd, cwd=config.project_path) != 0: + click.secho( + "Could not push the challenge repository. Please check git error messages above.", + fg="red", + ) + failed_pushes.append(challenge_instance) continue - click.echo(f"Adding git repo {url} to {folder} as subtree") - head_branch = get_git_repo_head_branch(url) - subprocess.call( - [ + + if len(failed_pushes) == 0: + if not quiet: + click.secho("Success! All challenges pushed!", fg="green") + + return 0 + + if not quiet: + click.secho("Push failed for:", fg="red") + for challenge in failed_pushes: + click.echo(f" - {challenge}") + + return 1 + + def pull(self, challenge: str | None = None, strategy: str = "fast-forward", quiet: bool = False) -> int: + log.debug(f"pull: (challenge={challenge}, quiet={quiet})") + config = Config() + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + challenges = [challenge_instance] + else: + challenges = self._resolve_all_challenges() + + if quiet or len(challenges) <= 1: + context = contextlib.nullcontext(challenges) + else: + context = click.progressbar(challenges, label="Pulling challenges") + + use_subrepo = config["config"].getboolean("use_subrepo", fallback=False) + if use_subrepo and not check_if_git_subrepo_is_installed(): + click.secho("This project is configured to use git subrepo, but it's not installed.") + return 1 + + failed_pulls = [] + with context as context_challenges: + for challenge_instance in context_challenges: + click.echo() + + # Get a relative path from project root to the challenge + # As this is what git subtree push requires + challenge_path = challenge_instance.challenge_directory.resolve().relative_to(config.project_path) + challenge_repo = config.challenges.get(str(challenge_path), None) + + # if we don't find the challenge by the directory, + # check if it's saved with a direct path to challenge.yml + if not challenge_repo: + challenge_repo = config.challenges.get(str(challenge_path / "challenge.yml"), None) + + if not challenge_repo: + click.secho( + f"Could not find added challenge '{challenge_path}' " + "Please check that the challenge is added to .ctf/config and that your path matches", + fg="red", + ) + failed_pulls.append(challenge_instance) + continue + + challenge_repo, challenge_branch = resolve_repo_url(challenge_repo) + + if not challenge_repo.endswith(".git"): + click.secho( + f"Cannot pull challenge '{challenge_path}', as it's not a git-based challenge", + fg="yellow", + ) + failed_pulls.append(challenge_instance) + continue + + click.secho(f"Pulling latest '{challenge_repo}' to '{challenge_path}'", fg="blue") + + pull_env = os.environ.copy() + if use_subrepo: + cmd = ["git", "subrepo", "pull", challenge_path] + + if strategy == "rebase": + cmd += ["--rebase"] + elif strategy == "merge": + cmd += ["--merge"] + elif strategy == "force": + cmd += ["--force"] + elif strategy == "fast-forward": + pass # fast-forward is the default strategy + else: + click.secho(f"Cannot pull challenge - '{strategy}' is not a valid pull strategy", fg="red") + else: + pull_env["GIT_MERGE_AUTOEDIT"] = "no" + cmd = [ "git", "subtree", - "add", + "pull", "--prefix", - folder, - url, - head_branch, + challenge_path, + challenge_repo, + challenge_branch, "--squash", - ], - cwd=get_project_path(), + ] + + log.debug(f"call({cmd}, cwd='{config.project_path})") + if subprocess.call(cmd, cwd=config.project_path, env=pull_env) != 0: + click.secho( + f"Could not pull the subtree for challenge '{challenge_path}'. " + "Please check git error messages above.", + fg="red", + ) + failed_pulls.append(challenge_instance) + continue + + if not use_subrepo: + log.debug(f"call(['git', 'mergetool'], cwd='{config.project_path / challenge_path}')") + git_mergetool = subprocess.call(["git", "mergetool"], cwd=config.project_path / challenge_path) + + log.debug(f"call(['git', 'commit', '--no-edit'], cwd='{config.project_path / challenge_path}')") + subprocess.call(["git", "commit", "--no-edit"], cwd=config.project_path / challenge_path) + + log.debug(f"call(['git', 'clean', '-f'], cwd='{config.project_path / challenge_path}')") + git_clean = subprocess.call(["git", "clean", "-f"], cwd=config.project_path / challenge_path) + + # git commit is allowed to return a non-zero code + # because it would also mean that there's nothing to commit + if any(r != 0 for r in [git_mergetool, git_clean]): + click.secho( + f"Could not commit the changes for challenge '{challenge_path}'. " + "Please check git error messages above.", + fg="red", + ) + failed_pulls.append(challenge_instance) + continue + + if len(failed_pulls) == 0: + if not quiet: + click.secho("Success! All challenges pulled!", fg="green") + return 0 + + if not quiet: + click.secho("Pull failed for:", fg="red") + for challenge in failed_pulls: + click.echo(f" - {challenge}") + + return 1 + + def restore(self, challenge: str | None = None) -> int: + log.debug(f"restore: (challenge={challenge})") + config = Config() + + if len(config.challenges.items()) == 0: + click.secho("Could not find any added challenges to restore", fg="yellow") + return 1 + + use_subrepo = config["config"].getboolean("use_subrepo", fallback=False) + if use_subrepo and not check_if_git_subrepo_is_installed(): + click.secho("This project is configured to use git subrepo, but it's not installed.") + return 1 + + failed_restores = [] + for challenge_key, challenge_source in config.challenges.items(): + if challenge is not None and challenge_key != challenge: + continue + + if not challenge_source.endswith(".git"): + click.secho( + f"Skipping restore of '{challenge_key}', as it's not a git-based challenge", + fg="yellow", ) - else: - click.echo(f"Skipping {url} - {folder}") + continue + + # Check if we have a target directory, or the challenge is saved as a reference to challenge.yml. + # We cannot restore this, as we don't know the root of the challenge to pull the subtree + if challenge_key.endswith(".yml"): + click.secho( + f"Skipping restore of '{challenge_key}', as it was added with a custom yaml_path. " + "Please restore this challenge again manually", + fg="yellow", + ) + failed_restores.append(challenge_key) + continue + + # If we're using subrepo - the restore can be achieved by performing a force pull + if use_subrepo: + if self.pull(challenge, strategy="force") != 0: + click.secho( + f"Failed to restore challenge '{challenge_key}' via subrepo force pull. " + "Please check git error messages above.", + fg="red", + ) + failed_restores.append(challenge_key) + + continue + + # Otherwise - default to restoring the repository via re-adding the subtree + # Check if target directory exits + if (config.project_path / challenge_key).exists(): + click.secho( + f"Skipping restore of '{challenge_key}', as the target directory exists. " + "Please remove this directory and retry restore.", + fg="yellow", + ) + failed_restores.append(challenge_key) + continue + + click.secho( + f"Restoring git repo '{challenge_source}' to '{challenge_key}'", + fg="blue", + ) + + challenge_source, challenge_branch = resolve_repo_url(challenge_source) + + log.debug( + f"call(['git', 'subtree', 'add', '--prefix', '{challenge_key}', '{challenge_source}', " + f"'{challenge_branch}', '--squash'], cwd='{config.project_path}')" + ) + git_subtree_add = subprocess.call( + [ + "git", + "subtree", + "add", + "--prefix", + challenge_key, + challenge_source, + challenge_branch, + "--squash", + ], + cwd=config.project_path, + ) - def install(self, challenge=None, force=False, ignore=()): - if challenge is None: - # Get all challenges if not specifying a challenge - config = load_config() - challenges = dict(config["challenges"]).keys() + if git_subtree_add != 0: + click.secho( + f"Could not restore the subtree for challenge '{challenge_key}'. " + "Please check git error messages above.", + fg="red", + ) + failed_restores.append(challenge_key) + + if len(failed_restores) == 0: + click.secho("Success! All challenges restored!", fg="green") + return 0 + + click.secho("Restore failed for:", fg="red") + for challenge in failed_restores: + click.echo(f" - {challenge}") + + return 1 + + def install( + self, challenge: str | None = None, force: bool = False, hidden: bool = False, ignore: str | tuple[str] = () + ) -> int: + log.debug(f"install: (challenge={challenge}, force={force}, hidden={hidden}, ignore={ignore})") + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + local_challenges = [challenge_instance] else: - challenges = [challenge] + local_challenges = self._resolve_all_challenges() if isinstance(ignore, str): ignore = (ignore,) - for challenge in challenges: - path = Path(challenge) + _config = Config() + remote_challenges = Challenge.load_installed_challenges() - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + failed_installs = [] + with click.progressbar(local_challenges, label="Installing challenges") as challenges: + for challenge_instance in challenges: + click.echo() - click.secho(f"Found {path}") - challenge = load_challenge(path) - click.secho(f'Loaded {challenge["name"]}', fg="yellow") + if hidden: + challenge_instance["state"] = "hidden" - installed_challenges = load_installed_challenges() - for c in installed_challenges: - if c["name"] == challenge["name"]: - click.secho( - f'Already found existing challenge with same name ({challenge["name"]}). Perhaps you meant sync instead of install?', - fg="red", - ) - if force is True: + click.secho( + f"Installing '{challenge_instance}' ({challenge_instance.challenge_file_path}) ...", + fg="blue", + ) + + found_duplicate = False + for remote_challenge in remote_challenges: + if remote_challenge["name"] == challenge_instance["name"]: click.secho( - "Ignoring existing challenge because of --force", - fg="yellow", + f"Found already existing challenge with the same name ({remote_challenge['name']}). " + "Perhaps you meant sync instead of install?", + fg="red", ) - else: + found_duplicate = True break - else: # If we don't break because of duplicated challenge names - click.secho(f'Installing {challenge["name"]}', fg="yellow") - create_challenge(challenge=challenge, ignore=ignore) - click.secho(f"Success!", fg="green") - - def sync(self, challenge=None, ignore=()): - if challenge is None: - # Get all challenges if not specifying a challenge - config = load_config() - challenges = dict(config["challenges"]).keys() + + if found_duplicate: + if not force: + failed_installs.append(challenge_instance) + continue + + click.secho("Syncing existing challenge instead (because of --force)", fg="yellow") + try: + challenge_instance.sync(ignore=ignore) + except ChallengeException as e: + click.secho("Failed to sync challenge", fg="red") + click.secho(str(e), fg="red") + failed_installs.append(challenge_instance) + + continue + + # If we don't break because of duplicated challenge names - continue the installation + try: + challenge_instance.create(ignore=ignore) + except ChallengeException as e: + click.secho("Failed to install challenge", fg="red") + click.secho(str(e), fg="red") + failed_installs.append(challenge_instance) + + if len(failed_installs) == 0: + click.secho("Success! All challenges installed!", fg="green") + return 0 + + click.secho("Install failed for:", fg="red") + for challenge_instance in failed_installs: + click.echo(f" - {challenge_instance}") + + return 1 + + def sync(self, challenge: str | None = None, ignore: str | tuple[str] = ()) -> int: + log.debug(f"sync: (challenge={challenge}, ignore={ignore})") + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + local_challenges = [challenge_instance] else: - challenges = [challenge] + local_challenges = self._resolve_all_challenges() if isinstance(ignore, str): ignore = (ignore,) - for challenge in challenges: - path = Path(challenge) + _config = Config() + remote_challenges = Challenge.load_installed_challenges() - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + failed_syncs = [] + with click.progressbar(local_challenges, label="Syncing challenges") as challenges: + for challenge_instance in challenges: + click.echo() - click.secho(f"Found {path}") - challenge = load_challenge(path) - click.secho(f'Loaded {challenge["name"]}', fg="yellow") + challenge_name = challenge_instance["name"] + if not any(c["name"] == challenge_name for c in remote_challenges): + click.secho( + f"Could not find existing challenge {challenge_name}. " + f"Perhaps you meant install instead of sync?", + fg="red", + ) + failed_syncs.append(challenge_instance) + continue - installed_challenges = load_installed_challenges() - for c in installed_challenges: - if c["name"] == challenge["name"]: - break - else: click.secho( - f'Couldn\'t find existing challenge {challenge["name"]}. Perhaps you meant install instead of sync?', - fg="red", + f"Syncing '{challenge_name}' ({challenge_instance.challenge_file_path}) ...", + fg="blue", ) - continue # Go to the next challenge in the overall list + try: + challenge_instance.sync(ignore=ignore) + except ChallengeException as e: + click.secho("Failed to sync challenge", fg="red") + click.secho(str(e), fg="red") + failed_syncs.append(challenge_instance) - click.secho(f'Syncing {challenge["name"]}', fg="yellow") - sync_challenge(challenge=challenge, ignore=ignore) - click.secho(f"Success!", fg="green") + if len(failed_syncs) == 0: + click.secho("Success! All challenges synced!", fg="green") + return 0 - def update(self, challenge=None): - config = load_config() - challenges = dict(config["challenges"]) - for folder, url in challenges.items(): - if challenge and challenge != folder: - continue - if url.endswith(".git"): - click.echo(f"Pulling latest {url} to {folder}") - head_branch = get_git_repo_head_branch(url) - subprocess.call( - [ - "git", - "subtree", - "pull", - "--prefix", - folder, - url, - head_branch, - "--squash", - ], - cwd=get_project_path(), - ) - subprocess.call(["git", "mergetool"], cwd=folder) - subprocess.call(["git", "clean", "-f"], cwd=folder) - subprocess.call(["git", "commit", "--no-edit"], cwd=folder) + click.secho("Sync failed for:", fg="red") + for challenge in failed_syncs: + click.echo(f" - {challenge}") + + return 1 + + def deploy( + self, + challenge: str | None = None, + host: str | None = None, + skip_login: bool = False, + ) -> int: + log.debug(f"deploy: (challenge={challenge}, host={host}, skip_login={skip_login})") + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + challenges = [challenge_instance] + else: + challenges = self._resolve_all_challenges() + + deployable_challenges, failed_deployments, skipped_deployments, failed_syncs = [], [], [], [] + + # get challenges which can be deployed (have an image) + for challenge_instance in challenges: + if challenge_instance.get("image"): + deployable_challenges.append(challenge_instance) else: - click.echo(f"Skipping {url} - {folder}") + skipped_deployments.append(challenge_instance) - def finalize(self, challenge=None): - if challenge is None: - challenge = os.getcwd() + _config = Config() + with click.progressbar(deployable_challenges, label="Deploying challenges") as challenges: + for challenge_instance in challenges: + click.echo() - path = Path(challenge) - spec = blank_challenge_spec() - for k in spec: - q = CHALLENGE_SPEC_DOCS.get(k) - fields = q._asdict() + challenge_name = challenge_instance.get("name") + target_host = host or challenge_instance.get("host") - ask = False - required = fields.pop("required", False) - if required is False: - try: - ask = click.confirm(f"Would you like to add the {k} field?") - if ask is False: + # Default to cloud deployment if host is not specified + scheme = "cloud" + if bool(target_host): + url = urlparse(target_host) + if not bool(url.netloc): + click.secho( + f"Host for challenge service '{challenge_name}' has no URI scheme - {target_host}. " + "Provide a URI scheme like ssh:// or registry://", + fg="red", + ) continue - except click.Abort: - click.echo("\n") + + scheme = url.scheme + + deployment_handler = get_deployment_handler(scheme)( + challenge_instance, host=host, protocol=challenge_instance.get("protocol") + ) + + click.secho( + f"Deploying challenge service '{challenge_name}' " + f"({challenge_instance.challenge_file_path}) " + f"with {deployment_handler.__class__.__name__} ...", + fg="blue", + ) + + deployment_result = deployment_handler.deploy(skip_login=skip_login) + + # Save connection_info from the deployment result if returned + if deployment_result.connection_info: + click.secho("Saving connection_info in challenge.yml", fg="yellow") + challenge_instance["connection_info"] = deployment_result.connection_info + + # If no connection_info was provided by the challenge + # and the deployment didn't result in one either, just ensure it's not present + elif not challenge_instance.get("connection_info"): + challenge_instance["connection_info"] = None + + if not deployment_result.success: + click.secho("An error occurred during service deployment!", fg="red") + failed_deployments.append(challenge_instance) continue - if ask is True: - fields["text"] = "\t" + fields["text"] + installed_challenges = Challenge.load_installed_challenges() + existing_challenge = next( + (c for c in installed_challenges if c["name"] == challenge_instance["name"]), + None, + ) + + if challenge_instance.get("connection_info"): + click.secho( + f"Challenge service deployed at: {challenge_instance['connection_info']}", + fg="green", + ) + + challenge_instance.save() # Save the challenge with the new connection_info + else: + click.secho( + "Could not resolve a connection_info for the deployed service.\n" + "If your DeploymentHandler does not return a connection_info, " + "make sure to provide one in the challenge.yml file.", + fg="yellow", + ) - multiple = fields.pop("multiple", False) - if multiple: - fields["text"] += " (Ctrl-C to continue)" - spec[k] = [] - try: - while True: - r = click.prompt(**fields) - spec[k].append(r) - except click.Abort: - click.echo("\n") - else: try: - r = click.prompt(**fields) - spec[k] = r - except click.Abort: - click.echo("\n") + if existing_challenge: + click.secho(f"Updating challenge '{challenge_name}'", fg="blue") + challenge_instance.sync( + ignore=["flags", "topics", "tags", "files", "hints", "requirements", "state"] + ) + else: + click.secho(f"Creating challenge '{challenge_name}'", fg="blue") + challenge_instance.create() + + except ChallengeException as e: + click.secho( + "Challenge service has been deployed, however the challenge could not be " + f"{'synced' if existing_challenge else 'created'}", + fg="red", + ) + click.secho(str(e), fg="red") + failed_syncs.append(challenge_instance) + + click.secho("Success!\n", fg="green") + + if len(skipped_deployments) > 0: + click.secho("Deployment skipped (no image specified) for:", fg="yellow") + for challenge_instance in skipped_deployments: + click.echo(f" - {challenge_instance}") + + if len(failed_deployments) == 0 and len(failed_syncs) == 0: + click.secho( + "Success! All deployable challenges deployed and installed or synced.", + fg="green", + ) + return 0 - with open(path / "challenge.yml", "w+") as f: - yaml.dump(spec, stream=f, default_flow_style=False, sort_keys=False) + if len(failed_deployments) > 0: + click.secho("Deployment failed for:", fg="red") + for challenge_instance in failed_deployments: + click.echo(f" - {challenge_instance}") - print("challenge.yml written to", path / "challenge.yml") + if len(failed_syncs) > 0: + click.secho("Install / Sync failed for:", fg="red") + for challenge_instance in failed_deployments: + click.echo(f" - {challenge_instance}") - def lint(self, challenge=None): - if challenge is None: - challenge = os.getcwd() + return 1 - path = Path(challenge) + def lint( + self, + challenge: str | None = None, + skip_hadolint: bool = False, + flag_format: str = "flag{", + ) -> int: + log.debug(f"lint: (challenge={challenge}, skip_hadolint={skip_hadolint}, flag_format='{flag_format}')") - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 - lint_challenge(path) + click.secho(f"Loaded {challenge_instance}", fg="blue") + try: + challenge_instance.lint(skip_hadolint=skip_hadolint, flag_format=flag_format) + except LintException as e: + click.secho("Linting found issues!\n", fg="yellow") + e.print_summary() + return 1 - def deploy(self, challenge, host=None): - if challenge is None: - challenge = os.getcwd() + click.secho("Success! Lint didn't find any issues!", fg="green") + return 0 - path = Path(challenge) + def healthcheck(self, challenge: str | None = None) -> int: + log.debug(f"healthcheck: (challenge={challenge})") - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 - challenge = load_challenge(path) - image = challenge.get("image") - target_host = host or challenge.get("host") or input("Target host URI: ") - if image is None: + click.secho(f"Loaded {challenge_instance}", fg="blue") + healthcheck = challenge_instance.get("healthcheck", None) + if not healthcheck: click.secho( - "This challenge can't be deployed because it doesn't have an associated image", + f"Challenge '{challenge_instance}' does not define a healthcheck.", fg="red", ) - return - if bool(target_host) is False: + return 1 + + # Get challenges installed from CTFd and try to find our challenge + remote_challenges = Challenge.load_installed_challenges() + + challenge_id = None + for remote_challenge in remote_challenges: + if challenge_instance["name"] == remote_challenge["name"]: + challenge_id = remote_challenge["id"] + + if challenge_id is None: click.secho( - "This challenge can't be deployed because there is no target host to deploy to", + f"Could not find existing challenge '{challenge_instance}'. " + f"Challenge needs to be installed and deployed to run a healthcheck.", fg="red", ) - return - url = urlparse(target_host) + return 1 - if bool(url.netloc) is False: + try: + challenge_data = Challenge.load_installed_challenge(challenge_id) + except RemoteChallengeNotFound: + click.secho(f"Could not load data for challenge '{challenge_instance}'.", fg="red") + return 1 + + connection_info = challenge_data.get("connection_info") + if not connection_info: click.secho( - "Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://", + f"Challenge '{challenge_instance}' does not provide connection info. " + "Perhaps it needs to be deployed first?", fg="red", ) - return + return 1 - status, domain, port = DEPLOY_HANDLERS[url.scheme]( - challenge=challenge, host=target_host + log.debug( + f"call(['{healthcheck}', '--connection-info', '{connection_info}'], " + f"cwd='{challenge_instance.challenge_directory}')" + ) + healthcheck_status = subprocess.call( + [healthcheck, "--connection-info", connection_info], + cwd=challenge_instance.challenge_directory, ) - if status: - click.secho( - f"Challenge deployed at {domain}:{port}", fg="green", - ) + if healthcheck_status != 0: + click.secho("Healthcheck failed!", fg="red") + return 1 + + click.secho("Success! Challenge passed the healthcheck.", fg="green") + return 0 + + def mirror( + self, + challenge: str | None = None, + files_directory: str = "dist", + skip_verify: bool = False, + ignore: str | tuple[str] = (), + create: bool = False, + ) -> int: + log.debug( + f"mirror: (challenge={challenge}, files_directory={files_directory}, " + f"skip_verify={skip_verify}, ignore={ignore})" + ) + config = Config() + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + local_challenges = [challenge_instance] else: - click.secho( - f"An error occured during deployment", fg="red", - ) + local_challenges = self._resolve_all_challenges() - def push(self, challenge=None): - config = load_config() - challenges = dict(config["challenges"]) - if challenge is None: - # Get relative path from project root to current directory - challenge_path = Path(os.path.relpath(os.getcwd(), get_project_path())) - challenge = str(challenge_path) + if isinstance(ignore, str): + ignore = (ignore,) - try: - url = challenges[challenge] - head_branch = get_git_repo_head_branch(url) - subprocess.call( - ["git", "subtree", "push", "--prefix", challenge, url, head_branch], - cwd=get_project_path(), - ) - except KeyError: - click.echo( - "Couldn't process that challenge path. Please check that the challenge is added to .ctf/config and that your path matches." - ) + remote_challenges = Challenge.load_installed_challenges() + + # Issue a warning if there are extra challenges on the remote that do not have a local version + local_challenge_names = [c["name"] for c in local_challenges] + for remote_challenge in remote_challenges: + if remote_challenge["name"] not in local_challenge_names: + click.secho( + f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config", + fg="yellow", + ) + if create: + click.secho( + f"Mirroring '{remote_challenge['name']}' to local due to --create", + fg="yellow", + ) + challenge_instance = Challenge.clone(config=config, remote_challenge=remote_challenge) + challenge_instance.mirror(files_directory_name=files_directory, ignore=ignore) + + failed_mirrors = [] + with click.progressbar(local_challenges, label="Mirroring challenges") as challenges: + for challenge_instance in challenges: + try: + if not skip_verify and challenge_instance.verify(ignore=ignore): + click.secho( + f"Challenge '{challenge_instance}' is already in sync. Skipping mirroring.", + fg="blue", + ) + else: + # if skip_verify is True or challenge.verify(ignore=ignore) is False + challenge_instance.mirror(files_directory_name=files_directory, ignore=ignore) - def healthcheck(self, challenge): - config = load_config() - challenges = config["challenges"] + except ChallengeException as e: + click.secho(str(e), fg="red") + failed_mirrors.append(challenge_instance) - # challenge_path = challenges[challenge] - path = Path(challenge) - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + if len(failed_mirrors) == 0: + click.secho("Success! All challenges mirrored!", fg="green") + return 0 - challenge = load_challenge(path) - click.secho(f'Loaded {challenge["name"]}', fg="yellow") - try: - healthcheck = challenge["healthcheck"] - except KeyError: - click.secho(f'{challenge["name"]} missing healthcheck parameter', fg="red") - return + click.secho("Mirror failed for:", fg="red") + for challenge_instance in failed_mirrors: + click.echo(f" - {challenge_instance}") - # Get challenges installed from CTFd and try to find our challenge - installed_challenges = load_installed_challenges() - target = None - for c in installed_challenges: - if c["name"] == challenge["name"]: - target = c - break + return 1 + + def verify(self, challenge: str | None = None, ignore: tuple[str] = ()) -> int: + log.debug(f"verify: (challenge={challenge}, ignore={ignore})") + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + local_challenges = [challenge_instance] else: - click.secho( - f'Couldn\'t find challenge {c["name"]} on CTFd', fg="red", - ) - return + local_challenges = self._resolve_all_challenges() + + if isinstance(ignore, str): + ignore = (ignore,) - # Get the actual challenge data - installed_challenge = load_installed_challenge(target["id"]) - connection_info = installed_challenge["connection_info"] + remote_challenges = Challenge.load_installed_challenges() + if len(local_challenges) > 1: + # Issue a warning if there are extra challenges on the remote that do not have a local version + local_challenge_names = [c["name"] for c in local_challenges] - # Run healthcheck - if connection_info: - rcode = subprocess.call( - [healthcheck, "--connection-info", connection_info], cwd=path.parent - ) + for remote_challenge in remote_challenges: + if remote_challenge["name"] not in local_challenge_names: + click.secho( + f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config\n" + "Please add the local challenge if you wish to manage it with ctfcli\n", + fg="yellow", + ) + + failed_verifications, challenges_in_sync, challenges_out_of_sync = [], [], [] + with click.progressbar(local_challenges, label="Verifying challenges") as challenges: + for challenge_instance in challenges: + try: + if not challenge_instance.verify(ignore=ignore): + challenges_out_of_sync.append(challenge_instance) + else: + challenges_in_sync.append(challenge_instance) + + except ChallengeException as e: + click.secho(str(e), fg="red") + failed_verifications.append(challenge_instance) + + if len(failed_verifications) == 0: + click.secho("Success! All challenges verified!", fg="green") + + if len(challenges_in_sync) > 0: + click.secho("Challenges in sync:", fg="green") + for challenge_instance in challenges_in_sync: + click.echo(f" - {challenge_instance}") + + if len(challenges_out_of_sync) > 0: + click.secho("Challenges out of sync:", fg="yellow") + for challenge_instance in challenges_out_of_sync: + click.echo(f" - {challenge_instance}") + + if len(challenges_out_of_sync) > 1: + return 2 + + return 1 + + click.secho("Verification failed for:", fg="red") + for challenge_instance in failed_verifications: + click.echo(f" - {challenge_instance}") + + return 1 + + def format(self, challenge: str | None = None) -> int: + log.debug(f"format: (challenge={challenge})") + + if challenge: + challenge_instance = self._resolve_single_challenge(challenge) + if not challenge_instance: + return 1 + + challenges = [challenge_instance] else: - rcode = subprocess.call([healthcheck], cwd=path.parent) + challenges = self._resolve_all_challenges() - if rcode != 0: - click.secho( - f"Healcheck failed", fg="red", - ) - sys.exit(1) + failed_formats = [] + for challenge_instance in challenges: + try: + # save the challenge without changes to trigger the format + challenge_instance.save() + + except ChallengeException as e: + click.secho(str(e), fg="red") + failed_formats.append(challenge_instance) + continue + + if len(failed_formats) == 0: + click.secho("Success! All challenges formatted!", fg="green") + return 0 + + click.secho("Format failed for:", fg="red") + for challenge_instance in failed_formats: + click.echo(f" - {challenge_instance}") + + return 1 + + @staticmethod + def _resolve_single_challenge(challenge: str | None = None) -> Challenge | None: + # if a challenge is specified + if challenge: + # check if it's a path to challenge.yml, or the current directory + if challenge.endswith(".yml") or challenge.endswith(".yaml") or challenge == ".": + challenge_path = Path(challenge) + + # otherwise it's a name to be resolved from the config + else: + config = Config() + challenge_path = config.project_path / Path(challenge) + + # otherwise, assume it's in the current directory else: - click.secho( - f"Success", fg="green", - ) - sys.exit(0) + challenge_path = Path.cwd() + + if not challenge_path.name.endswith(".yml") and not challenge_path.name.endswith(".yaml"): + challenge_path = challenge_path / "challenge.yml" + + try: + return Challenge(challenge_path) + except ChallengeException as e: + click.secho(str(e), fg="red") + return None + + @staticmethod + def _resolve_all_challenges() -> list[Challenge]: + config = Config() + challenge_keys = config.challenges.keys() + + challenges = [] + for challenge_key in challenge_keys: + challenge_path = config.project_path / Path(challenge_key) + + if not challenge_path.name.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" + + try: + challenges.append(Challenge(challenge_path)) + except ChallengeException as e: + click.secho(str(e), fg="red") + continue + + return challenges diff --git a/ctfcli/cli/config.py b/ctfcli/cli/config.py index 4c974fc..b6cc6b6 100644 --- a/ctfcli/cli/config.py +++ b/ctfcli/cli/config.py @@ -1,3 +1,4 @@ +import logging import os import subprocess @@ -6,28 +7,50 @@ from pygments.formatters import TerminalFormatter from pygments.lexers import IniLexer, JsonLexer -from ctfcli.utils.config import get_config_path, preview_config +from ctfcli.core.config import Config +log = logging.getLogger("ctfcli.cli.config") -class Config(object): - def edit(self): + +class ConfigCommand: + def edit(self) -> int: + log.debug("edit") editor = os.getenv("EDITOR", "vi") - command = editor, get_config_path() - subprocess.call(command) - - def path(self): - click.echo(get_config_path()) - - def view(self, color=True, json=False): - config = get_config_path() - with open(config) as f: - if json is True: - config = preview_config(as_string=True) - if color: - config = highlight(config, JsonLexer(), TerminalFormatter()) - else: - config = f.read() - if color: - config = highlight(config, IniLexer(), TerminalFormatter()) - - print(config) + + log.debug(f"call(['{editor}', '{Config.get_config_path()}'])") + subprocess.call([editor, Config.get_config_path()]) + return 0 + + def path(self) -> int: + log.debug("path") + click.echo(Config.get_config_path()) + return 0 + + def show(self, color=True, json=False) -> int: + # alias for the view command + log.debug(f"show (color={color}, json={json})") + return self.view(color=color, json=json) + + def view(self, color=True, json=False) -> int: + log.debug(f"view (color={color}, json={json})") + config = Config() + + if json: + config_json = config.as_json(pretty=True) + + if color: + click.echo(highlight(config_json, JsonLexer(), TerminalFormatter())) + return 0 + + click.echo(config_json) + return 0 + + with open(config.get_config_path()) as config_file: + config_ini = config_file.read() + + if color: + click.echo(highlight(config_ini, IniLexer(), TerminalFormatter())) + return 0 + + click.echo(config_ini) + return 0 diff --git a/ctfcli/cli/instance.py b/ctfcli/cli/instance.py new file mode 100644 index 0000000..2b73ca0 --- /dev/null +++ b/ctfcli/cli/instance.py @@ -0,0 +1,70 @@ +import logging + +import click + +from ctfcli.core.config import Config +from ctfcli.core.instance.config import ServerConfig + +log = logging.getLogger("ctfcli.cli.instance") + + +class ConfigCommand: + def get(self, key): + """Get the value of a specific remote instance config key""" + log.debug(f"ConfigCommand.get: ({key=})") + return ServerConfig.get(key=key) + + def set(self, key, value): + """Set the value of a specific remote instance config key""" + log.debug(f"ConfigCommand.set: ({key=})") + ServerConfig.set(key=key, value=value) + click.secho(f"Successfully set '{key}' to '{value}'", fg="green") + + def pull(self): + """Copy remote instance configuration values to local config""" + log.debug("ConfigCommand.pull") + server_configs = ServerConfig.getall() + + config = Config() + if config.config.has_section("instance") is False: + config.config.add_section("instance") + + for k, v in server_configs.items(): + # We always store as a string because the CTFd Configs model is a string + if v == "None": + v = "null" + config.config.set("instance", k, str(v)) + + with open(config.config_path, "w+") as f: + config.write(f) + + click.secho("Successfully pulled configuration", fg="green") + + def push(self): + """Save local instance configuration values to remote CTFd instance""" + log.debug("ConfigCommand.push") + config = Config() + if config.config.has_section("instance") is False: + config.config.add_section("instance") + + configs = {} + for k in config["instance"]: + v = config["instance"][k] + if v == "null": + v = None + configs[k] = v + + failed_configs = ServerConfig.setall(configs=configs) + for f in failed_configs: + click.secho(f"Failed to push config {f}", fg="red") + + if not failed_configs: + click.secho("Successfully pushed config", fg="green") + return 0 + + return 1 + + +class InstanceCommand: + def config(self): + return ConfigCommand diff --git a/ctfcli/cli/media.py b/ctfcli/cli/media.py new file mode 100644 index 0000000..a8cecce --- /dev/null +++ b/ctfcli/cli/media.py @@ -0,0 +1,80 @@ +import os + +import click + +from ctfcli.core.api import API +from ctfcli.core.config import Config + + +class MediaCommand: + def add(self, path): + """Add local media file to config file and remote instance""" + config = Config() + if config.config.has_section("media") is False: + config.config.add_section("media") + + api = API() + + new_file = ("file", open(path, mode="rb")) # noqa: SIM115 + filename = os.path.basename(path) + location = f"media/{filename}" + file_payload = { + "type": "page", + "location": location, + } + + # Specifically use data= here to send multipart/form-data + r = api.post("/api/v1/files", files=[new_file], data=file_payload) + r.raise_for_status() + resp = r.json() + server_location = resp["data"][0]["location"] + + # Close the file handle + new_file[1].close() + + config.config.set("media", location, f"/files/{server_location}") + + with open(config.config_path, "w+") as f: + config.write(f) + + def rm(self, path): + """Remove local media file from remote server and local config""" + config = Config() + api = API() + + local_location = config["media"][path] + + remote_files = api.get("/api/v1/files?type=page").json()["data"] + for remote_file in remote_files: + if f"/files/{remote_file['location']}" == local_location: + # Delete file from server + r = api.delete(f"/api/v1/files/{remote_file['id']}") + r.raise_for_status() + + # Update local config file + del config["media"][path] + with open(config.config_path, "w+") as f: + config.write(f) + + def url(self, path): + """Get server URL for a file key""" + config = Config() + api = API() + + if config.config.has_section("media") is False: + config.config.add_section("media") + + try: + location = config["media"][path] + except KeyError: + click.secho(f"Could not locate local media '{path}'", fg="red") + return 1 + + remote_files = api.get("/api/v1/files?type=page").json()["data"] + for remote_file in remote_files: + if f"/files/{remote_file['location']}" == location: + base_url = config["config"]["url"] + base_url = base_url.rstrip("/") + return f"{base_url}{location}" + click.secho(f"Could not locate remote media '{path}'", fg="red") + return 1 diff --git a/ctfcli/cli/pages.py b/ctfcli/cli/pages.py index 8d94005..5bb155e 100644 --- a/ctfcli/cli/pages.py +++ b/ctfcli/cli/pages.py @@ -1,44 +1,94 @@ -import sys -from pathlib import Path -import frontmatter +import logging import click -from ctfcli.utils.config import load_config -from ctfcli.utils.pages import ( - get_current_pages, - get_existing_page, - sync_page, - install_page, -) +from ctfcli.core.exceptions import PageException +from ctfcli.core.page import Page +log = logging.getLogger("ctfcli.cli.pages") -class Pages(object): - def install(self): + +class PagesCommand: + # _page_operation is a wrapper to calling Page methods, to avoid code duplication, it will: + # - log the operation + # - perform it on the page object, with given arguments + # - handle the possible exception and echo it out with click + # - return right exit code + def _page_operation(self, page: Page, operation: str, *args, **kwargs) -> int: + if operation not in ["push", "pull", "sync"]: + raise ValueError + + page_operation = getattr(page, operation) try: - _config = load_config() - except: - click.secho("No ctfcli configuration found", fg="red") - sys.exit(1) - - pages = Path("./pages") - if pages.is_dir() is False: - click.secho( - f'"pages" folder not found. All pages must exist in the "pages" folder.', - fg="red", + log.debug(f"{operation}: {page}") + page_operation(*args, **kwargs) + click.secho(f"Successfully {operation}ed page '{page.page_file_path}'", fg="green") + return 0 + except PageException as e: + click.secho(str(e), fg="red") + return 1 + + def push(self, page: str | None = None) -> int: + pages = Page.get_local_pages() + + if page: + page_object = next( + (page_obj for page_obj in pages if page == page_obj.page_file_path), + None, ) - sys.exit(1) - else: - current_pages = get_current_pages() - - pagefiles = list(pages.glob("**/*.md")) + list(pages.glob("**/*.html")) - for path_obj in pagefiles: - page = frontmatter.load(path_obj) - existing_page = get_existing_page( - route=page["route"], pageset=current_pages - ) - - if existing_page: - sync_page(page, path_obj, existing_page["id"]) - else: - install_page(page, path_obj) + if page_object: + return self._page_operation(page_object, "push") + + click.secho(f"Could not find page '{page}'", fg="red") + return 1 + + return_code = 0 + for page_object in pages: + status = self._page_operation(page_object, "push") + + if status == 1: + return_code = 1 + + return return_code + + def sync(self, page: str | None = None) -> int: + pages = Page.get_local_pages() + + if page: + page_object = next( + (page_obj for page_obj in pages if page == page_obj.page_file_path), + None, + ) + if page_object: + return self._page_operation(page_object, "sync") + + click.secho(f"Could not find page '{page}'", fg="red") + return 1 + + return_code = 0 + for page_object in pages: + status = self._page_operation(page_object, "sync") + + if status == 1: + return_code = 1 + + return return_code + + def pull(self, route: str | None = None, force=False) -> int: + if route: + page_id = Page.get_remote_page_id(route) + if not page_id: + click.secho(f"Could not find page with route '{route}'", fg="red") + return 1 + + page_object = Page(page_id=page_id) + return self._page_operation(page_object, "pull", overwrite=force) + + return_code = 0 + pages = Page.get_remote_pages() + for page_object in pages: + status = self._page_operation(page_object, "pull", overwrite=force) + if status == 1: + return_code = 1 + + return return_code diff --git a/ctfcli/cli/plugins.py b/ctfcli/cli/plugins.py index e07c0a2..c6d899c 100644 --- a/ctfcli/cli/plugins.py +++ b/ctfcli/cli/plugins.py @@ -1,40 +1,99 @@ -import os +import logging import shutil import subprocess +from pathlib import Path import click -from ctfcli.utils.plugins import get_plugin_dir +from ctfcli.core.config import Config +log = logging.getLogger("ctfcli.cli.plugins") -class Plugins(object): - def install(self, url): - local_dir = os.path.join( - get_plugin_dir(), os.path.basename(url).rsplit(".", maxsplit=1)[0] - ) - subprocess.call(["git", "clone", url, local_dir]) - pip = shutil.which("pip") - pip3 = shutil.which("pip3") +class PluginsCommand: + @staticmethod + def install(plugin_url: str) -> int: + log.debug(f"install: {plugin_url}") - if pip is None and pip3 is None: - click.secho(f"Neither pip nor pip3 was found, is it in the PATH?", fg="red") - return + plugins_path = Config.get_plugins_path() + if not plugin_url.endswith(".git"): + click.secho( + f"Can only install plugins from git repositories - {plugin_url} does not end with .git", + fg="red", + ) + return 1 - if pip is None: - pip = pip3 + installed_plugin_path = plugins_path / Path(plugin_url).stem + log.debug(f"call(['git', 'clone', '{plugin_url}', '{installed_plugin_path}'])") + git_clone = subprocess.call(["git", "clone", plugin_url, installed_plugin_path]) - subprocess.call( - [pip, "install", "-r", os.path.join(local_dir, "requirements.txt")] - ) + if git_clone != 0: + click.secho( + "Failed to clone the plugin repository. Please check git output above.", + fg="red", + ) + return 1 - def uninstall(self, plugin_name): - plugin_dir = os.path.join(get_plugin_dir(), plugin_name) - shutil.rmtree(plugin_dir) + requirements_path = installed_plugin_path / "requirements.txt" + if requirements_path.exists(): + pip = shutil.which("pip") + pip3 = shutil.which("pip3") - def list(self): - for plugin in sorted(os.listdir(get_plugin_dir())): - print(plugin) + if pip is None and pip3 is None: + click.secho("Neither pip nor pip3 was found, is it in the PATH?", fg="red") + return 1 - def dir(self): - print(get_plugin_dir()) + if pip is None: + pip = pip3 + + log.debug(f"call(['{pip}', 'install', '-r', '{requirements_path}'])") + pip_install = subprocess.call([pip, "install", "-r", requirements_path]) + + if pip_install != 0: + click.secho( + "Failed to install plugin requirements. Please check pip output above.", + fg="red", + ) + return 1 + + return 0 + + @staticmethod + def uninstall(plugin_name: str) -> int: + log.debug(f"uninstall: {plugin_name}") + + plugins_path = Config.get_plugins_path() + plugin_path = plugins_path / plugin_name + + if not plugin_path.exists(): + click.secho(f"Could not find plugin {plugin_name} in {plugins_path}", fg="red") + return 1 + + shutil.rmtree(plugin_path) + return 0 + + @staticmethod + def list() -> int: + log.debug("list") + + installed_plugins = sorted(Config.get_plugins_path().iterdir()) + if len(installed_plugins) == 0: + click.secho("Found no installed plugins", fg="blue") + return 0 + + click.secho("List of installed plugins:", fg="blue") + for plugin in installed_plugins: + click.echo(f" - {plugin}") + + return 0 + + @staticmethod + def dir() -> int: + log.debug("dir") + return PluginsCommand.path() + + @staticmethod + def path() -> int: + log.debug("path") + click.echo(Config.get_plugins_path()) + return 0 diff --git a/ctfcli/cli/templates.py b/ctfcli/cli/templates.py index b248c9f..8bdf0a4 100644 --- a/ctfcli/cli/templates.py +++ b/ctfcli/cli/templates.py @@ -1,36 +1,92 @@ -import os +import logging import shutil import subprocess from glob import glob from pathlib import Path -from ctfcli.utils.config import get_base_path -from ctfcli.utils.templates import get_template_dir +import click +from ctfcli.core.config import Config -class Templates(object): - def install(self, url): - local_dir = os.path.join( - get_template_dir(), os.path.basename(url).rsplit(".", maxsplit=1)[0] - ) - subprocess.call(["git", "clone", url, local_dir]) +log = logging.getLogger("ctfcli.cli.templates") - def uninstall(self, template_name): - template_dir = os.path.join(get_template_dir(), template_name) - shutil.rmtree(template_dir) - def list(self): - # Print included templates - path = Path(get_base_path()) / "templates" - for dir in path.iterdir(): - print(dir.relative_to(path)) +class TemplatesCommand: + @staticmethod + def install(template_url: str) -> int: + log.debug(f"install: {template_url}") - # Print installed templates - template_dir = get_template_dir() + "/" - for template in glob(f"{template_dir}/**/*/cookiecutter.json", recursive=True): - # Remove prefix of template_dir and remove suffix of /cookiecutter.json - template = template[len(template_dir) : -len("/cookiecutter.json")] - print(template) + templates_path = Config.get_templates_path() + if not template_url.endswith(".git"): + click.secho( + f"Can only install templates from git repositories - {template_url} does not end with .git", + fg="red", + ) + return 1 - def dir(self): - print(get_template_dir()) + installed_template_path = templates_path / Path(template_url).stem + git_clone = subprocess.call(["git", "clone", template_url, installed_template_path]) + + if git_clone != 0: + click.secho( + "Failed to clone the template repository. Please check git output above.", + fg="red", + ) + return 1 + + return 0 + + @staticmethod + def uninstall(template_name: str) -> int: + log.debug(f"uninstall: {template_name}") + + templates_path = Config.get_templates_path() + template_path = templates_path / template_name + + if not template_path.exists(): + click.secho(f"Could not find template {template_name} in {templates_path}", fg="red") + return 1 + + shutil.rmtree(template_path) + return 0 + + @staticmethod + def list() -> int: + log.debug("list") + + base_path = Config.get_base_path() + templates_path = Config.get_templates_path() + + # Echo built-in templates + built_in_templates = base_path / "templates" + + click.secho("List of built-in templates:", fg="blue") + for template in built_in_templates.iterdir(): + click.echo(f" - {template.relative_to(built_in_templates)}") + + # echo a new line separator + click.echo() + + installed_templates = glob(f"{templates_path}/**/*/cookiecutter.json", recursive=True) + if len(installed_templates) == 0: + click.secho("Found no user-installed templates", fg="blue") + return 0 + + click.secho("List of user-installed templates:", fg="blue") + for template in installed_templates: + # Remove prefix of templates_path (+1 for last slash) and remove suffix of /cookiecutter.json + template_path = str(template)[len(str(templates_path)) + 1 : -len("/cookiecutter.json")] + click.echo(f" - {template_path}") + + return 0 + + @staticmethod + def dir() -> int: + log.debug("dir") + return TemplatesCommand.path() + + @staticmethod + def path() -> int: + log.debug("path") + click.echo(Config.get_templates_path()) + return 0 diff --git a/ctfcli/core/__init__.py b/ctfcli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ctfcli/core/api.py b/ctfcli/core/api.py new file mode 100644 index 0000000..9a34a2c --- /dev/null +++ b/ctfcli/core/api.py @@ -0,0 +1,65 @@ +from urllib.parse import urljoin + +from requests import Session + +from ctfcli.core.config import Config +from ctfcli.core.exceptions import MissingAPIKey, MissingInstanceURL + + +class API(Session): + def __init__(self): + config = Config() + + # Load required configuration values + try: + self.url = config["config"]["url"] + except KeyError as e: + raise MissingInstanceURL() from e + + try: + self.access_token = config["config"]["access_token"] + except KeyError as e: + raise MissingAPIKey() from e + + # Handle SSL verification disabling + try: + # Get an ssl_verify config. Default to True if it doesn't exist + ssl_verify = config["config"].getboolean("ssl_verify", True) + except ValueError: + # If we didn't a proper boolean value we should load it as a string + # https://requests.kennethreitz.org/en/master/user/advanced/#ssl-cert-verification + ssl_verify = config["config"].get("ssl_verify") + + super().__init__() + + # Strip out ending slashes and append a singular one, so we generate + # clean base URLs for both main deployments and subdir deployments + self.prefix_url = self.url.rstrip("/") + "/" + + # Handle SSL verification + self.verify = ssl_verify + + # Handle Authorization + self.headers.update({"Authorization": f"Token {self.access_token}"}) + + # Handle cookies section in config + if "cookies" in config: + self.cookies.update(dict(config["cookies"])) + + def request(self, method, url, *args, **kwargs): + # Strip out the preceding / so that urljoin creates the right url + # considering the appended / on the prefix_url + url = urljoin(self.prefix_url, url.lstrip("/")) + + # if data= is present, do not modify the content-type + if kwargs.get("data") is not None: + return super().request(method, url, *args, **kwargs) + + # otherwise set the content-type to application/json for all API requests + # modify the headers here instead of using self.headers because we don't want to + # override the multipart/form-data case above + if kwargs.get("headers") is None: + kwargs["headers"] = {} + + kwargs["headers"]["Content-Type"] = "application/json" + return super().request(method, url, *args, **kwargs) diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py new file mode 100644 index 0000000..f2e4ba1 --- /dev/null +++ b/ctfcli/core/challenge.py @@ -0,0 +1,1316 @@ +import logging +import re +import subprocess +from os import PathLike +from pathlib import Path +from typing import Any + +import click +import yaml +from cookiecutter.main import cookiecutter +from slugify import slugify + +from ctfcli.core.api import API +from ctfcli.core.exceptions import ( + ChallengeException, + InvalidChallengeDefinition, + InvalidChallengeFile, + LintException, + RemoteChallengeNotFound, +) +from ctfcli.core.image import Image +from ctfcli.utils.hashing import hash_file +from ctfcli.utils.tools import strings + +log = logging.getLogger("ctfcli.core.challenge") + + +def str_presenter(dumper, data): + if len(data.splitlines()) > 1 or "\n" in data: + text_list = [line.rstrip() for line in data.splitlines()] + fixed_data = "\n".join(text_list) + return dumper.represent_scalar("tag:yaml.org,2002:str", fixed_data, style="|") + + if len(data) > 80: + return dumper.represent_scalar("tag:yaml.org,2002:str", data.rstrip(), style=">") + + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +yaml.add_representer(str, str_presenter) +yaml.representer.SafeRepresenter.add_representer(str, str_presenter) + + +class Challenge(dict): + key_order = [ + "name", + "author", + "category", + "description", + "attribution", + "value", + "type", + "extra", + "image", + "protocol", + "host", + "connection_info", + "healthcheck", + "solution", + "attempts", + "logic", + "flags", + "files", + "topics", + "tags", + "files", + "hints", + "requirements", + "next", + "state", + "version", + ] + + keys_with_newline = [ + "extra", + "image", + "attempts", + "flags", + "topics", + "tags", + "files", + "hints", + "requirements", + "state", + "version", + ] + + @staticmethod + def load_installed_challenge(challenge_id) -> dict: + api = API() + r = api.get(f"/api/v1/challenges/{challenge_id}?view=admin") + + if not r.ok: + raise RemoteChallengeNotFound(f"Could not load challenge with id={challenge_id}") + + installed_challenge = r.json().get("data", None) + if not installed_challenge: + raise RemoteChallengeNotFound(f"Could not load challenge with id={challenge_id}") + + return installed_challenge + + @staticmethod + def load_installed_challenges() -> list: + api = API() + r = api.get("/api/v1/challenges?view=admin") + + if not r.ok: + return [] + + installed_challenges = r.json().get("data", None) + if not installed_challenges: + return [] + + return installed_challenges + + @staticmethod + def is_default_challenge_property(key: str, value: Any) -> bool: + if key == "connection_info" and value is None: + return True + + if key == "attempts" and value == 0: + return True + + if key == "state" and value == "visible": + return True + + if key == "type" and value == "standard": + return True + + if key in ["tags", "hints", "topics", "requirements", "files"] and value == []: + return True + + if key == "requirements" and value == {"prerequisites": [], "anonymize": False}: + return True + + return bool(key == "next" and value is None) + + @staticmethod + def clone(config, remote_challenge): + name = remote_challenge["name"] + + if name is None: + raise ChallengeException(f"Could not get name of remote challenge with id {remote_challenge['id']}") + + # First, generate a name for the challenge directory + category = remote_challenge.get("category", None) + challenge_dir_name = slugify(name) + if category is not None: + challenge_dir_name = str(Path(slugify(category)) / challenge_dir_name) + + if Path(challenge_dir_name).exists(): + raise ChallengeException( + f"Challenge directory '{challenge_dir_name}' for challenge '{name}' already exists" + ) + + # Create an blank/empty challenge, with only the challenge.yml containing the challenge name + template_path = config.get_base_path() / "templates" / "blank" / "empty" + log.debug(f"Challenge.clone: cookiecutter({template_path!s}, {name=}, {challenge_dir_name=}") + cookiecutter( + str(template_path), + no_input=True, + extra_context={"name": name, "dirname": challenge_dir_name}, + ) + + if not Path(challenge_dir_name).exists(): + raise ChallengeException(f"Could not create challenge directory '{challenge_dir_name}' for '{name}'") + + # Add the newly created local challenge to the config file + config["challenges"][challenge_dir_name] = challenge_dir_name + with open(config.config_path, "w+") as f: + config.write(f) + + return Challenge(f"{challenge_dir_name}/challenge.yml") + + @property + def api(self): + if not self._api: + self._api = API() + + return self._api + + # __init__ expects an absolute path to challenge_yml, or a relative one from the cwd + # it does not join that path with the project_path + def __init__(self, challenge_yml: str | PathLike, overrides=None): + log.debug(f"Challenge.__init__: ({challenge_yml=}, {overrides=}") + if overrides is None: + overrides = {} + + self.challenge_file_path = Path(challenge_yml) + + if not self.challenge_file_path.is_file(): + raise InvalidChallengeFile(f"Challenge file at {self.challenge_file_path} could not be found") + + self.challenge_directory = self.challenge_file_path.parent + + with open(self.challenge_file_path) as challenge_file: + try: + challenge_definition = yaml.safe_load(challenge_file.read()) + except yaml.YAMLError as e: + raise InvalidChallengeFile( + f"Challenge file at {self.challenge_file_path} could not be loaded:\n{e}" + ) from e + + if type(challenge_definition) != dict: + raise InvalidChallengeFile( + f"Challenge file at {self.challenge_file_path} is either empty or not a dictionary / object" + ) + + challenge_data = {**challenge_definition, **overrides} + super().__init__(challenge_data) + + # Challenge id is unknown before loading the remote challenge + self.challenge_id = None + + # API is not initialized before running an API-related operation, but should be reused later + self._api = None + + # Assign an image if the challenge provides one, otherwise this will be set to None + self.image = self._process_challenge_image(self.get("image")) + + def __str__(self): + return self["name"] + + def _process_challenge_image(self, challenge_image: str | None) -> Image | None: + if not challenge_image: + return None + + # Check if challenge_image is explicitly marked with registry:// prefix + if challenge_image.startswith("registry://"): + challenge_image = challenge_image.replace("registry://", "") + return Image(challenge_image) + + # Check if it's a library image + if challenge_image.startswith("library/"): + return Image(f"docker.io/{challenge_image}") + + # Check if it defines a known registry + known_registries = [ + "docker.io", + "gcr.io", + "ecr.aws", + "ghcr.io", + "azurecr.io", + "registry.digitalocean.com", + "registry.gitlab.com", + "registry.ctfd.io", + ] + for registry in known_registries: + if registry in challenge_image: + return Image(challenge_image) + + # Check if it's a path to dockerfile to be built + if (self.challenge_directory / challenge_image / "Dockerfile").exists(): + return Image(slugify(self["name"]), self.challenge_directory / self["image"]) + + # Check if it's a local pre-built image + if ( + subprocess.call( + ["docker", "inspect", challenge_image], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + == 0 + ): + return Image(challenge_image) + + # If the image is set, but we fail to determine whether it's local / remote - raise an exception + raise InvalidChallengeFile( + f"Challenge file at {self.challenge_file_path} defines an image, but it couldn't be resolved" + ) + + def _load_challenge_id(self): + remote_challenges = self.load_installed_challenges() + if not remote_challenges: + raise RemoteChallengeNotFound("Could not load any remote challenges") + + # get challenge id from the remote + for inspected_challenge in remote_challenges: + if inspected_challenge["name"] == self["name"]: + self.challenge_id = inspected_challenge["id"] + break + + # return if we failed to determine the challenge id (failed to find the challenge) + if self.challenge_id is None: + raise RemoteChallengeNotFound(f"Could not load remote challenge with name '{self['name']}'") + + def _validate_files(self): + files = self.get("files") or [] + for challenge_file in files: + if not (self.challenge_directory / challenge_file).exists(): + raise InvalidChallengeFile(f"File {challenge_file} could not be loaded") + + def _get_initial_challenge_payload(self, ignore: tuple[str] = ()) -> dict: + challenge = self + challenge_payload = { + "name": self["name"], + "category": self.get("category", ""), + "description": self.get("description", ""), + "attribution": self.get("attribution", ""), + "type": self.get("type", "standard"), + # Hide the challenge for the duration of the sync / creation + "state": "hidden", + } + + # Some challenge types (e.g., dynamic) override value. + # We can't send it to CTFd because we don't know the current value + if challenge.get("value", None) is not None: + # if value is an int as string, cast it + if type(challenge["value"]) == str and challenge["value"].isdigit(): + challenge_payload["value"] = int(challenge["value"]) + + if type(challenge["value"] == int): + challenge_payload["value"] = challenge["value"] + + if "attempts" not in ignore: + challenge_payload["max_attempts"] = challenge.get("attempts", 0) + + if "connection_info" not in ignore: + challenge_payload["connection_info"] = challenge.get("connection_info", None) + + if "logic" not in ignore and challenge.get("logic"): + challenge_payload["logic"] = challenge.get("logic") or "any" + + if "extra" not in ignore: + challenge_payload = {**challenge_payload, **challenge.get("extra", {})} + + return challenge_payload + + def _delete_existing_flags(self): + remote_flags = self.api.get("/api/v1/flags").json()["data"] + for flag in remote_flags: + if flag["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/flags/{flag['id']}") + r.raise_for_status() + + def _create_flags(self): + for flag in self["flags"]: + if type(flag) == str: + flag_payload = { + "content": flag, + "type": "static", + "challenge_id": self.challenge_id, + } + else: + flag_payload = {**flag, "challenge_id": self.challenge_id} + + r = self.api.post("/api/v1/flags", json=flag_payload) + r.raise_for_status() + + def _delete_existing_topics(self): + remote_topics = self.api.get(f"/api/v1/challenges/{self.challenge_id}/topics").json()["data"] + for topic in remote_topics: + r = self.api.delete(f"/api/v1/topics?type=challenge&target_id={topic['id']}") + r.raise_for_status() + + def _create_topics(self): + for topic in self["topics"]: + r = self.api.post( + "/api/v1/topics", + json={ + "value": topic, + "type": "challenge", + "challenge_id": self.challenge_id, + }, + ) + r.raise_for_status() + + def _delete_existing_tags(self): + remote_tags = self.api.get("/api/v1/tags").json()["data"] + for tag in remote_tags: + if tag["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/tags/{tag['id']}") + r.raise_for_status() + + def _create_tags(self): + for tag in self["tags"]: + r = self.api.post( + "/api/v1/tags", + json={"challenge_id": self.challenge_id, "value": tag}, + ) + r.raise_for_status() + + def _delete_file(self, remote_location: str): + remote_files = self.api.get("/api/v1/files?type=challenge").json()["data"] + + for remote_file in remote_files: + if remote_file["location"] == remote_location: + r = self.api.delete(f"/api/v1/files/{remote_file['id']}") + r.raise_for_status() + + def _create_file(self, local_path: Path): + new_file = ("file", open(local_path, mode="rb")) # noqa: SIM115 + file_payload = {"challenge_id": self.challenge_id, "type": "challenge"} + + # Specifically use data= here to send multipart/form-data + r = self.api.post("/api/v1/files", files=[new_file], data=file_payload) + r.raise_for_status() + + # Close the file handle + new_file[1].close() + + def _create_all_files(self): + new_files = [] + + files = self.get("files") or [] + for challenge_file in files: + new_files.append(("file", open(self.challenge_directory / challenge_file, mode="rb"))) # noqa: SIM115 + + files_payload = {"challenge_id": self.challenge_id, "type": "challenge"} + + # Specifically use data= here to send multipart/form-data + r = self.api.post("/api/v1/files", files=new_files, data=files_payload) + r.raise_for_status() + + # Close the file handles + for file_payload in new_files: + file_payload[1].close() + + def _delete_existing_hints(self): + remote_hints = self.api.get("/api/v1/hints").json()["data"] + for hint in remote_hints: + if hint["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/hints/{hint['id']}") + r.raise_for_status() + + def _create_hints(self): + for hint in self["hints"]: + if type(hint) == str: + hint_payload = { + "content": hint, + "title": "", + "cost": 0, + "challenge_id": self.challenge_id, + } + else: + hint_payload = { + "content": hint["content"], + "title": hint.get("title", ""), + "cost": hint.get("cost", 0), + "challenge_id": self.challenge_id, + } + + r = self.api.post("/api/v1/hints", json=hint_payload) + r.raise_for_status() + + def _parse_solution_definition(self) -> tuple[str, str] | None: + solution = self.get("solution", None) + if not solution: + return None + + if type(solution) == str: + return solution, "hidden" + + if type(solution) != dict: + click.secho( + "The solution field must be a string path or an object with path and state", + fg="red", + ) + return None + + solution_path = solution.get("path") + if type(solution_path) != str or not solution_path: + click.secho("The solution object must define a non-empty string path field", fg="red") + return None + + solution_state = solution.get("state", "hidden") + if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]: + click.secho("The solution state must be one of: hidden, visible, solved", fg="red") + return None + + return solution_path, solution_state + + def _resolve_solution_path(self) -> tuple[Path, str] | None: + parsed_solution = self._parse_solution_definition() + if not parsed_solution: + return None + + solution_path_string, solution_state = parsed_solution + solution_path = self.challenge_directory / solution_path_string + if not solution_path.is_file(): + click.secho( + f"Solution file '{solution_path_string}' specified, but not found at {solution_path}", + fg="red", + ) + return None + + return solution_path, solution_state + + def _delete_existing_solution(self): + remote_solutions = self.api.get("/api/v1/solutions").json()["data"] + for solution in remote_solutions: + if solution["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/solutions/{solution['id']}") + r.raise_for_status() + + def _get_existing_solution_id(self) -> int | None: + r = self.api.get("/api/v1/solutions") + r.raise_for_status() + remote_solutions = r.json().get("data") or [] + for solution in remote_solutions: + if solution["challenge_id"] == self.challenge_id: + return solution["id"] + return None + + def _create_solution(self): + resolved_solution = self._resolve_solution_path() + if not resolved_solution: + return + solution_path, solution_state = resolved_solution + + solution_id = self._get_existing_solution_id() + if solution_id is None: + solution_payload_create = {"challenge_id": self.challenge_id, "state": solution_state, "content": ""} + + r = self.api.post("/api/v1/solutions", json=solution_payload_create) + r.raise_for_status() + solution_id = r.json()["data"]["id"] + else: + # Keep solution state in sync and clear stale content before rebuilding references. + r = self.api.patch( + f"/api/v1/solutions/{solution_id}", + json={"state": solution_state, "content": ""}, + ) + r.raise_for_status() + + with solution_path.open("r") as solution_file: + content = solution_file.read() + + # Find all images in the content (markdown format; ignore html format) + # Markdown format: ![alt text](image_url) + # Returns tuples: (full_match, alt_text, image_path) + markdown_images = re.findall(r"(!\[([^\]]*)\]\(([^\)]+)\))", content) + + # Find all snippet includes (MkDocs style: --8<-- "filename") + # Returns tuples: (full_match, filename) + snippet_includes = re.findall(r'(--8<--\s+["\']([^"\']+)["\'])', content) + + for mdx, alt, path in markdown_images: + new_file = ("file", open(solution_path.parent / path, mode="rb")) + file_payload = { + "type": "solution", + "solution_id": solution_id, + } + + # Specifically use data= here to send multipart/form-data + r = self.api.post("/api/v1/files", files=[new_file], data=file_payload) + r.raise_for_status() + resp = r.json() + server_location = resp["data"][0]["location"] + content = content.replace(mdx, f"![{alt}](/files/{server_location})") + + # Process snippet includes (--8<-- "filename") + for full_match, filename in snippet_includes: + snippet_file_path = solution_path.parent / filename + if snippet_file_path.exists(): + with snippet_file_path.open("r") as snippet_file: + snippet_content = snippet_file.read() + # Replace the --8<-- directive with the actual file content + content = content.replace(full_match, snippet_content) + else: + log.warning(f"Snippet file not found: {filename}") + + solution_payload_patch = {"content": content} + r = self.api.patch(f"/api/v1/solutions/{solution_id}", json=solution_payload_patch) + r.raise_for_status() + + def _set_required_challenges(self): + remote_challenges = self.load_installed_challenges() + required_challenges = [] + anonymize = False + if type(self["requirements"]) == dict: + rc = self["requirements"].get("prerequisites", []) + anonymize = self["requirements"].get("anonymize", False) + else: + rc = self["requirements"] + + for required_challenge in rc: + if type(required_challenge) == str: + # requirement by name + # find the challenge id from installed challenges + found = False + for remote_challenge in remote_challenges: + if remote_challenge["name"] == required_challenge: + required_challenges.append(remote_challenge["id"]) + found = True + break + if found is False: + click.secho( + f'Challenge id cannot be found. Skipping invalid requirement name "{required_challenge}".', + fg="yellow", + ) + + elif type(required_challenge) == int: + # requirement by challenge id + # trust it and use it directly + required_challenges.append(required_challenge) + + required_challenge_ids = list(set(required_challenges)) + + if self.challenge_id in required_challenge_ids: + click.secho( + "Challenge cannot require itself. Skipping invalid requirement.", + fg="yellow", + ) + required_challenges.remove(self.challenge_id) + required_challenges.sort() + + requirements_payload = { + "requirements": { + "prerequisites": required_challenges, + "anonymize": anonymize, + } + } + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=requirements_payload) + r.raise_for_status() + + def _set_next(self, _next): + if type(_next) == str: + # nid by name + # find the challenge id from installed challenges + remote_challenges = self.load_installed_challenges() + for remote_challenge in remote_challenges: + if remote_challenge["name"] == _next: + _next = remote_challenge["id"] + break + if type(_next) == str: + click.secho( + "Challenge cannot find next challenge. Maybe it is invalid name or id. It will be cleared.", + fg="yellow", + ) + _next = None + elif type(_next) == int and _next > 0: + # nid by challenge id + # trust it and use it directly + _next = remote_challenge["id"] + else: + _next = None + + if self.challenge_id == _next: + click.secho( + "Challenge cannot set next challenge itself. Skipping invalid next challenge.", + fg="yellow", + ) + _next = None + + next_payload = {"next_id": _next} + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=next_payload) + r.raise_for_status() + + # Compare challenge requirements, will resolve all IDs to names + def _compare_challenge_requirements(self, r1: list[str | int], r2: list[str | int]) -> bool: + remote_challenges = self.load_installed_challenges() + + def normalize_requirements(requirements): + normalized = [] + for r in requirements: + if type(r) == int: + for remote_challenge in remote_challenges: + if remote_challenge["id"] == r: + normalized.append(remote_challenge["name"]) + break + else: + normalized.append(r) + + return normalized + + nr1 = normalize_requirements(r1) + nr1.sort() + nr2 = normalize_requirements(r2) + nr2.sort() + return nr1 == nr2 + + # Compare next challenges, will resolve all IDs to names + def _compare_challenge_next(self, r1: str | int | None, r2: str | int | None) -> bool: + def normalize_next(r): + normalized = None + if type(r) == int: + if r > 0: + remote_challenge = self.load_installed_challenge(r) + if remote_challenge["id"] == r: + normalized = remote_challenge["name"] + else: + normalized = r + + return normalized + + return normalize_next(r1) == normalize_next(r2) + + # Normalize challenge data from the API response to match challenge.yml + # It will remove any extra fields from the remote, as well as expand external references + # that have to be fetched separately (e.g., files, flags, hints, etc.) + # Note: files won't be included for two reasons: + # 1. To avoid downloading them unnecessarily, e.g., when they are ignored + # 2. Because it's dependent on the implementation whether to save them (mirror) or just compare (verify) + def _normalize_challenge(self, challenge_data: dict[str, Any]): + challenge = {} + + copy_keys = [ + "name", + "category", + "attribution", + "value", + "type", + "state", + "connection_info", + "logic", + ] + for key in copy_keys: + if key in challenge_data: + challenge[key] = challenge_data[key] + + challenge["description"] = challenge_data["description"].strip().replace("\r\n", "\n").replace("\t", "") + challenge["attribution"] = challenge_data.get("attribution", "") + if challenge["attribution"]: + challenge["attribution"] = challenge["attribution"].strip().replace("\r\n", "\n").replace("\t", "") + challenge["attempts"] = challenge_data["max_attempts"] + + for key in ["initial", "decay", "minimum"]: + if key in challenge_data: + if "extra" not in challenge: + challenge["extra"] = {} + + challenge["extra"][key] = challenge_data[key] + + # Add flags + r = self.api.get(f"/api/v1/challenges/{self.challenge_id}/flags") + r.raise_for_status() + flags = r.json()["data"] + challenge["flags"] = [ + ( + f["content"] + if f["type"] == "static" and (f["data"] is None or f["data"] == "") + else { + "content": f["content"].strip().replace("\r\n", "\n"), + "type": f["type"], + "data": f["data"], + } + ) + for f in flags + ] + + # Add tags + r = self.api.get(f"/api/v1/challenges/{self.challenge_id}/tags") + r.raise_for_status() + tags = r.json()["data"] + challenge["tags"] = [t["value"] for t in tags] + + # Add hints + r = self.api.get(f"/api/v1/challenges/{self.challenge_id}/hints") + r.raise_for_status() + hints = r.json()["data"] + # skipping pre-requisites for hints because they are not supported in ctfcli + challenge["hints"] = [ + ({"content": h["content"], "cost": h["cost"]} if h["cost"] > 0 else h["content"]) for h in hints + ] + + # Add topics + r = self.api.get(f"/api/v1/challenges/{self.challenge_id}/topics") + r.raise_for_status() + topics = r.json()["data"] + challenge["topics"] = [t["value"] for t in topics] + + # Add requirements + r = self.api.get(f"/api/v1/challenges/{self.challenge_id}/requirements") + r.raise_for_status() + requirements = (r.json().get("data") or {}).get("prerequisites", []) + challenge["requirements"] = {"prerequisites": [], "anonymize": False} + if len(requirements) > 0: + # Prefer challenge names over IDs + r2 = self.api.get("/api/v1/challenges?view=admin") + r2.raise_for_status() + challenges = r2.json()["data"] + challenge["requirements"]["prerequisites"] = [c["name"] for c in challenges if c["id"] in requirements] + # Add anonymize flag + challenge["requirements"]["anonymize"] = (r.json().get("data") or {}).get("anonymize", False) + + # Add next + nid = challenge_data.get("next_id") + if nid: + # Prefer challenge names over IDs + r = self.api.get(f"/api/v1/challenges/{nid}") + r.raise_for_status() + challenge["next"] = (r.json().get("data") or {}).get("name", None) + else: + challenge["next"] = None + + return challenge + + # Create a dictionary of remote files in { basename: {"url": "", "location": ""} } format + def _normalize_remote_files(self, remote_files: list[str]) -> dict[str, dict[str, str]]: + normalized = {} + for f in remote_files: + file_parts = f.split("?token=")[0].split("/") + normalized[file_parts[-1]] = { + "url": f, + "location": f"{file_parts[-2]}/{file_parts[-1]}", + } + + return normalized + + # Create a dictionary of sha1sums in { location: sha1sum } format + def _get_files_sha1sums(self) -> dict[str, str]: + r = self.api.get("/api/v1/files?type=challenge") + r.raise_for_status() + return {f["location"]: f.get("sha1sum", None) for f in r.json()["data"]} + + def sync(self, ignore: tuple[str] = ()) -> None: + challenge = self + + if "name" in ignore: + click.secho( + "Attribute 'name' cannot be ignored when syncing a challenge", + fg="yellow", + ) + + if not self.get("name"): + raise InvalidChallengeFile("Challenge does not provide a name") + + if challenge.get("files", False) and "files" not in ignore: + # _validate_files will raise if file is not found + self._validate_files() + + challenge_payload = self._get_initial_challenge_payload(ignore=ignore) + + self._load_challenge_id() + remote_challenge = self.load_installed_challenge(self.challenge_id) + + # if value, category, type or description are ignored, revert them to the remote state in the initial payload + reset_properties_if_ignored = [ + "value", + "category", + "type", + "description", + "attribution", + ] + for p in reset_properties_if_ignored: + if p in ignore: + challenge_payload[p] = remote_challenge[p] + + # Update simple properties + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=challenge_payload) + if r.ok is False: + click.secho(f"Failed to sync challenge: ({r.status_code}) {r.text}", fg="red") + r.raise_for_status() + + # Update flags + if "flags" not in ignore: + self._delete_existing_flags() + if challenge.get("flags"): + self._create_flags() + + # Update topics + if "topics" not in ignore: + self._delete_existing_topics() + if challenge.get("topics"): + self._create_topics() + + # Update tags + if "tags" not in ignore: + self._delete_existing_tags() + if challenge.get("tags"): + self._create_tags() + + # Create / Upload files + if "files" not in ignore: + self["files"] = self.get("files") or [] + remote_challenge["files"] = remote_challenge.get("files") or [] + + # Get basenames of local files to compare against remote files + local_files = {f.split("/")[-1]: f for f in self["files"]} + remote_files = self._normalize_remote_files(remote_challenge["files"]) + + # Delete remote files which are no longer defined locally + for remote_file in remote_files: + if remote_file not in local_files: + self._delete_file(remote_files[remote_file]["location"]) + + # Only check for file changes if there are files to upload + if local_files: + sha1sums = self._get_files_sha1sums() + for local_file_name in local_files: + # Creating a new file + if local_file_name not in remote_files: + self._create_file(self.challenge_directory / local_files[local_file_name]) + continue + + # Updating an existing file + # sha1sum is present in CTFd 3.7+, use it instead of always re-uploading the file if possible + remote_file_sha1sum = sha1sums[remote_files[local_file_name]["location"]] + if remote_file_sha1sum is not None: + with open( + self.challenge_directory / local_files[local_file_name], + "rb", + ) as lf: + local_file_sha1sum = hash_file(lf) + + # Allow users to specify sha1sum in ignore to force reuploads + if "sha1sum" not in ignore: + if local_file_sha1sum == remote_file_sha1sum: + continue + + # if sha1sums are not present, or the hashes are different, re-upload the file + self._delete_file(remote_files[local_file_name]["location"]) + self._create_file(self.challenge_directory / local_files[local_file_name]) + + # Update hints + if "hints" not in ignore: + self._delete_existing_hints() + if challenge.get("hints"): + self._create_hints() + + # Update requirements + if challenge.get("requirements") and "requirements" not in ignore: + self._set_required_challenges() + + # Set next + _next = challenge.get("next", None) + if "next" not in ignore: + self._set_next(_next) + + if "solution" not in ignore: + resolved_solution = self._resolve_solution_path() + if not resolved_solution: + self._delete_existing_solution() + self._create_solution() + + make_challenge_visible = False + + # Bring back the challenge to be visible if: + # 1. State is not ignored and set to visible, or defaults to visible + if "state" not in ignore: + if challenge.get("state", "visible") == "visible": + make_challenge_visible = True + + # 2. State is ignored, but regardless of the local value, the remote state was visible + else: + if remote_challenge.get("state") == "visible": + make_challenge_visible = True + + if make_challenge_visible: + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json={"state": "visible"}) + r.raise_for_status() + + def create(self, ignore: tuple[str] = ()) -> None: + challenge = self + + for attr in ["name", "value"]: + if attr in ignore: + click.secho( + f"Attribute '{attr}' cannot be ignored when creating a challenge", + fg="yellow", + ) + + if not challenge.get("name", False): + raise InvalidChallengeDefinition("Challenge does not provide a name") + + if not challenge.get("value", False) and challenge.get("type", "standard") != "dynamic": + raise InvalidChallengeDefinition("Challenge does not provide a value") + + if challenge.get("files", False) and "files" not in ignore: + # _validate_files will raise if file is not found + self._validate_files() + + challenge_payload = self._get_initial_challenge_payload(ignore=ignore) + + # in the case of creation, value and type can't be ignored: + # value is required (unless the challenge is a dynamic value challenge), + # and the type will default to standard + # if category or description are ignored, set them to an empty string + reset_properties_if_ignored = ["category", "description", "attribution"] + for p in reset_properties_if_ignored: + if p in ignore: + challenge_payload[p] = "" + + r = self.api.post("/api/v1/challenges", json=challenge_payload) + if r.ok is False: + click.secho(f"Failed to create challenge: ({r.status_code}) {r.text}", fg="red") + r.raise_for_status() + + self.challenge_id = r.json()["data"]["id"] + + # Create flags + if challenge.get("flags") and "flags" not in ignore: + self._create_flags() + + # Create topics + if challenge.get("topics") and "topics" not in ignore: + self._create_topics() + + # Create tags + if challenge.get("tags") and "tags" not in ignore: + self._create_tags() + + # Upload files + if challenge.get("files") and "files" not in ignore: + self._create_all_files() + + # Add hints + if challenge.get("hints") and "hints" not in ignore: + self._create_hints() + + # Add requirements + if challenge.get("requirements") and "requirements" not in ignore: + self._set_required_challenges() + + # Add next + _next = challenge.get("next", None) + if "next" not in ignore: + self._set_next(_next) + + # Add solution + if "solution" not in ignore: + self._create_solution() + + # Bring back the challenge if it's supposed to be visible + # Either explicitly, or by assuming the default value (possibly because the state is ignored) + if challenge.get("state", "visible") == "visible" or "state" in ignore: + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json={"state": "visible"}) + r.raise_for_status() + + def lint(self, skip_hadolint=False, flag_format="flag{") -> bool: + challenge = self + + issues = {"fields": [], "dockerfile": [], "hadolint": [], "files": []} + + # Check if required fields are present + for field in [ + "name", + "author", + "category", + "description", + "attribution", + "value", + ]: + # value is allowed to be none if the challenge type is dynamic + if field == "value" and challenge.get("type") == "dynamic": + continue + + if challenge.get(field) is None: + issues["fields"].append(f"challenge.yml is missing required field: {field}") + + # Check that the image field and Dockerfile match + if (self.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") != ".": + issues["dockerfile"].append("Dockerfile exists but image field does not point to it") + + # Check that Dockerfile exists and is EXPOSE'ing a port + if challenge.get("image") == ".": + dockerfile_path = self.challenge_directory / "Dockerfile" + has_dockerfile = dockerfile_path.is_file() + + if not has_dockerfile: + issues["dockerfile"].append("Dockerfile specified in 'image' field but no Dockerfile found") + + if has_dockerfile: + with open(dockerfile_path) as dockerfile: + dockerfile_source = dockerfile.read() + + if "EXPOSE" not in dockerfile_source: + issues["dockerfile"].append("Dockerfile is missing EXPOSE") + + if not skip_hadolint: + # Check Dockerfile with hadolint + hadolint = subprocess.run( + ["docker", "run", "--rm", "-i", "hadolint/hadolint"], + capture_output=True, + input=dockerfile_source.encode(), + ) + + if hadolint.returncode != 0: + issues["hadolint"].append(hadolint.stdout.decode()) + + else: + click.secho("Skipping Hadolint", fg="yellow") + + # Check that all files exist + files = self.get("files") or [] + for challenge_file in files: + challenge_file_path = self.challenge_directory / challenge_file + + if challenge_file_path.is_file() is False: + issues["files"].append( + f"Challenge file '{challenge_file}' specified, but not found at {challenge_file_path}" + ) + + # Check that the optional solution file exists + solution = self.get("solution", None) + if solution: + solution_file = None + solution_state = "hidden" + + if type(solution) == str: + solution_file = solution + elif type(solution) == dict: + solution_file = solution.get("path") + solution_state = solution.get("state", "hidden") + + if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]: + issues["fields"].append("The solution state must be one of: hidden, visible, solved") + + else: + issues["fields"].append("The solution field must be a string path or an object with path and state") + + if type(solution_file) != str or not solution_file: + issues["fields"].append("The solution object must define a non-empty string path field") + else: + solution_file_path = self.challenge_directory / solution_file + if solution_file_path.is_file() is False: + issues["files"].append( + f"Solution file '{solution_file}' specified, but not found at {solution_file_path}" + ) + + # Check that files don't have a flag in them + for challenge_file in files: + challenge_file_path = self.challenge_directory / challenge_file + + if not challenge_file_path.exists(): + # The check for files present is above; this is only to look for flags in files that we do have + continue + + for s in strings(challenge_file_path): + if flag_format in s: + s = s.strip() + issues["files"].append(f"Potential flag found in distributed file '{challenge_file}':\n {s}") + + if any(messages for messages in issues.values() if len(messages) > 0): + raise LintException(issues=issues) + + return True + + def mirror(self, files_directory_name: str = "dist", ignore: tuple[str] = ()) -> None: + self._load_challenge_id() + remote_challenge = self.load_installed_challenge(self.challenge_id) + challenge = self._normalize_challenge(remote_challenge) + + remote_challenge["files"] = remote_challenge.get("files") or [] + challenge["files"] = challenge.get("files") or [] + + # Add files which are not handled in _normalize_challenge + if "files" not in ignore: + local_files = {Path(f).name: f for f in challenge["files"]} + + # Update files + for remote_file in remote_challenge["files"]: + # Get base file name + remote_file_name = remote_file.split("/")[-1].split("?token=")[0] + + # The file is only present on the remote - we have to download it, and assume a path + if remote_file_name not in local_files: + r = self.api.get(remote_file) + r.raise_for_status() + + # Ensure the directory for the challenge files exists + challenge_files_directory = self.challenge_directory / files_directory_name + challenge_files_directory.mkdir(parents=True, exist_ok=True) + + (challenge_files_directory / remote_file_name).write_bytes(r.content) + challenge["files"].append(f"{files_directory_name}/{remote_file_name}") + + # The file is already present in the challenge.yml - we know the desired path + else: + r = self.api.get(remote_file) + r.raise_for_status() + (self.challenge_directory / local_files[remote_file_name]).write_bytes(r.content) + + # Soft-Delete files that are not present on the remote + # Remove them from challenge.yml but do not delete them from disk + remote_file_names = [f.split("/")[-1].split("?token=")[0] for f in remote_challenge["files"]] + challenge["files"] = [f for f in challenge["files"] if Path(f).name in remote_file_names] + + for key in challenge: + if key not in ignore: + self[key] = challenge[key] + + self.save() + + def verify(self, ignore: tuple[str] = ()) -> bool: + self._load_challenge_id() + challenge = self + remote_challenge = self.load_installed_challenge(self.challenge_id) + normalized_challenge = self._normalize_challenge(remote_challenge) + + remote_challenge["files"] = remote_challenge.get("files") or [] + challenge["files"] = challenge.get("files") or [] + + for key in normalized_challenge: + if key in ignore: + continue + + # If challenge.yml doesn't have some property from the remote + # Check if it's a default value that can be omitted + if key not in challenge: + if self.is_default_challenge_property(key, normalized_challenge[key]): + continue + + click.secho( + f"{key} is not in challenge.", + fg="yellow", + ) + + return False + + if challenge[key] != normalized_challenge[key]: + if key == "requirements": + if type(challenge[key]) == dict: + cr = challenge[key]["prerequisites"] + ca = challenge[key].get("anonymize", False) + else: + cr = challenge[key] + ca = False + if ( + self._compare_challenge_requirements(cr, normalized_challenge[key]["prerequisites"]) + and ca == normalized_challenge[key]["anonymize"] + ): + continue + + if key == "next" and self._compare_challenge_next(challenge[key], normalized_challenge[key]): + continue + + click.secho( + f"{key} comparison failed.", + fg="yellow", + ) + + return False + + # Handle a special case for files, unless they are ignored + if "files" not in ignore: + # Check if files defined in challenge.yml are present + try: + self._validate_files() + local_files = {Path(f).name: f for f in challenge["files"]} + except InvalidChallengeFile: + click.secho( + "InvalidChallengeFile", + fg="yellow", + ) + return False + + remote_files = self._normalize_remote_files(remote_challenge["files"]) + # Check if there are no extra local files + for local_file in local_files: + if local_file not in remote_files: + click.secho( + f"{local_file} is not in remote challenge.", + fg="yellow", + ) + return False + + sha1sums = self._get_files_sha1sums() + # Check if all remote files are present locally + for remote_file_name in remote_files: + if remote_file_name not in local_files: + click.secho( + f"{remote_file_name} is not in local challenge.", + fg="yellow", + ) + return False + + # sha1sum is present in CTFd 3.7+, use it instead of downloading the file if possible + remote_file_sha1sum = sha1sums[remote_files[remote_file_name]["location"]] + if remote_file_sha1sum is not None: + with open(self.challenge_directory / local_files[remote_file_name], "rb") as lf: + local_file_sha1sum = hash_file(lf) + + if local_file_sha1sum != remote_file_sha1sum: + click.secho( + "sha1sum does not match with remote one.", + fg="yellow", + ) + return False + + return True + + # If sha1sum is not present, download the file and compare the contents + r = self.api.get(remote_files[remote_file_name]["url"]) + r.raise_for_status() + remote_file_contents = r.content + local_file_contents = (self.challenge_directory / local_files[remote_file_name]).read_bytes() + + if remote_file_contents != local_file_contents: + click.secho( + "the file content does not match with the remote one.", + fg="yellow", + ) + return False + + return True + + def save(self): + challenge_dict = dict(self) + + # sort the challenge dict by the key order defined from the spec + # also strip any default values + sorted_challenge_dict = { + k: challenge_dict[k] + for k in self.key_order + if k in challenge_dict and not self.is_default_challenge_property(k, challenge_dict[k]) + } + + # if there are any additional keys append them at the end + unknown_keys = set(challenge_dict) - set(self.key_order) + for k in unknown_keys: + sorted_challenge_dict[k] = challenge_dict[k] + + try: + challenge_yml = yaml.safe_dump(sorted_challenge_dict, sort_keys=False, allow_unicode=True) + + # attempt to pretty print the yaml (add an extra newline between selected top-level keys) + pattern = "|".join(r"^" + re.escape(key) + r":" for key in self.keys_with_newline) + pretty_challenge_yml = re.sub(pattern, r"\n\g<0>", challenge_yml, flags=re.MULTILINE) + + with open(self.challenge_file_path, "w") as challenge_file: + challenge_file.write(pretty_challenge_yml) + + except Exception as e: + raise InvalidChallengeFile(f"Challenge file could not be saved:\n{e}") from e diff --git a/ctfcli/core/config.py b/ctfcli/core/config.py new file mode 100644 index 0000000..028a7a0 --- /dev/null +++ b/ctfcli/core/config.py @@ -0,0 +1,135 @@ +import configparser +import json +import os +from pathlib import Path + +import appdirs + +from ctfcli import __name__ as pkg_name +from ctfcli.core.exceptions import ProjectNotInitialized + + +class Config: + _env_vars = { + "CTFCLI_ACCESS_TOKEN": "access_token", + "CTFCLI_URL": "url", + } + + def __init__(self): + self.base_path = self.get_base_path() + self.project_path = self.get_project_path() + self.config_path = self.project_path / ".ctf" / "config" + self.data_path = self.get_data_path() + self.templates_path = self.get_templates_path() + self.pages_path = self.get_pages_path() + self.plugins_path = self.get_plugins_path() + + parser = configparser.ConfigParser() + parser.optionxform = str + parser.read(self.config_path) + + self.config = parser + self.challenges = dict(self.config["challenges"]) + + # Load environment variables + self._env_overrides() + + def _env_overrides(self): + """ + For each environment variable specified in _env_vars, check if it exists + and if so, add it to the config under the "config" section. + """ + for env_var, config_key in self._env_vars.items(): + env_value = os.getenv(env_var) + if not env_value: + continue + + if not self.config.has_section("config"): + self.config.add_section("config") + + self.config["config"][config_key] = env_value + + def __getitem__(self, key): + return self.config[key] + + def __contains__(self, key): + return key in self.config + + def write(self, file_handle): + return self.config.write(file_handle) + + def as_json(self, pretty=False) -> str: + data = {} + for section in self.config.sections(): + data[section] = {} + for k, v in self.config.items(section): + data[section][k] = v + + if pretty: + return json.dumps(data, sort_keys=True, indent=4) + + return json.dumps(data) + + @staticmethod + def get_project_path() -> Path: + pwd = Path.cwd() + while pwd != Path("/"): + config = pwd / ".ctf" / "config" + if config.is_file(): + return pwd + pwd = pwd.parent + + raise ProjectNotInitialized + + @staticmethod + def get_config_path() -> Path: + return Config.get_project_path() / ".ctf" / "config" + + @staticmethod + def get_base_path() -> Path: + return Path(__file__).parent.parent + + @staticmethod + def get_data_path() -> Path: + return Path(appdirs.user_data_dir(appname=pkg_name)) + + @staticmethod + def get_pages_path() -> Path: + pages_path = Config.get_project_path() / "pages" + + if not pages_path.exists(): + pages_path.mkdir() + + return pages_path + + @staticmethod + def get_templates_path() -> Path: + templates_path = Config.get_data_path() / "templates" + + if not templates_path.exists(): + templates_path.mkdir(parents=True) + + return templates_path + + @staticmethod + def get_plugins_path() -> Path: + if os.getenv("CTFCLI_PLUGIN_PATH"): + plugins_path = Config._get_custom_plugin_path() + else: + plugins_path = Config.get_data_path() / "plugins" + + if not plugins_path.exists(): + plugins_path.mkdir(parents=True) + + return plugins_path + + @staticmethod + def _get_custom_plugin_path() -> Path: + # Assumes CTFCLI_PLUGIN_PATH is present + custom_plugins_path = Path(os.getenv("CTFCLI_PLUGIN_PATH")) + + if custom_plugins_path.is_absolute(): + return custom_plugins_path + + # If path is relative, assume it's to ctfcli base_path (development convenience) + return Config.get_base_path() / custom_plugins_path diff --git a/ctfcli/core/deployment/__init__.py b/ctfcli/core/deployment/__init__.py new file mode 100644 index 0000000..51b80b2 --- /dev/null +++ b/ctfcli/core/deployment/__init__.py @@ -0,0 +1,18 @@ +from ctfcli.core.deployment.base import DeploymentHandler +from ctfcli.core.deployment.cloud import CloudDeploymentHandler +from ctfcli.core.deployment.registry import RegistryDeploymentHandler +from ctfcli.core.deployment.ssh import SSHDeploymentHandler + +DEPLOYMENT_HANDLERS: dict[str, type[DeploymentHandler]] = { + "cloud": CloudDeploymentHandler, + "ssh": SSHDeploymentHandler, + "registry": RegistryDeploymentHandler, +} + + +def get_deployment_handler(name: str) -> type[DeploymentHandler]: + return DEPLOYMENT_HANDLERS[name] + + +def register_deployment_handler(name: str, handler: type[DeploymentHandler]): + DEPLOYMENT_HANDLERS[name] = handler diff --git a/ctfcli/core/deployment/base.py b/ctfcli/core/deployment/base.py new file mode 100644 index 0000000..4e3a754 --- /dev/null +++ b/ctfcli/core/deployment/base.py @@ -0,0 +1,25 @@ +from ctfcli.core.challenge import Challenge + + +class DeploymentResult: + def __init__( + self, + success: bool, + domain: str | None = None, + port: str | None = None, + connection_info: str | None = None, + ): + self.success = success + self.domain = domain + self.port = port + self.connection_info = connection_info + + +class DeploymentHandler: + def __init__(self, challenge: Challenge, host: str | None = None, protocol: str | None = None): + self.challenge = challenge + self.host = host if host else challenge.get("host", None) + self.protocol = protocol if protocol else challenge.get("protocol", None) + + def deploy(self, *args, **kwargs) -> DeploymentResult: + raise NotImplementedError diff --git a/ctfcli/core/deployment/cloud.py b/ctfcli/core/deployment/cloud.py new file mode 100644 index 0000000..fe001bc --- /dev/null +++ b/ctfcli/core/deployment/cloud.py @@ -0,0 +1,200 @@ +import logging +import subprocess +import time +from urllib.parse import urlparse + +import click +from slugify import slugify + +from ctfcli.core.api import API +from ctfcli.core.config import Config +from ctfcli.core.deployment.base import DeploymentHandler, DeploymentResult + +log = logging.getLogger("ctfcli.core.deployment.cloud") + + +class CloudDeploymentHandler(DeploymentHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.api = API() + self.config = Config() + + # Do not fail here if challenge does not provide an image + # rather return a failed deployment result during deploy + if self.challenge.get("image"): + self.image_name = self.challenge.image.basename + + def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: + # Check whether challenge defines image + if not self.challenge.get("image"): + click.secho("Challenge does not define an image to deploy", fg="red") + return DeploymentResult(False) + + # Check whether instance supports cloud deployments + check = self.api.get("/api/v1/images") + if not check.ok: + click.secho("Target instance does not support cloud deployments", fg="red") + return DeploymentResult(False) + + # Get or create Image in CTFd + image_data = self._get_or_create_image() + + # Build or Pull / Update the configured image + if self.challenge.image.built: + if not self.challenge.image.pull(): + click.secho("Could not pull the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + else: + if not self.challenge.image.build(): + click.secho("Could not build the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + if skip_login: + click.secho( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + else: + login_result = self._registry_login() + + if not login_result: + click.secho( + "Could not log in to the registry. Please check your access token and instance URL", fg="red" + ) + return DeploymentResult(False) + + push_result = self.challenge.image.push(image_data["location"]) + if not push_result: + click.secho("Could not push image to the registry.", fg="red") + + if skip_login: + click.secho( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ) + + return DeploymentResult(False) + + # Get or create Service in CTFd + service_data = self._get_or_create_service(image_data["location"]) + + deployed_service_data = self._await_service_deployment(service_data) + if not deployed_service_data: + return DeploymentResult(False) + + # Expose TCP port if configured + if self.protocol == "tcp": + self.api.patch(f"/api/v1/services/{deployed_service_data['id']}", json={"expose": True}).raise_for_status() + deployed_service_data = self.api.get(f"/api/v1/services/{deployed_service_data['id']}").json()["data"] + + connection_info = self._get_connection_info( + hostname=deployed_service_data["hostname"], + tcp_hostname=deployed_service_data.get("tcp_hostname"), + tcp_port=deployed_service_data.get("tcp_port"), + ) + + return DeploymentResult(True, connection_info=connection_info) + + def _get_or_create_image(self): + # Check if image already exists + existing_images = self.api.get("/api/v1/images").json()["data"] + for image_data in existing_images: + if image_data["location"].endswith(self.image_name): + return image_data + + # Create the image if it doesn't exist + return self.api.post("/api/v1/images", json={"name": self.image_name}).json()["data"] + + def _get_or_create_service(self, image_location: str): + existing_services = self.api.get("/api/v1/services").json()["data"] + for service_data in existing_services: + if service_data["name"] == self.image_name: + # Update the existing service image information + self.api.patch( + f"/api/v1/services/{service_data['id']}", + json={"image": image_location}, + ).raise_for_status() + return self.api.get(f"/api/v1/services/{service_data['id']}").json()["data"] + + # Create the service if it doesn't exist + image_name_slug = slugify(self.image_name) + return self.api.post("/api/v1/services", json={"name": image_name_slug, "image": image_location}).json()["data"] + + def _await_service_deployment(self, service_data, interval=10, timeout=180) -> dict | None: + service_id = service_data["id"] + + base_timeout = timeout + i = 0 + while service_data.get("hostname") is None and timeout > 0: + click.secho( + f"Awaiting service deployment [{i * interval}/{base_timeout}s]", + fg="yellow", + ) + service_data = self.api.get(f"/api/v1/services/{service_id}").json()["data"] + + i += 1 + timeout -= interval + time.sleep(interval) + + if timeout == 0: + click.secho("Timeout awaiting challenge deployment", fg="red") + return None + + return service_data + + def _registry_login(self, registry: str = "registry.ctfd.io") -> bool: + r = self.api.get("/api/v1/users/me") + r.raise_for_status() + data = r.json() + + if not data["success"]: + return False + + # build registry username: admin@instance.ctfd.io + username = data["data"]["name"] + hostname = urlparse(self.api.prefix_url).hostname + + # require instance url to be ctfd assigned for now + # later this could use dig to resolve a cname record + if not hostname or not str(hostname).endswith(".ctfd.io"): + click.secho( + "Instance URL is not a CTFd assigned URL. Either use the CTFd assigned domain name, " + "or login to the registry manually and deploy with --skip-login", + fg="red", + ) + return False + + docker_login = f"{username}@{hostname}" + docker_password = self.api.access_token.encode() + docker_login_command = [ + "docker", + "login", + "-u", + docker_login, + "--password-stdin", + registry, + ] + + log.debug(f"call({docker_login_command}, stderr=subprocess.PIPE, input=docker_password)") + login_response = subprocess.check_output(docker_login_command, input=docker_password, stderr=subprocess.PIPE) + + return b"Login Succeeded" in login_response + + def _get_connection_info( + self, + hostname: str, + tcp_hostname: str | None = None, + tcp_port: str | None = None, + ) -> str: + # if protocol is http(s) - return an URL + if self.protocol and self.protocol.startswith("http"): + # cloud deployments always deploy on https + return f"https://{hostname}" + + # if protocol is tcp, and connection details are provided - return netcat connection string + if self.protocol and self.protocol == "tcp" and tcp_hostname and tcp_port: + return f"nc {tcp_hostname} {tcp_port}" + + # Otherwise return plain hostname + return hostname diff --git a/ctfcli/core/deployment/registry.py b/ctfcli/core/deployment/registry.py new file mode 100644 index 0000000..0db741f --- /dev/null +++ b/ctfcli/core/deployment/registry.py @@ -0,0 +1,104 @@ +import logging +import subprocess +from urllib.parse import urlparse + +import click + +from ctfcli.core.config import Config +from ctfcli.core.deployment.base import DeploymentHandler, DeploymentResult + +log = logging.getLogger("ctfcli.core.deployment.registry") + + +class RegistryDeploymentHandler(DeploymentHandler): + def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: + config = Config() + + # Check whether challenge defines image + if not self.challenge.get("image"): + click.secho("Challenge does not define an image to deploy", fg="red") + return DeploymentResult(False) + + if not self.host: + click.secho( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + return DeploymentResult(False) + + # resolve a location for the image push + # e.g. registry.example.com/test-project/challenge-image-name + # challenge image name is appended to the host provided for the deployment + host_url = urlparse(self.host) + location = f"{host_url.netloc}{host_url.path.rstrip('/')}/{self.challenge.image.basename}" + + if skip_login: + click.secho( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + else: + if "registry" not in config or not config["registry"]: + click.secho("Config does not provide a registry section.", fg="red") + return DeploymentResult(False) + + registry_username = config["registry"].get("username") + registry_password = config["registry"].get("password") + if not registry_username or not registry_password: + click.secho("Config is missing credentials for the registry.", fg="red") + return DeploymentResult(False) + + login_result = self._registry_login( + registry_username, + registry_password, + host_url.netloc, + ) + + if not login_result: + click.secho("Could not log in to the registry. Please check your configured credentials.", fg="red") + return DeploymentResult(False) + + if self.challenge.image.built: + if not self.challenge.image.pull(): + click.secho("Could not pull the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + else: + if not self.challenge.image.build(): + click.secho("Could not build the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + push_result = self.challenge.image.push(location) + if not push_result: + click.secho("Could not push image to the registry. Please check docker output above.", fg="red") + + if skip_login: + click.secho( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ) + + return DeploymentResult(False) + + # In this deployment result we can't really provide more data such as the connection info, as we don't know + # where and how the service is deployed - only that it's pushed to a registry + return DeploymentResult(True) + + def _registry_login(self, username: str, password: str, registry: str): + docker_login_command = [ + "docker", + "login", + "-u", + username, + "--password-stdin", + registry, + ] + + try: + log.debug( + f"call({docker_login_command}, input=password, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)" + ) + subprocess.run( + docker_login_command, input=password.encode(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except subprocess.CalledProcessError: + return False + return True diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py new file mode 100644 index 0000000..a39b5a1 --- /dev/null +++ b/ctfcli/core/deployment/ssh.py @@ -0,0 +1,101 @@ +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import click + +from ctfcli.core.deployment.base import DeploymentHandler, DeploymentResult + + +class SSHDeploymentHandler(DeploymentHandler): + def deploy(self, *args, **kwargs) -> DeploymentResult: + if not self.challenge.get("image"): + click.secho("Challenge does not define an image to deploy", fg="red") + return DeploymentResult(False) + + if not self.host: + click.secho( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + return DeploymentResult(False) + + if self.challenge.image.built: + if not self.challenge.image.pull(): + click.secho("Could not pull the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + else: + if not self.challenge.image.build(): + click.secho("Could not build the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + image_name = self.challenge.image.name + image_basename = self.challenge.image.basename + image_export = self.challenge.image.export() + if not image_export: + click.secho("Could not export the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + image_export_path = Path(image_export) + host_url = urlparse(self.host) + target_path = host_url.path or "/tmp" # noqa: S108 + target_file = f"{target_path}/{image_export_path.name}" + + exposed_port = self.challenge.image.get_exposed_port() + if not exposed_port: + click.secho("Could not resolve a port to expose. Make sure your Dockerfile EXPOSE's a port.", fg="red") + return DeploymentResult(False) + + target_hostname = host_url.netloc[host_url.netloc.find("@") + 1 :] + try: + subprocess.run(["scp", image_export_path, f"{host_url.netloc}:{target_file}"]) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"docker load -i {target_file} && rm {target_file}", + ] + ) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"docker stop {image_basename} 2>/dev/null; docker rm {image_basename} 2>/dev/null", + ] + ) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"docker run -d -p{exposed_port}:{exposed_port} --name {image_basename} " + f"--restart always {image_name}", + ] + ) + + except subprocess.CalledProcessError as e: + click.secho("Failed to deploy image!", fg="red") + click.secho(str(e), fg="red") + return DeploymentResult(False) + + image_export_path.unlink() + + connection_info = self._get_connection_info(target_hostname, exposed_port) + return DeploymentResult(True, target_hostname, exposed_port, connection_info) + + def _get_connection_info(self, hostname: str, port: int) -> str: + # if protocol is http(s) - return an URL + if self.protocol and self.protocol.startswith("http"): + # if port is the default for http or https don't include a port + if port == 80 and self.protocol == "http": + return f"http://{hostname}" + + if port == 443 and self.protocol == "https": + return f"https://{hostname}" + + return f"{self.protocol}://{hostname}:{port}" + + # if protocol is tcp, and connection details are provided - return netcat connection string + if self.protocol and self.protocol == "tcp": + return f"nc {hostname} {port}" + + # Otherwise return plain hostname + return hostname diff --git a/ctfcli/core/exceptions.py b/ctfcli/core/exceptions.py new file mode 100644 index 0000000..1ac3a62 --- /dev/null +++ b/ctfcli/core/exceptions.py @@ -0,0 +1,75 @@ +import click + + +class MissingAPIKey(Exception): + def __str__(self): + return ( + "Missing API key. " + "Please set the API key in your configuration file or set the CTFCLI_ACCESS_TOKEN environment variable." + ) + + +class MissingInstanceURL(Exception): + def __str__(self): + return ( + "Missing CTFd instance URL. " + "Please set the instance URL in your configuration file or set the CTFCLI_URL environment variable." + ) + + +class ProjectNotInitialized(Exception): + pass + + +class ChallengeException(Exception): + pass + + +class InvalidChallengeDefinition(ChallengeException): + pass + + +class InvalidChallengeFile(ChallengeException): + pass + + +class RemoteChallengeNotFound(ChallengeException): + pass + + +class LintException(Exception): + def __init__(self, *args, issues: dict[str, list[str]] | None = None): + self.issues = issues if issues else {} + super().__init__(*args) + + def print_summary(self): + for category, issues in self.issues.items(): + if len(issues) > 0: + click.secho(f"{category.capitalize()}:", fg="yellow") + + prefix = " - " if category != "hadolint" else "" + for issue in issues: + click.echo(f"{prefix}{issue}") + + if category != "hadolint": + click.echo() + + +class PageException(Exception): + pass + + +class InvalidPageFormat(PageException): + pass + + +class InvalidPageConfiguration(PageException): + pass + + +class IllegalPageOperation(PageException): + pass + + +class InstanceConfigException(Exception): + pass diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py new file mode 100644 index 0000000..c97ead8 --- /dev/null +++ b/ctfcli/core/image.py @@ -0,0 +1,84 @@ +import json +import subprocess +import tempfile +from os import PathLike +from pathlib import Path + + +class Image: + def __init__(self, name: str, build_path: str | PathLike | None = None): + # name can be either a new name to assign or an existing image name + self.name = name + + # if the image is a remote image (eg. ghcr.io/.../...), extract the basename + self.basename = name + if "/" in self.name or ":" in self.name: + self.basename = self.name.split(":")[0].split("/")[-1] + + self.built = True + + # if the image provides a build path, assume it is not built yet + if build_path: + self.build_path = Path(build_path) + self.built = False + + def build(self) -> str | None: + docker_build = subprocess.call( + ["docker", "build", "--load", "-t", self.name, "."], cwd=self.build_path.absolute() + ) + if docker_build != 0: + return None + + self.built = True + return self.name + + def pull(self) -> str | None: + docker_pull = subprocess.call(["docker", "pull", self.name]) + if docker_pull != 0: + return None + + return self.name + + def push(self, location: str) -> str | None: + if not self.built: + self.build() + + docker_tag = subprocess.call(["docker", "tag", self.name, location]) + docker_push = subprocess.call(["docker", "push", location]) + + if any(r != 0 for r in [docker_tag, docker_push]): + return None + + return location + + def export(self) -> str | None: + if not self.built: + self.build() + + image_tar = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{self.basename}.docker.tar") # noqa: SIM115 + docker_save = subprocess.call(["docker", "save", "--output", image_tar.name, self.name]) + + if docker_save != 0: + return None + + return image_tar.name + + def get_exposed_port(self) -> str | None: + if not self.built: + self.build() + + try: + docker_output = subprocess.check_output( + ["docker", "inspect", "--format={{json .Config.ExposedPorts}}", self.name] + ) + except subprocess.CalledProcessError: + return None + + ports_data = json.loads(docker_output) + if ports_data: + ports = list(ports_data.keys()) + + if ports: + # Split '2323/tcp' + return ports[0].split("/")[0] + return None diff --git a/ctfcli/core/instance/__init__.py b/ctfcli/core/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ctfcli/core/instance/config.py b/ctfcli/core/instance/config.py new file mode 100644 index 0000000..b605f70 --- /dev/null +++ b/ctfcli/core/instance/config.py @@ -0,0 +1,63 @@ +from ctfcli.core.api import API +from ctfcli.core.exceptions import InstanceConfigException + + +class ServerConfig: + @staticmethod + def get(key: str) -> str: + api = API() + resp = api.get(f"/api/v1/configs/{key}") + if resp.ok is False: + raise InstanceConfigException( + f"Could not get config {key=} because '{resp.content}' with {resp.status_code}" + ) + resp = resp.json() + return resp["data"]["value"] + + @staticmethod + def set(key: str, value: str) -> bool: + api = API() + data = { + "value": value, + } + resp = api.patch(f"/api/v1/configs/{key}", json=data) + if resp.ok is False: + raise InstanceConfigException( + f"Could not get config {key=} because '{resp.content}' with {resp.status_code}" + ) + resp = resp.json() + + return resp["success"] + + @staticmethod + def getall(): + api = API() + resp = api.get("/api/v1/configs") + if resp.ok is False: + raise InstanceConfigException(f"Could not get configs because '{resp.content}' with {resp.status_code}") + resp = resp.json() + configs = resp["data"] + + config = {} + for c in configs: + # Ignore alembic_version configs as they are managed by plugins + if c["key"].endswith("alembic_version") is False: + config[c["key"]] = c["value"] + + # Not much point in saving internal configs + config.pop("ctf_version", None) + config.pop("version_latest", None) + config.pop("next_update_check", None) + config.pop("setup", None) + + return config + + @staticmethod + def setall(configs) -> list[str]: + failed = [] + for k, v in configs.items(): + try: + ServerConfig.set(key=k, value=v) + except InstanceConfigException: + failed.append(k) + return failed diff --git a/ctfcli/core/media.py b/ctfcli/core/media.py new file mode 100644 index 0000000..59b0de6 --- /dev/null +++ b/ctfcli/core/media.py @@ -0,0 +1,15 @@ +from ctfcli.core.config import Config +from ctfcli.utils.tools import safe_format + + +class Media: + @staticmethod + def replace_placeholders(content: str) -> str: + config = Config() + try: + section = config["media"] + except KeyError: + section = [] + for m in section: + content = safe_format(content, items={m: config["media"][m]}) + return content diff --git a/ctfcli/core/page.py b/ctfcli/core/page.py new file mode 100644 index 0000000..a3eb484 --- /dev/null +++ b/ctfcli/core/page.py @@ -0,0 +1,240 @@ +from os import PathLike +from pathlib import Path + +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +import click +import frontmatter + +from ctfcli.core.api import API +from ctfcli.core.config import Config +from ctfcli.core.exceptions import ( + IllegalPageOperation, + InvalidPageConfiguration, + InvalidPageFormat, +) +from ctfcli.core.media import Media + +PAGE_FORMATS = { + ".md": "markdown", + ".html": "html", + ".htm": "html", +} + + +# Note: +# This class cannot delete pages yet - which can lead to expected, but perhaps unintuitive behavior with sync +# When changing a route of a page, it will not be possible to sync it as we will not be able to determine an existing +# page to update (that is done with the route). +# The page can be pushed, but that will just create another page at the new route - which can then be reflected locally +# by using pull. The old page has to be deleted manually or hidden with sync and hidden: true + + +class Page: + _remote_pages: list[Self] | None = None + _remote_page_ids: dict[str, int] | None = None + + def __init__(self, page_path: str | PathLike | None = None, page_id: int | None = None): + # single page object can only be created as either local or remote at the moment + # this can be changed later to allow a merge-like behavior + if (not page_path and not page_id) or (page_path and page_id): + raise InvalidPageConfiguration + + self.api = API() + + if page_id: + # if page is remote - it can only be used for downloading + self.page_id = page_id + page_data = self._get_data_by_id() + self.format = page_data["format"] + + # if page is remote we have to infer a local file name + # default to route.ext which also works for nesting: nested/route.ext + self.page_file_path = f"{page_data['route']}{self.get_format_extension(self.format)}" + else: + # if page is local - it can be used for sync, upload and download (with overwrite) + self.page_path = Path(page_path) + page_data = self._get_data_by_path() + self.format = self.get_format(self.page_path.suffix) + + # check if page is also remote, so that we can potentially sync + remote_page_id = self.get_remote_page_id(page_data["route"]) + if remote_page_id: + self.page_id = remote_page_id + + # if page is local we can know the page file path + # which should be output as relative to the pages directory + self.page_file_path = str(self.page_path.relative_to(Config.get_pages_path())) + + self.route = page_data["route"] + self.title = page_data["title"] + self.content = page_data["content"] + self.is_draft = bool(page_data.get("draft", False)) + self.is_hidden = bool(page_data.get("hidden", False)) + self.is_auth_required = bool(page_data.get("auth_required", False)) + + def __str__(self): + return self.page_file_path + + def _get_data_by_path(self) -> dict | None: + if not self.page_path.exists(): + return None + + with open(self.page_path) as page_file: + page_data = frontmatter.load(page_file) + content = Media.replace_placeholders(page_data.content) + return {**page_data.metadata, "content": content} + + def _get_data_by_id(self) -> dict | None: + r = self.api.get(f"/api/v1/pages/{self.page_id}") + + if not r.ok: + return None + + return r.json()["data"] + + def as_dict(self): + return { + "route": self.route, + "title": self.title, + "content": self.content, + "draft": self.is_draft, + "hidden": self.is_hidden, + "auth_required": self.is_auth_required, + "format": self.format, + } + + def as_frontmatter_post(self) -> frontmatter.Post: + metadata = { + "route": self.route, + "title": self.title, + "draft": self.is_draft, + "hidden": self.is_hidden, + "auth_required": self.is_auth_required, + } + return frontmatter.Post(self.content, **metadata) + + def sync(self): + # sync / update remote copy with local state + if not getattr(self, "page_id", None): + raise IllegalPageOperation( + f"Cannot sync page '{self.page_file_path}' - remote version does not exists. Use push first." + ) + + if not getattr(self, "page_path", None): + raise IllegalPageOperation( + f"Cannot sync page '{self.page_file_path}' - local version does not exists. Use pull first." + ) + + r = self.api.patch(f"/api/v1/pages/{self.page_id}", json=self.as_dict()) + r.raise_for_status() + + def pull(self, overwrite=False): + # download / create local copy of a remote page + # download without overwrite is useful only for initial pull + if not getattr(self, "page_id", None): + raise IllegalPageOperation(f"Cannot pull page '{self.page_file_path}' - remote version does not exists.") + + page_path = Config.get_pages_path() / self.page_file_path + + # make sure all necessary directories exist + page_path.parent.mkdir(parents=True, exist_ok=True) + + if page_path.is_file(): + if not overwrite: + raise IllegalPageOperation(f"Cannot pull page '{self.page_file_path}' - file already exists.") + + click.secho(f"Overwriting page file '{self.page_file_path}'", fg="yellow") + + with open(page_path, "wb+") as page_file: + frontmatter.dump(self.as_frontmatter_post(), page_file) + + def push(self): + # upload / create remote copy of a local page + if getattr(self, "page_id", None): + raise IllegalPageOperation( + f"Cannot push page '{self.page_file_path}' - remote version exists. Use sync instead." + ) + + r = self.api.post("/api/v1/pages", json=self.as_dict()) + r.raise_for_status() + + self.page_id = r.json()["data"]["id"] + + @staticmethod + def get_format(ext) -> str: + if ext not in PAGE_FORMATS: + raise InvalidPageFormat + + return PAGE_FORMATS[ext] + + @staticmethod + def get_format_extension(fmt) -> str: + if fmt is None: + return ".md" + for supported_ext, supported_fmt in PAGE_FORMATS.items(): + if fmt == supported_fmt: + return supported_ext + + raise InvalidPageFormat + + @classmethod + def get_remote_pages(cls) -> list[Self]: + # if we find a saved list of remote pages we can use it + if cls._remote_pages: + return cls._remote_pages + + api = API() + installed_pages = api.get("/api/v1/pages").json()["data"] + + pages = [] + for page in installed_pages: + pages.append(Page(page_id=page["id"])) + + # save remote pages for reuse + cls._remote_pages = pages + return pages + + @classmethod + def get_remote_page_id(cls, route: str) -> int | None: + # if we find a saved cache, and the route has a page_id associated - we can return it + if cls._remote_page_ids and route in cls._remote_page_ids: + return cls._remote_page_ids[route] + + # if we find a saved list of remote pages instead, we can also use it + if cls._remote_pages: + for page in cls._remote_pages: + if route == page.route: + return page.page_id + + # otherwise we just take the result of listing all pages to search for the id + # and build the lookup cache for further use + api = API() + remote_pages = api.get("/api/v1/pages").json()["data"] + + remote_page_ids = {} + for page in remote_pages: + remote_page_ids[page["route"]] = page["id"] + + cls._remote_page_ids = remote_page_ids + + # finally return the page id from the lookup dict, if the route has been found + return cls._remote_page_ids.get(route, None) + + @classmethod + def get_local_pages(cls) -> list[Self]: + config = Config() + pages_dir = config.get_pages_path() + + page_files = [] + for supported_ext in PAGE_FORMATS: + page_files.extend(list(pages_dir.glob(f"**/*{supported_ext}"))) + + pages = [] + for page_path in page_files: + pages.append(Page(page_path=page_path)) + + return pages diff --git a/ctfcli/core/plugins.py b/ctfcli/core/plugins.py new file mode 100644 index 0000000..2631243 --- /dev/null +++ b/ctfcli/core/plugins.py @@ -0,0 +1,25 @@ +import importlib +import logging +import sys + +from ctfcli.core.config import Config + +log = logging.getLogger("ctfcli.core.plugins") + + +def load_plugins(commands: dict): + plugins_path = Config.get_plugins_path() + sys.path.insert(0, str(plugins_path.absolute())) + + for plugin in sorted(plugins_path.iterdir()): + if plugin.name.startswith("_") or plugin.name.startswith("."): + continue + + plugin_path = plugins_path / plugin / "__init__.py" + + log.debug(f"Loading plugin '{plugin}' from '{plugin_path}'") + + loaded = importlib.import_module(plugin.stem) + loaded.load(commands) + + sys.path.remove(str(plugins_path.absolute())) diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml index a4b3989..33f4cd3 100644 --- a/ctfcli/spec/challenge-example.yml +++ b/ctfcli/spec/challenge-example.yml @@ -6,6 +6,7 @@ name: "{{cookiecutter.name}}" author: "author" category: category description: This is a sample description +attribution: Written by [author](https://ctfd.io) value: 100 type: standard @@ -24,6 +25,12 @@ type: standard # If you have an imaged hosted on Docker set to the image url (e.g. python/3.8:latest, registry.gitlab.com/python/3.8:latest) # Follow Docker best practices and assign a tag image: null + +# Specify a protocol that should be used to connect to the running image +# For example if the image is a website you can specify http or https +# Otherwise you can specify tcp +protocol: null + # Specify a host to deploy the challenge onto. # The currently supported URI schemes are ssh:// and registry:// # ssh is an ssh URI where the above image will be copied to and deployed (e.g. ssh://root@123.123.123.123) @@ -41,9 +48,24 @@ connection_info: nc hostname 12345 # ./writeup/exploit.sh --connection-info "nc hostname 12345" healthcheck: writeup/exploit.sh +# solution is used to provide a path to the challenge solution document. +# The file path is relative to this challenge.yml file. +# If provided as a string path, ctfcli uploads it as a hidden CTFd solution during sync. +# You can also use an object: +# solution: +# path: writeup/WRITEUP.md +# state: solved # hidden | visible | solved +solution: writeup/WRITEUP.md + # Can be removed if unused attempts: 5 +# Specifies flag processing behavior of challenge +# Accept any flag (default): any +# Require all flags to be provided: all +# Require all team members to submit any flag: team +logic: any + # Flags specify answers that your challenge use. You should generally provide at least one. # Can be removed if unused # Accepts strings or dictionaries of CTFd API data @@ -54,6 +76,7 @@ flags: - { type: "static", content: "flag{wat}", + data: "case_sensitive", } # A static case insensitive flag - { @@ -99,6 +122,9 @@ hints: cost: 10 } - This hint is free + - title: Titled Hint + content: This hint has a title and costs points + cost: 10 # Requirements are used to make a challenge require another challenge to be # solved before being available. @@ -108,6 +134,22 @@ requirements: - "Warmup" - "Are you alive" +# The following format for requirements can also be used. This format can also +# support the "anonymize" flag, which will display the challenge but with all +# the challenge information anonymized if it is not unlocked yet. +#requirements: +# prerequisites: +# - "Warmup" +# - "Are you alive" +# anonymize: true + +# The next is used to display a next recommended challenge to a user when +# the user correctly answers the current challenge. +# Can be removed if unused +# Accepts a challenge name as a string, a challenge ID as an integer, or null +# if you want to remove or disable it. +next: null + # The state of the challenge. # If the field is omitted, the challenge is visible by default. # If provided, the field can take one of two values: hidden, visible. @@ -115,4 +157,4 @@ state: hidden # Specifies what version of the challenge specification was used. # Subject to change until ctfcli v1.0.0 -version: "0.1" \ No newline at end of file +version: "0.1" diff --git a/ctfcli/templates/blank/empty/cookiecutter.json b/ctfcli/templates/blank/empty/cookiecutter.json new file mode 100644 index 0000000..21a8468 --- /dev/null +++ b/ctfcli/templates/blank/empty/cookiecutter.json @@ -0,0 +1,4 @@ +{ + "name": "challenge", + "dirname": "challenge" +} \ No newline at end of file diff --git a/ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml b/ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml new file mode 100644 index 0000000..2e841df --- /dev/null +++ b/ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml @@ -0,0 +1 @@ +name: "{{cookiecutter.name}}" \ No newline at end of file diff --git a/ctfcli/templates/crypto/default/cookiecutter.json b/ctfcli/templates/crypto/default/cookiecutter.json index e8b089c..9fbc311 100644 --- a/ctfcli/templates/crypto/default/cookiecutter.json +++ b/ctfcli/templates/crypto/default/cookiecutter.json @@ -1,3 +1,3 @@ { - "name": "Hello" + "name": "crypto-challenge" } \ No newline at end of file diff --git a/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py b/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py index 559a418..aa12ee5 100755 --- a/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py +++ b/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py @@ -3,9 +3,8 @@ from flask import Flask, redirect, render_template, request, session, url_for from flask_bootstrap import Bootstrap -from sqlalchemy.exc import IntegrityError - from models import Users, db, hash_password, verify_password +from sqlalchemy.exc import IntegrityError app = Flask(__name__) app.config.from_object("config.Config") diff --git a/ctfcli/utils/api.py b/ctfcli/utils/api.py deleted file mode 100644 index 1e3f660..0000000 --- a/ctfcli/utils/api.py +++ /dev/null @@ -1,17 +0,0 @@ -from urllib.parse import urljoin - -from requests import Session - - -class APISession(Session): - def __init__(self, prefix_url=None, *args, **kwargs): - super(APISession, self).__init__(*args, **kwargs) - # Strip out ending slashes and append a singular one so we generate - # clean base URLs for both main deployments and subdir deployments - self.prefix_url = prefix_url.rstrip("/") + "/" - - def request(self, method, url, *args, **kwargs): - # Strip out the preceding / so that urljoin creates the right url - # considering the appended / on the prefix_url - url = urljoin(self.prefix_url, url.lstrip("/")) - return super(APISession, self).request(method, url, *args, **kwargs) diff --git a/ctfcli/utils/challenge.py b/ctfcli/utils/challenge.py deleted file mode 100644 index 7094fd5..0000000 --- a/ctfcli/utils/challenge.py +++ /dev/null @@ -1,408 +0,0 @@ -from pathlib import Path -import subprocess - -import click -import yaml - -from .config import generate_session -from .tools import strings - - -class Yaml(dict): - def __init__(self, data, file_path=None): - super().__init__(data) - self.file_path = Path(file_path) - self.directory = self.file_path.parent - - -def load_challenge(path): - try: - with open(path) as f: - return Yaml(data=yaml.safe_load(f.read()), file_path=path) - except FileNotFoundError: - click.secho(f"No challenge.yml was found in {path}", fg="red") - return - - -def load_installed_challenge(challenge_id): - s = generate_session() - return s.get(f"/api/v1/challenges/{challenge_id}", json=True).json()["data"] - - -def load_installed_challenges(): - s = generate_session() - return s.get("/api/v1/challenges?view=admin", json=True).json()["data"] - - -def sync_challenge(challenge, ignore=[]): - data = { - "name": challenge["name"], - "category": challenge["category"], - "description": challenge["description"], - "type": challenge.get("type", "standard"), - "value": int(challenge["value"]) if challenge["value"] else challenge["value"], - **challenge.get("extra", {}), - } - - # Some challenge types (e.g. dynamic) override value. - # We can't send it to CTFd because we don't know the current value - if challenge["value"] is None: - del challenge["value"] - - if challenge.get("attempts") and "attempts" not in ignore: - data["max_attempts"] = challenge.get("attempts") - - if challenge.get("connection_info") and "connection_info" not in ignore: - data["connection_info"] = challenge.get("connection_info") - - data["state"] = "hidden" - - installed_challenges = load_installed_challenges() - for c in installed_challenges: - if c["name"] == challenge["name"]: - if "category" in ignore: - data["category"] = c["category"] - challenge_id = c["id"] - break - else: - return - - s = generate_session() - - original_challenge = s.get(f"/api/v1/challenges/{challenge_id}", json=data).json()[ - "data" - ] - - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - # Create new flags - if challenge.get("flags") and "flags" not in ignore: - # Delete existing flags - current_flags = s.get(f"/api/v1/flags", json=data).json()["data"] - for flag in current_flags: - if flag["challenge_id"] == challenge_id: - flag_id = flag["id"] - r = s.delete(f"/api/v1/flags/{flag_id}", json=True) - r.raise_for_status() - for flag in challenge["flags"]: - if type(flag) == str: - data = {"content": flag, "type": "static", "challenge_id": challenge_id} - r = s.post(f"/api/v1/flags", json=data) - r.raise_for_status() - elif type(flag) == dict: - flag["challenge_id"] = challenge_id - r = s.post(f"/api/v1/flags", json=flag) - r.raise_for_status() - - # Update topics - if challenge.get("topics") and "topics" not in ignore: - # Delete existing challenge topics - current_topics = s.get( - f"/api/v1/challenges/{challenge_id}/topics", json="" - ).json()["data"] - for topic in current_topics: - topic_id = topic["id"] - r = s.delete( - f"/api/v1/topics?type=challenge&target_id={topic_id}", json=True - ) - r.raise_for_status() - # Add new challenge topics - for topic in challenge["topics"]: - r = s.post( - f"/api/v1/topics", - json={ - "value": topic, - "type": "challenge", - "challenge_id": challenge_id, - }, - ) - r.raise_for_status() - - # Update tags - if challenge.get("tags") and "tags" not in ignore: - # Delete existing tags - current_tags = s.get(f"/api/v1/tags", json=data).json()["data"] - for tag in current_tags: - if tag["challenge_id"] == challenge_id: - tag_id = tag["id"] - r = s.delete(f"/api/v1/tags/{tag_id}", json=True) - r.raise_for_status() - for tag in challenge["tags"]: - r = s.post( - f"/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} - ) - r.raise_for_status() - - # Upload files - if challenge.get("files") and "files" not in ignore: - # Delete existing files - all_current_files = s.get(f"/api/v1/files?type=challenge", json=data).json()[ - "data" - ] - for f in all_current_files: - for used_file in original_challenge["files"]: - if f["location"] in used_file: - file_id = f["id"] - r = s.delete(f"/api/v1/files/{file_id}", json=True) - r.raise_for_status() - files = [] - for f in challenge["files"]: - file_path = Path(challenge.directory, f) - if file_path.exists(): - file_object = ("file", file_path.open(mode="rb")) - files.append(file_object) - else: - click.secho(f"File {file_path} was not found", fg="red") - raise Exception(f"File {file_path} was not found") - - data = {"challenge_id": challenge_id, "type": "challenge"} - # Specifically use data= here instead of json= to send multipart/form-data - r = s.post(f"/api/v1/files", files=files, data=data) - r.raise_for_status() - - # Create hints - if challenge.get("hints") and "hints" not in ignore: - # Delete existing hints - current_hints = s.get(f"/api/v1/hints", json=data).json()["data"] - for hint in current_hints: - if hint["challenge_id"] == challenge_id: - hint_id = hint["id"] - r = s.delete(f"/api/v1/hints/{hint_id}", json=True) - r.raise_for_status() - - for hint in challenge["hints"]: - if type(hint) == str: - data = {"content": hint, "cost": 0, "challenge_id": challenge_id} - else: - data = { - "content": hint["content"], - "cost": hint["cost"], - "challenge_id": challenge_id, - } - - r = s.post(f"/api/v1/hints", json=data) - r.raise_for_status() - - # Update requirements - if challenge.get("requirements") and "requirements" not in ignore: - installed_challenges = load_installed_challenges() - required_challenges = [] - for r in challenge["requirements"]: - if type(r) == str: - for c in installed_challenges: - if c["name"] == r: - required_challenges.append(c["id"]) - elif type(r) == int: - required_challenges.append(r) - - required_challenges = list(set(required_challenges)) - data = {"requirements": {"prerequisites": required_challenges}} - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - # Unhide challenge depending upon the value of "state" in spec - if "state" not in ignore: - data = {"state": "visible"} - if challenge.get("state"): - if challenge["state"] in ["hidden", "visible"]: - data["state"] = challenge["state"] - - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - -def create_challenge(challenge, ignore=[]): - data = { - "name": challenge["name"], - "category": challenge["category"], - "description": challenge["description"], - "type": challenge.get("type", "standard"), - "value": int(challenge["value"]) if challenge["value"] else challenge["value"], - **challenge.get("extra", {}), - } - - # Some challenge types (e.g. dynamic) override value. - # We can't send it to CTFd because we don't know the current value - if challenge["value"] is None: - del challenge["value"] - - if challenge.get("attempts") and "attempts" not in ignore: - data["max_attempts"] = challenge.get("attempts") - - if challenge.get("connection_info") and "connection_info" not in ignore: - data["connection_info"] = challenge.get("connection_info") - - s = generate_session() - - r = s.post("/api/v1/challenges", json=data) - r.raise_for_status() - - challenge_data = r.json() - challenge_id = challenge_data["data"]["id"] - - # Create flags - if challenge.get("flags") and "flags" not in ignore: - for flag in challenge["flags"]: - if type(flag) == str: - data = {"content": flag, "type": "static", "challenge_id": challenge_id} - r = s.post(f"/api/v1/flags", json=data) - r.raise_for_status() - elif type(flag) == dict: - flag["challenge"] = challenge_id - r = s.post(f"/api/v1/flags", json=flag) - r.raise_for_status() - - # Create topics - if challenge.get("topics") and "topics" not in ignore: - for topic in challenge["topics"]: - r = s.post( - f"/api/v1/topics", - json={ - "value": topic, - "type": "challenge", - "challenge_id": challenge_id, - }, - ) - r.raise_for_status() - - # Create tags - if challenge.get("tags") and "tags" not in ignore: - for tag in challenge["tags"]: - r = s.post( - f"/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} - ) - r.raise_for_status() - - # Upload files - if challenge.get("files") and "files" not in ignore: - files = [] - for f in challenge["files"]: - file_path = Path(challenge.directory, f) - if file_path.exists(): - file_object = ("file", file_path.open(mode="rb")) - files.append(file_object) - else: - click.secho(f"File {file_path} was not found", fg="red") - raise Exception(f"File {file_path} was not found") - - data = {"challenge_id": challenge_id, "type": "challenge"} - # Specifically use data= here instead of json= to send multipart/form-data - r = s.post(f"/api/v1/files", files=files, data=data) - r.raise_for_status() - - # Add hints - if challenge.get("hints") and "hints" not in ignore: - for hint in challenge["hints"]: - if type(hint) == str: - data = {"content": hint, "cost": 0, "challenge_id": challenge_id} - else: - data = { - "content": hint["content"], - "cost": hint["cost"], - "challenge_id": challenge_id, - } - - r = s.post(f"/api/v1/hints", json=data) - r.raise_for_status() - - # Add requirements - if challenge.get("requirements") and "requirements" not in ignore: - installed_challenges = load_installed_challenges() - required_challenges = [] - for r in challenge["requirements"]: - if type(r) == str: - for c in installed_challenges: - if c["name"] == r: - required_challenges.append(c["id"]) - elif type(r) == int: - required_challenges.append(r) - - required_challenges = list(set(required_challenges)) - data = {"requirements": {"prerequisites": required_challenges}} - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - # Set challenge state - if challenge.get("state") and "state" not in ignore: - data = {"state": "hidden"} - if challenge["state"] in ["hidden", "visible"]: - data["state"] = challenge["state"] - - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - -def lint_challenge(path): - try: - challenge = load_challenge(path) - except yaml.YAMLError as e: - click.secho(f"Error parsing challenge.yml: {e}", fg="red") - exit(1) - - required_fields = ["name", "author", "category", "description", "value"] - errors = [] - for field in required_fields: - if field == "value" and challenge.get("type") == "dynamic": - pass - else: - if challenge.get(field) is None: - errors.append(field) - - if len(errors) > 0: - print("Missing fields: ", ", ".join(errors)) - exit(1) - - # Check that the image field and Dockerfile match - if (Path(path).parent / "Dockerfile").is_file() and challenge.get("image") != ".": - print("Dockerfile exists but image field does not point to it") - exit(1) - - # Check that Dockerfile exists and is EXPOSE'ing a port - if challenge.get("image") == ".": - try: - dockerfile = (Path(path).parent / "Dockerfile").open().read() - except FileNotFoundError: - print("Dockerfile specified in 'image' field but no Dockerfile found") - exit(1) - - if "EXPOSE" not in dockerfile: - print("Dockerfile missing EXPOSE") - exit(1) - - # Check Dockerfile with hadolint - proc = subprocess.run( - ["docker", "run", "--rm", "-i", "hadolint/hadolint"], - input=dockerfile.encode(), - ) - if proc.returncode != 0: - print("Hadolint found Dockerfile lint issues, please resolve") - exit(1) - - # Check that all files exists - files = challenge.get("files", []) - errored = False - for f in files: - fpath = Path(path).parent / f - if fpath.is_file() is False: - print(f"File {f} specified but not found at {fpath.absolute()}") - errored = True - if errored: - exit(1) - - # Check that files don't have a flag in them - files = challenge.get("files", []) - errored = False - for f in files: - fpath = Path(path).parent / f - for s in strings(fpath): - # TODO make flag format customizable - if "flag" in s: - print( - f"Potential flag {s} found in distributed file {fpath.absolute()}" - ) - errored = True - if errored: - exit(1) - - exit(0) diff --git a/ctfcli/utils/config.py b/ctfcli/utils/config.py deleted file mode 100644 index 207f1ba..0000000 --- a/ctfcli/utils/config.py +++ /dev/null @@ -1,83 +0,0 @@ -import configparser -import json -import os - -from ctfcli import __file__ as base_path - -from .api import APISession - - -def get_base_path(): - return os.path.dirname(base_path) - - -def get_config_path(): - pwd = os.getcwd() - while pwd: - config = os.path.join(pwd, ".ctf/config") - if os.path.isfile(config): - return config - new_pwd = os.path.dirname(pwd) - pwd = None if new_pwd == pwd else new_pwd - return None - - -def get_project_path(): - pwd = os.getcwd() - while pwd: - config = os.path.join(pwd, ".ctf/config") - if os.path.isfile(config): - return pwd - new_pwd = os.path.dirname(pwd) - pwd = None if new_pwd == pwd else new_pwd - return None - - -def load_config(): - path = get_config_path() - parser = configparser.ConfigParser() - - # Preserve case in configparser - parser.optionxform = str - - parser.read(path) - return parser - - -def preview_config(as_string=False): - config = load_config() - - d = {} - for section in config.sections(): - d[section] = {} - for k, v in config.items(section): - d[section][k] = v - - preview = json.dumps(d, sort_keys=True, indent=4) - - if as_string is True: - return preview - else: - print(preview) - - -def generate_session(): - config = load_config() - - # Load required configuration values - url = config["config"]["url"] - access_token = config["config"]["access_token"] - - # Handle SSL verification disabling - try: - # Get an ssl_verify config. Default to True if it doesn't exist - ssl_verify = config["config"].getboolean("ssl_verify", True) - except ValueError: - # If we didn't a proper boolean value we should load it as a string - # https://requests.kennethreitz.org/en/master/user/advanced/#ssl-cert-verification - ssl_verify = config["config"].get("ssl_verify") - - s = APISession(prefix_url=url) - s.verify = ssl_verify - s.headers.update({"Authorization": f"Token {access_token}"}) - return s diff --git a/ctfcli/utils/deploy.py b/ctfcli/utils/deploy.py deleted file mode 100644 index 2945fe2..0000000 --- a/ctfcli/utils/deploy.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import subprocess -from pathlib import Path -from urllib.parse import urlparse - -from ctfcli.utils.images import build_image, export_image, get_exposed_ports - - -def ssh(challenge, host): - # Build image - image_name = build_image(challenge=challenge) - print(f"Built {image_name}") - - # Export image to a file - image_path = export_image(challenge=challenge) - print(f"Exported {image_name} to {image_path}") - filename = Path(image_path).name - - # Transfer file to SSH host - print(f"Transferring {image_path} to {host}") - host = urlparse(host) - folder = host.path or "/tmp" - target_file = f"{folder}/{filename}" - exposed_port = get_exposed_ports(challenge=challenge) - domain = host.netloc[host.netloc.find("@") + 1 :] - subprocess.run(["scp", image_path, f"{host.netloc}:{target_file}"]) - subprocess.run( - ["ssh", host.netloc, f"docker load -i {target_file} && rm {target_file}"] - ) - subprocess.run( - [ - "ssh", - host.netloc, - f"docker run -d -p{exposed_port}:{exposed_port} --name {image_name} --restart always {image_name}", - ] - ) - - # Clean up files - os.remove(image_path) - print(f"Cleaned up {image_path}") - - return True, domain, exposed_port - - -def registry(challenge, host): - # Build image - image_name = build_image(challenge=challenge) - print(f"Built {image_name}") - url = urlparse(host) - tag = f"{url.netloc}{url.path}" - subprocess.call(["docker", "tag", image_name, tag]) - subprocess.call(["docker", "push", tag]) - - -DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry} diff --git a/ctfcli/utils/git.py b/ctfcli/utils/git.py index d29640f..2daa866 100644 --- a/ctfcli/utils/git.py +++ b/ctfcli/utils/git.py @@ -1,35 +1,78 @@ import subprocess +from os import PathLike -def get_git_repo_head_branch(repo): +def resolve_repo_url(repo: str, branch: str | None = None) -> tuple[str, str | None]: """ - A helper method to get the reference of the HEAD branch of a git remote repo. - https://stackoverflow.com/a/41925348 + Resolves a repo string to (clean_url, branch). + + Resolution order: + 1. The `branch` parameter, if provided + 2. An inline @branch parsed from the repo string + 3. The remote's HEAD branch, detected via git ls-remote + + Returns (url, None) if no branch can be determined. """ - out = subprocess.check_output( - ["git", "ls-remote", "--symref", repo, "HEAD"] - ).decode() - head_branch = out.split()[1] - return head_branch + # Strip an inline @branch suffix if present + marker = ".git@" + idx = repo.rfind(marker) + if idx != -1: + inline_branch = repo[idx + 5 :] + repo = repo[: idx + 4] # clean URL up to .git + if not branch and inline_branch: + branch = inline_branch + + # Branch already resolved + if branch: + return repo, branch + + # Non-git paths have no remote to query + if not repo.endswith(".git"): + return repo, None + + # Fall back to detecting the remote HEAD branch + # https://stackoverflow.com/a/41925348 + try: + output = subprocess.check_output( + ["git", "ls-remote", "--symref", repo, "HEAD"], + stderr=subprocess.DEVNULL, + ) + + # repo exists but doesn't have a head branch + if type(output) != bytes or len(output) == 0: + return repo, None + + except subprocess.CalledProcessError: + return repo, None + + output = output.decode().strip() + head_branch_line = output.split()[1] + if head_branch_line.startswith("refs/heads/"): + return repo, head_branch_line[11:] + + return repo, None -def check_if_dir_is_inside_git_repo(dir=None): +def check_if_git_subrepo_is_installed() -> bool: + output = subprocess.run(["git", "subrepo"], capture_output=True, text=True) + return "git: 'subrepo' is not a git command" not in output.stderr + + +def check_if_dir_is_inside_git_repo(cwd: str | PathLike | None = None) -> bool: """ - Checks whether a given directory is inside of a git repo. + Checks whether a given directory is inside a git repo. """ try: out = ( subprocess.check_output( ["git", "rev-parse", "--is-inside-work-tree"], - cwd=dir, + cwd=cwd, stderr=subprocess.DEVNULL, ) .decode() .strip() ) - print(out) - if out == "true": - return True - return False + + return out == "true" except subprocess.CalledProcessError: return False diff --git a/ctfcli/utils/hashing.py b/ctfcli/utils/hashing.py new file mode 100644 index 0000000..81e7651 --- /dev/null +++ b/ctfcli/utils/hashing.py @@ -0,0 +1,16 @@ +import hashlib + + +def hash_file(fp, algo="sha1"): + fp.seek(0) + if algo == "sha1": + h = hashlib.sha1() # noqa: S324 + + # https://stackoverflow.com/a/64730457 + while chunk := fp.read(1024): + h.update(chunk) + + fp.seek(0) + return h.hexdigest() + + raise NotImplementedError diff --git a/ctfcli/utils/images.py b/ctfcli/utils/images.py deleted file mode 100644 index 1b1e666..0000000 --- a/ctfcli/utils/images.py +++ /dev/null @@ -1,47 +0,0 @@ -import json -import subprocess -import tempfile -from pathlib import Path - - -def sanitize_name(name): - """ - Function to sanitize names to docker safe image names - TODO: Good enough but probably needs to be more conformant with docker - """ - return name.lower().replace(" ", "-") - - -def build_image(challenge): - name = sanitize_name(challenge["name"]) - path = Path(challenge.file_path).parent.absolute() - print(f"Building {name} from {path}") - subprocess.call(["docker", "build", "-t", name, "."], cwd=path) - return name - - -def export_image(challenge): - name = sanitize_name(challenge["name"]) - temp = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{name}.docker.tar") - subprocess.call(["docker", "save", "--output", temp.name, name]) - return temp.name - - -def get_exposed_ports(challenge): - image_name = sanitize_name(challenge["name"]) - output = subprocess.check_output( - ["docker", "inspect", "--format={{json .Config.ExposedPorts }}", image_name,] - ) - output = json.loads(output) - if output: - ports = list(output.keys()) - if ports: - # Split '2323/tcp' - port = ports[0] - port = port.split("/") - port = port[0] - return port - else: - return None - else: - return None diff --git a/ctfcli/utils/pages.py b/ctfcli/utils/pages.py deleted file mode 100644 index 6ddd568..0000000 --- a/ctfcli/utils/pages.py +++ /dev/null @@ -1,71 +0,0 @@ -from .config import generate_session - -FORMATS = { - ".md": "markdown", - ".html": "html", - ".htm": "html", -} - - -def get_current_pages(): - s = generate_session() - return s.get(f"/api/v1/pages", json=True).json()["data"] - - -def get_existing_page(route, pageset=None): - if pageset is None: - pageset = get_current_pages() - for page in pageset: - if route == page["route"]: - return page - return None - - -def get_format(ext): - return FORMATS[ext] - - -def sync_page(matter, path_obj, page_id): - route = matter["route"] - title = matter["title"] - content = matter.content - draft = bool(matter.get("draft")) - hidden = bool(matter.get("hidden")) - auth_required = bool(matter.get("auth_required")) - format = get_format(path_obj.suffix) - - s = generate_session() - data = { - "route": route, - "title": title, - "content": content, - "draft": draft, - "hidden": hidden, - "auth_required": auth_required, - "format": format, - } - r = s.patch(f"/api/v1/pages/{page_id}", json=data) - r.raise_for_status() - - -def install_page(matter, path_obj): - route = matter["route"] - title = matter["title"] - content = matter.content - draft = bool(matter.get("draft")) - hidden = bool(matter.get("hidden")) - auth_required = bool(matter.get("auth_required")) - format = get_format(path_obj.suffix) - - s = generate_session() - data = { - "route": route, - "title": title, - "content": content, - "draft": draft, - "hidden": hidden, - "auth_required": auth_required, - "format": format, - } - r = s.post(f"/api/v1/pages", json=data) - r.raise_for_status() diff --git a/ctfcli/utils/plugins.py b/ctfcli/utils/plugins.py deleted file mode 100644 index 6f93c21..0000000 --- a/ctfcli/utils/plugins.py +++ /dev/null @@ -1,16 +0,0 @@ -import os - -import appdirs - -from ctfcli import __name__ as pkg_name - - -def get_plugin_dir(): - plugins_path = os.path.join(get_data_dir(), "plugins") - if not os.path.exists(plugins_path): - os.makedirs(plugins_path) - return os.path.join(plugins_path) - - -def get_data_dir(): - return appdirs.user_data_dir(appname=pkg_name) diff --git a/ctfcli/utils/spec.py b/ctfcli/utils/spec.py deleted file mode 100644 index de5828a..0000000 --- a/ctfcli/utils/spec.py +++ /dev/null @@ -1,122 +0,0 @@ -import getpass -from collections import namedtuple -from pathlib import Path - -import yaml - -Prompt = namedtuple("Prompt", ["text", "type", "default", "required", "multiple"]) - - -CHALLENGE_SPEC_DOCS = { - "name": Prompt( - text="Challenge name or identifier", - type=None, - default=None, - required=True, - multiple=False, - ), - "author": Prompt( - text="Your name or handle", - type=None, - default=getpass.getuser(), - required=True, - multiple=False, - ), - "category": Prompt( - text="Challenge category", - type=None, - default=None, - required=True, - multiple=False, - ), - "description": Prompt( - text="Challenge description shown to the user", - type=None, - default=None, - required=True, - multiple=False, - ), - "value": Prompt( - text="How many points your challenge should be worth", - type=int, - default=None, - required=True, - multiple=False, - ), - "version": Prompt( - text="What version of the challenge specification was used", - type=None, - default="0.1", - required=False, - multiple=False, - ), - "image": Prompt( - text="Docker image used to deploy challenge", - type=None, - default=None, - required=False, - multiple=False, - ), - "type": Prompt( - text="Type of challenge", - type=None, - default="standard", - required=True, - multiple=False, - ), - "attempts": Prompt( - text="How many attempts should the player have", - type=int, - default=None, - required=False, - multiple=False, - ), - "flags": Prompt( - text="Flags that mark the challenge as solved", - type=None, - default=None, - required=False, - multiple=True, - ), - "tags": Prompt( - text="Tag that denotes a challenge topic", - type=None, - default=None, - required=False, - multiple=True, - ), - "files": Prompt( - text="Files to be shared with the user", - type=None, - default=None, - required=False, - multiple=True, - ), - "hints": Prompt( - text="Hints to be shared with the user", - type=None, - default=None, - required=False, - multiple=True, - ), - "requirements": Prompt( - text="Challenge dependencies that must be solved before this one can be attempted", - type=None, - default=None, - required=False, - multiple=True, - ), -} - - -def blank_challenge_spec(): - pwd = Path(__file__) - spec = pwd.parent.parent / "spec" / "challenge-example.yml" - with open(spec) as f: - blank = yaml.safe_load(f) - - for k in blank: - if k != "version": - blank[k] = None - - return blank diff --git a/ctfcli/utils/templates.py b/ctfcli/utils/templates.py deleted file mode 100644 index 394693b..0000000 --- a/ctfcli/utils/templates.py +++ /dev/null @@ -1,16 +0,0 @@ -import os - -import appdirs - -from ctfcli import __name__ as pkg_name - - -def get_template_dir(): - plugins_path = os.path.join(get_data_dir(), "templates") - if not os.path.exists(plugins_path): - os.makedirs(plugins_path) - return os.path.join(plugins_path) - - -def get_data_dir(): - return appdirs.user_data_dir(appname=pkg_name) diff --git a/ctfcli/utils/tools.py b/ctfcli/utils/tools.py index 1452f4e..620a24f 100644 --- a/ctfcli/utils/tools.py +++ b/ctfcli/utils/tools.py @@ -1,19 +1,32 @@ +import re import string -def strings(filename, min=4): +def strings(filename, min_length=4): """ Python implementation of strings https://stackoverflow.com/a/17197027 """ with open(filename, errors="ignore") as f: result = "" + for c in f.read(): if c in string.printable: result += c continue - if len(result) >= min: + + if len(result) >= min_length: yield result + result = "" - if len(result) >= min: # catch result at EOF + + if len(result) >= min_length: # catch result at EOF yield result + + +def safe_format(fmt, items): + """ + Function that safely formats strings with arbitrary potentially user-supplied format strings + Looks for interpolation placeholders like {target} or {{ target }} + """ + return re.sub(r"\{?\{([^{}]*)\}\}?", lambda m: items.get(m.group(1).strip(), m.group(0)), fmt) diff --git a/development.txt b/development.txt deleted file mode 100644 index e834895..0000000 --- a/development.txt +++ /dev/null @@ -1,10 +0,0 @@ --r requirements.txt -twine==3.1.1 -setuptools==46.1.3 -wheel==0.34.2 -black==19.10b0 -flake8==3.7.9 -flake8-bugbear==20.1.2 -flake8-comprehensions==3.1.4 -isort==4.3.21 -flake8-isort==2.8.0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e8a368 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "ctfcli" +version = "0.1.7" +description = "ctfcli is a tool to manage Capture The Flag events and challenges" +authors = [ + { name = "Kevin Chung", email = "kchung@ctfd.io" }, + { name = "Miłosz Skaza", email = "milosz.skaza@ctfd.io" }, +] +requires-python = ">=3.10,<4" +readme = "README.md" +dependencies = [ + "python-frontmatter>=1.0.0,<2", + "requests>=2.31.0,<3", + "pyyaml>=6.0.1,<7", + "click>=8.1.7,<9", + "cookiecutter>=2.3.0,<3", + "appdirs>=1.4.4,<2", + "colorama>=0.4.6,<0.5", + "fire>=0.7.0,<0.8", + "typing-extensions>=4.7.1,<5", + "python-slugify>=8.0.4,<9", +] + +[project.scripts] +ctf = "ctfcli.__main__:main" + +[dependency-groups] +dev = [ + "ruff>=0.14.9,<0.15", + "pytest>=9.0.2,<10", + "pytest-sugar>=1.1.1,<2", + "pytest-cov>=7.0.0,<8", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "UP", "B", "I", "S", "SIM", "DTZ", "RUF", "RET", "TID"] +ignore = ["E721", "S603", "S607", "RUF012"] +exclude = ["build/**", "ctfcli/templates/**"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S", "SIM", "RUF", "RET503"] + +[tool.ruff.format] +exclude = ["build/**", "ctfcli/templates/**"] + diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 3531835..0000000 --- a/requirements.in +++ /dev/null @@ -1,9 +0,0 @@ -cookiecutter==2.1.1 -click==7.0 -fire==0.2.1 -pyyaml==5.4 -Pygments==2.7.4 -requests==2.28.1 -colorama==0.4.6 -appdirs==1.4.3 -python-frontmatter==1.0.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 92d4357..0000000 --- a/requirements.txt +++ /dev/null @@ -1,65 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: -# -# ./scripts/pip-compile.sh -# -appdirs==1.4.3 - # via -r requirements.in -arrow==1.2.3 - # via jinja2-time -binaryornot==0.4.4 - # via cookiecutter -certifi==2022.9.24 - # via requests -chardet==5.0.0 - # via binaryornot -charset-normalizer==2.1.1 - # via requests -click==7.0 - # via - # -r requirements.in - # cookiecutter -colorama==0.4.6 - # via -r requirements.in -cookiecutter==2.1.1 - # via -r requirements.in -fire==0.2.1 - # via -r requirements.in -idna==3.4 - # via requests -jinja2==3.1.2 - # via - # cookiecutter - # jinja2-time -jinja2-time==0.2.0 - # via cookiecutter -markupsafe==2.1.1 - # via jinja2 -pygments==2.7.4 - # via -r requirements.in -python-dateutil==2.8.2 - # via arrow -python-frontmatter==1.0.0 - # via -r requirements.in -python-slugify==6.1.2 - # via cookiecutter -pyyaml==5.4 - # via - # -r requirements.in - # cookiecutter - # python-frontmatter -requests==2.28.1 - # via - # -r requirements.in - # cookiecutter -six==1.16.0 - # via - # fire - # python-dateutil -termcolor==2.1.0 - # via fire -text-unidecode==1.3 - # via python-slugify -urllib3==1.26.12 - # via requests diff --git a/scripts/pip-compile.sh b/scripts/pip-compile.sh deleted file mode 100755 index 31e9342..0000000 --- a/scripts/pip-compile.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# Script to pin Python requirements in a Docker container -ROOTDIR=`pwd -P` -docker run \ - --rm \ - --entrypoint bash \ - -v $ROOTDIR:/mnt/ctfcli \ - -e CUSTOM_COMPILE_COMMAND='./scripts/pip-compile.sh' \ - -it python:3.9-slim-buster \ - -c 'cd /mnt/ctfcli && pip install pip-tools==6.6.0 && pip-compile' \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index bba699b..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import re - -try: - from setuptools import setup, find_packages -except ImportError: - from distutils.core import setup, find_packages - - -with open("ctfcli/__init__.py") as f: - version = re.search(r'__version__ = "(.*?)"', f.read()).group(1) - - -def read(fname): - try: - with open(os.path.join(os.path.dirname(__file__), fname), "r") as fp: - return fp.read().strip() - except IOError: - return "" - - -setup( - name="ctfcli", - version=version, - author="Kevin Chung", - author_email="kchung@ctfd.io", - license="Apache 2.0", - description="Tool for creating and running Capture The Flag competitions", - long_description=read("README.md"), - long_description_content_type="text/markdown", - keywords=["ctf"], - classifiers=[], - zip_safe=False, - install_requires=[ - "cookiecutter==2.1.1", - "click==7.0", - "fire==0.2.1", - "pyyaml==5.4", - "Pygments==2.7.4", - "requests==2.28.1", - "colorama==0.4.6", - "appdirs==1.4.3", - "python-frontmatter==1.0.0", - ], - packages=find_packages(), - include_package_data=True, - entry_points={"console_scripts": ["ctf = ctfcli.__main__:main"]}, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py new file mode 100644 index 0000000..e30dba9 --- /dev/null +++ b/tests/cli/test_main.py @@ -0,0 +1,71 @@ +import contextlib +import io +import unittest + +import fire + +from ctfcli import __main__ as ctfcli + + +class TestCLIEntrypoint(unittest.TestCase): + def test_loads_challenge_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["challenge"]) + + # fmt: off + expected_commands = [ + "add", "deploy", "edit", "healthcheck", + "install", "lint", "new", "pull", "push", + "restore", "show", "sync", "templates", + "view", + ] + # fmt: on + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_config_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["config"]) + + expected_commands = ["edit", "path", "show", "view"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_pages_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["pages"]) + + expected_commands = ["pull", "push", "sync"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_plugins_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["plugins"]) + + expected_commands = ["dir", "install", "list", "path", "uninstall"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_templates_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["templates"]) + + expected_commands = ["dir", "install", "list", "path", "uninstall"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/deployment/__init__.py b/tests/core/deployment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/deployment/test_base_deployment_handler.py b/tests/core/deployment/test_base_deployment_handler.py new file mode 100644 index 0000000..b4bd46f --- /dev/null +++ b/tests/core/deployment/test_base_deployment_handler.py @@ -0,0 +1,57 @@ +import unittest +from pathlib import Path + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import ( + DEPLOYMENT_HANDLERS, + CloudDeploymentHandler, + DeploymentHandler, + RegistryDeploymentHandler, + SSHDeploymentHandler, + get_deployment_handler, + register_deployment_handler, +) +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestDeploymentHandlerLoading(unittest.TestCase): + def test_get_deployment_handler(self): + handlers = { + "cloud": CloudDeploymentHandler, + "ssh": SSHDeploymentHandler, + "registry": RegistryDeploymentHandler, + } + + for key, _handler in handlers.items(): + handler_class = get_deployment_handler(key) + self.assertIs(handlers[key], handler_class) + + def test_register_deployment_handler(self): + class MyDeploymentHandler(DeploymentHandler): + def deploy(self, *args, **kwargs) -> DeploymentResult: + return DeploymentResult(False) + + register_deployment_handler("my-handler", MyDeploymentHandler) + self.assertIs(DEPLOYMENT_HANDLERS["my-handler"], MyDeploymentHandler) + + +class TestBaseDeploymentHandler(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + def test_assigns_attributes(self): + challenge = Challenge(self.challenge_path) + handler = DeploymentHandler(challenge, "example.com", "https") + + self.assertIs(challenge, handler.challenge) + self.assertEqual("example.com", handler.host) + self.assertEqual("https", handler.protocol) + + def test_does_not_implement_deploy(self): + challenge = Challenge(self.challenge_path) + handler = DeploymentHandler(challenge, "example.com", "https") + + with self.assertRaises(NotImplementedError): + handler.deploy() diff --git a/tests/core/deployment/test_cloud_deployment.py b/tests/core/deployment/test_cloud_deployment.py new file mode 100644 index 0000000..4e36966 --- /dev/null +++ b/tests/core/deployment/test_cloud_deployment.py @@ -0,0 +1,1210 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import CloudDeploymentHandler +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestCloudDeployment(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + mock_user_response = { + "success": True, + "data": { + "affiliation": None, + "oauth_id": None, + "fields": [], + "name": "admin", + "language": None, + "bracket": None, + "email": "test@example.com", + "id": 1, + "website": None, + "team_id": 1, + "place": None, + "score": 0, + }, + } + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.API") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + def test_fails_deployment_if_challenge_does_not_provide_image(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"image": None}) + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Challenge does not define an image to deploy", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.API") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + def test_fails_deployment_if_instance_does_not_support_deployments( + self, mock_secho: MagicMock, mock_api_constructor: MagicMock, *args, **kwargs + ): + mock_response = MagicMock() + mock_response.ok = False + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value = mock_response + + challenge = Challenge(self.challenge_path) + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_api.get.assert_called_once_with("/api/v1/images") + mock_secho.assert_called_once_with("Target instance does not support cloud deployments", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_image_build_failed( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.built = False + mock_image.build.return_value = None + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + ] + ) + + mock_api.patch.assert_not_called() + + # expect an error message + mock_secho.assert_called_once_with("Could not build the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_image_push_failed( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = None + + mock_api: MagicMock = mock_api_constructor.return_value + + def mock_get(*args, **kwargs): + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + ] + ) + + mock_api.patch.assert_not_called() + + # expect an error message + mock_secho.assert_has_calls( + [ + call( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ), + call("Could not push image to the registry.", fg="red"), + call( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ), + ] + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch( + "ctfcli.core.deployment.cloud.subprocess.check_output", + return_value=b'Error response from daemon: Get "https://registry.ctfd.io/v2/": unauthorized: authentication required', # noqa + ) + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_registry_login_unsuccessful( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get username for registry login + ] + ) + + mock_api.patch.assert_not_called() + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + # expect an error message + mock_secho.assert_called_once_with( + "Could not log in to the registry. Please check your access token and instance URL", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_instance_url_is_not_ctfd_assigned( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://custom-project.example.com/" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get username for registry login + ] + ) + + mock_api.patch.assert_not_called() + + # check docker registry login didn't happen + mock_subprocess.assert_not_called() + + # expect an error message + mock_secho.assert_has_calls( + [ + call( + "Instance URL is not a CTFd assigned URL. Either use the CTFd assigned domain name, " + "or login to the registry manually and deploy with --skip-login", + fg="red", + ), + call("Could not log in to the registry. Please check your access token and instance URL", fg="red"), + ] + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_allows_skipping_registry_login( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "pushed", + "location": "registry.ctfd.io/example-project/test-challenge", + } + ], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [{"id": 1, "name": "test-challenge", "status": "deployed"}], + } + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + } + return mock_response + + def mock_patch(*args, **kwargs): + new_image = kwargs.get("json", {}).get("image", None) + + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": new_image, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + } + ], + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.patch.side_effect = mock_patch + + handler = CloudDeploymentHandler(challenge, protocol="https") + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("https://example-project-test-challenge.chals.io", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + ] + ) + + mock_api.patch.assert_has_calls( + [ + # update service image + call("/api/v1/services/1", json={"image": "registry.ctfd.io/example-project/test-challenge"}) + ] + ) + + # check docker registry login didn't happen + mock_subprocess.assert_not_called() + + # expect a warning for skip_login + mock_secho.assert_called_once_with( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_deploys_challenge_with_existing_image_service( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "pushed", + "location": "registry.ctfd.io/example-project/test-challenge", + } + ], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [{"id": 1, "name": "test-challenge", "status": "deployed"}], + } + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_patch(*args, **kwargs): + new_image = kwargs.get("json", {}).get("image", None) + + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": new_image, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + } + ], + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.patch.side_effect = mock_patch + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("https://example-project-test-challenge.chals.io", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get username for registry login + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + ] + ) + + mock_api.patch.assert_has_calls( + [ + # update service image + call("/api/v1/services/1", json={"image": "registry.ctfd.io/example-project/test-challenge"}) + ] + ) + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + # do not expect a call to secho as in this case the mocked deployment is instant + mock_secho.assert_not_called() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.time.sleep") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_deploys_challenge_with_new_image_service( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_sleep: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + # return a deployed service response on the 3rd status check + service_status_responses = [ + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + }, + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + }, + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "image": "registry.ctfd.io/example-project/test-challenge", + "internal_port": 80, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + }, + ] + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = service_status_responses.pop(0) + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": None, + "location": "registry.ctfd.io/example-project/test-challenge", + }, + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("https://example-project-test-challenge.chals.io", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get existing images + call("/api/v1/users/me"), # get user data for registry login + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status (1st) + call("/api/v1/services/1"), # await service deployment #1 (2nd) + call("/api/v1/services/1"), # await service deployment #2 (3rd) - should be deployed + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/images", json={"name": "test-challenge"}), + call( + "/api/v1/services", + json={"name": "test-challenge", "image": "registry.ctfd.io/example-project/test-challenge"}, + ), + ] + ) + + mock_secho.assert_has_calls( + [ + call("Awaiting service deployment [0/180s]", fg="yellow"), + call("Awaiting service deployment [10/180s]", fg="yellow"), + ] + ) + + mock_sleep.assert_has_calls( + [ + call(10), + call(10), + ] + ) + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.time.sleep") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_after_timeout( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_sleep: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": None, + "location": "registry.ctfd.io/example-project/test-challenge", + }, + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + self.assertIsNone(result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get existing images + call("/api/v1/users/me"), # get user data + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + call("/api/v1/services/1"), # await service deployment #1 + call("/api/v1/services/1"), # await service deployment #2 + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/images", json={"name": "test-challenge"}), + call( + "/api/v1/services", + json={"name": "test-challenge", "image": "registry.ctfd.io/example-project/test-challenge"}, + ), + ] + ) + + mock_secho.assert_has_calls( + [ + call("Awaiting service deployment [0/180s]", fg="yellow"), + call("Awaiting service deployment [10/180s]", fg="yellow"), + call("Awaiting service deployment [20/180s]", fg="yellow"), + call("Awaiting service deployment [30/180s]", fg="yellow"), + call("Awaiting service deployment [40/180s]", fg="yellow"), + call("Awaiting service deployment [50/180s]", fg="yellow"), + call("Awaiting service deployment [60/180s]", fg="yellow"), + call("Awaiting service deployment [70/180s]", fg="yellow"), + call("Awaiting service deployment [80/180s]", fg="yellow"), + call("Awaiting service deployment [90/180s]", fg="yellow"), + call("Awaiting service deployment [100/180s]", fg="yellow"), + call("Awaiting service deployment [110/180s]", fg="yellow"), + call("Awaiting service deployment [120/180s]", fg="yellow"), + call("Awaiting service deployment [130/180s]", fg="yellow"), + call("Awaiting service deployment [140/180s]", fg="yellow"), + call("Awaiting service deployment [150/180s]", fg="yellow"), + call("Awaiting service deployment [160/180s]", fg="yellow"), + call("Awaiting service deployment [170/180s]", fg="yellow"), + call("Timeout awaiting challenge deployment", fg="red"), + ] + ) + + mock_sleep.assert_has_calls( + [ + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + ] + ) + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_exposes_tcp_port( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path, {"protocol": "tcp"}) + + mock_image = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + service_status_responses = [ + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + }, + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": "0.cloud.chals.io", + "tcp_port": "31900", + "memory_limit": "256 MB", + }, + }, + ] + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "pushed", + "location": "registry.ctfd.io/example-project/test-challenge", + } + ], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [{"id": 1, "name": "test-challenge", "status": "deployed"}], + } + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + + mock_response.json.return_value = service_status_responses.pop(0) + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_patch(*args, **kwargs): + new_image = kwargs.get("json").get("image") + + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": new_image, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + } + + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.patch.side_effect = mock_patch + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("nc 0.cloud.chals.io 31900", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get user data + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + ] + ) + + mock_api.patch.assert_has_calls( + [ + # update service image + call("/api/v1/services/1", json={"image": "registry.ctfd.io/example-project/test-challenge"}), + # expose TCP port + call("/api/v1/services/1", json={"expose": True}), + ] + ) + + # do not expect a call to secho as in this case the mocked deployment is instant + mock_secho.assert_not_called() + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = CloudDeploymentHandler(challenge, protocol="http") + + self.assertEqual( + "https://example-project-test-challenge.ctfd.io", + handler._get_connection_info("example-project-test-challenge.ctfd.io"), + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_tcp(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = CloudDeploymentHandler(challenge, protocol="tcp") + + self.assertEqual( + "nc 0.cloud.chals.io 30054", + handler._get_connection_info( + "example-project-test-challenge.ctfd.io", tcp_hostname="0.cloud.chals.io", tcp_port="30054" + ), + ) + + @mock.patch( + "ctfcli.core.config.Path.cwd", return_value=BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + ) + def test_get_connection_info_fallback(self, *args, **kwargs): + # test with a challenge that does not define a protocol + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + + challenge = Challenge(minimal_challenge) + handler = CloudDeploymentHandler(challenge) + + self.assertEqual( + "example-project-test-challenge.ctfd.io", + handler._get_connection_info("example-project-test-challenge.ctfd.io"), + ) diff --git a/tests/core/deployment/test_registry_deployment.py b/tests/core/deployment/test_registry_deployment.py new file mode 100644 index 0000000..134538e --- /dev/null +++ b/tests/core/deployment/test_registry_deployment.py @@ -0,0 +1,291 @@ +import unittest +from pathlib import Path +from subprocess import CalledProcessError +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import RegistryDeploymentHandler +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestRegistryDeployment(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_builds_and_pushes_image( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.example.com/example-project/test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + def test_fails_deployment_if_challenge_does_not_provide_image(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"image": None, "host": "registry.example.com/example-project"}) + handler = RegistryDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Challenge does not define an image to deploy", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + def test_fails_deployment_if_no_host_provided(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = RegistryDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_no_registry_config( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Config does not provide a registry section.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_if_no_credentials( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Config is missing credentials for the registry.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_if_registry_credentials_invalid( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_subprocess: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + mock_subprocess.side_effect = [CalledProcessError(1, "")] + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "Could not log in to the registry. Please check your configured credentials.", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_build_failed( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.built = False + mock_image.build.return_value = None + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Could not build the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_push_failed( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = None + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "Could not push image to the registry. Please check docker output above.", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_allows_skipping_login( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_subprocess: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.example.com/example-project/test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + + mock_secho.assert_called_once_with( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + mock_subprocess.assert_not_called() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.challenge.Image") + def test_warns_about_logging_in_with_skip_login( + self, + mock_image_constructor: MagicMock, + mock_subprocess: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = None + + mock_subprocess.side_effect = [CalledProcessError(1, "")] + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_has_calls( + [ + call( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ), + call("Could not push image to the registry. Please check docker output above.", fg="red"), + call( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ), + ] + ) diff --git a/tests/core/deployment/test_ssh_deployment.py b/tests/core/deployment/test_ssh_deployment.py new file mode 100644 index 0000000..29911c9 --- /dev/null +++ b/tests/core/deployment/test_ssh_deployment.py @@ -0,0 +1,245 @@ +import unittest +from pathlib import Path +from subprocess import CalledProcessError +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import SSHDeploymentHandler +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestSSHDeployment(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch.object(Path, "unlink") + @mock.patch("ctfcli.core.deployment.ssh.subprocess.run") + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_builds_exports_and_copies_image( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + mock_unlink: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = "/tmp/test/test-challenge.tar" + mock_image.get_exposed_port.return_value = 80 + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("http://127.0.0.1", result.connection_info) + + mock_secho.assert_not_called() + mock_subprocess.assert_has_calls( + [ + call(["scp", Path("/tmp/test/test-challenge.tar"), "root@127.0.0.1:/tmp/test-challenge.tar"]), + call(["ssh", "root@127.0.0.1", "docker load -i /tmp/test-challenge.tar && rm /tmp/test-challenge.tar"]), + call( + [ + "ssh", + "root@127.0.0.1", + "docker stop test-challenge 2>/dev/null; docker rm test-challenge 2>/dev/null", + ] + ), + call( + [ + "ssh", + "root@127.0.0.1", + "docker run -d -p80:80 --name test-challenge --restart always test-challenge", + ] + ), + ] + ) + + mock_unlink.assert_called_once() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + def test_fails_deployment_if_challenge_does_not_provide_image(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"image": None, "host": "ssh://root@127.0.0.1"}) + handler = SSHDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Challenge does not define an image to deploy", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + def test_fails_deployment_if_no_host_provided(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = SSHDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_build_failed( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.built = False + mock_image.build.return_value = None + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Could not build the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_export_failed( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = None + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Could not export the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_no_exposed_port( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = "/tmp/test/test-challenge.tar" + mock_image.get_exposed_port.return_value = None + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "Could not resolve a port to expose. Make sure your Dockerfile EXPOSE's a port.", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.subprocess.run") + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_any_subprocess_exits( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.basename = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = "/tmp/test/test-challenge.tar" + mock_image.get_exposed_port.return_value = 80 + + mock_subprocess.side_effect = [CalledProcessError(1, "test-exception")] + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_has_calls( + [ + call("Failed to deploy image!", fg="red"), + call("Command 'test-exception' returned non-zero exit status 1.", fg="red"), + ] + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_http_s(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="http") + connection_info_http = handler._get_connection_info("127.0.0.1", 8080) + self.assertEqual("http://127.0.0.1:8080", connection_info_http) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="https") + connection_info_https = handler._get_connection_info("127.0.0.1", 8443) + self.assertEqual("https://127.0.0.1:8443", connection_info_https) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_does_not_assign_standard_ports(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="http") + connection_info_http = handler._get_connection_info("127.0.0.1", 80) + self.assertEqual("http://127.0.0.1", connection_info_http) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="https") + connection_info_https = handler._get_connection_info("127.0.0.1", 443) + self.assertEqual("https://127.0.0.1", connection_info_https) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_tcp(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="tcp") + connection_info = handler._get_connection_info("127.0.0.1", 9001) + self.assertEqual("nc 127.0.0.1 9001", connection_info) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_fallback(self, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"protocol": None}) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + connection_info = handler._get_connection_info("127.0.0.1", 9001) + self.assertEqual("127.0.0.1", connection_info) diff --git a/tests/core/test_api.py b/tests/core/test_api.py new file mode 100644 index 0000000..13a4766 --- /dev/null +++ b/tests/core/test_api.py @@ -0,0 +1,173 @@ +import unittest +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.api import API + + +class MockConfigSection(dict): + def __init__(self, data): + super().__init__(data) + + # this is a wrong implementation but all that's necessary for this test + def getboolean(self, key, default=None): + if key not in self: + return default + + return self[key] + + +class TestAPI(unittest.TestCase): + def test_api_object_ensures_trailing_slash_on_prefix_url(self): + test_urls = [ + "https://example.com/test/", + "https://example.com/test", + "https://example.com/test////", + ] + + for url in test_urls: + mock_config = {"config": MockConfigSection({"url": url, "access_token": "test"})} + + with mock.patch("ctfcli.core.api.Config", return_value=mock_config): + api = API() + self.assertEqual(api.prefix_url, "https://example.com/test/") + + @mock.patch( + "ctfcli.core.api.Config", + return_value=MockConfigSection( + {"config": MockConfigSection({"url": "https://example.com/test", "access_token": "test"})} + ), + ) + @mock.patch("ctfcli.core.api.Session.request") + def test_api_object_request_strips_preceding_slash_from_url_path(self, mock_request: MagicMock, *args, **kwargs): + api = API() + api.request("GET", "/path") + api.request("GET", "path") + + mock_request.assert_has_calls( + [ + call("GET", "https://example.com/test/path", headers={"Content-Type": "application/json"}), + call("GET", "https://example.com/test/path", headers={"Content-Type": "application/json"}), + ] + ) + + @mock.patch( + "ctfcli.core.api.Config", + return_value={"config": MockConfigSection({"url": "https://example.com/test", "access_token": "test"})}, + ) + @mock.patch("ctfcli.core.api.Session.request") + def test_api_object_request_assigns_prefix_url(self, mock_request: MagicMock, *args, **kwargs): + api = API() + api.request("GET", "path") + mock_request.assert_called_once_with( + "GET", "https://example.com/test/path", headers={"Content-Type": "application/json"} + ) + + def test_api_object_assigns_ssl_verify(self, *args, **kwargs): + with mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + } + ) + }, + ): + api = API() + # expect the default to be true + self.assertTrue(api.verify) + + with mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + "ssl_verify": True, + } + ) + }, + ): + api = API() + self.assertTrue(api.verify) + + with mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + "ssl_verify": False, + } + ) + }, + ): + api = API() + self.assertFalse(api.verify) + + @mock.patch("ctfcli.core.api.Config") + def test_api_expects_value_error(self, mock_config_constructor: MagicMock): + mock_config_constructor.return_value = { + "config": MagicMock( + getboolean=MagicMock(side_effect=ValueError("Invalid boolean value")), + get=MagicMock(return_value="/tmp/certificate"), + ) + } + + api = API() + self.assertEqual("/tmp/certificate", api.verify) + + @mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + } + ) + }, + ) + def test_api_object_assigns_headers(self, *args, **kwargs): + api = API() + + self.assertIn("Authorization", api.headers) + self.assertEqual("Token test", api.headers["Authorization"]) + + @mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + } + ), + "cookies": MockConfigSection({"test-cookie": "test-value"}), + }, + ) + def test_api_object_assigns_cookies(self, *args, **kwargs): + api = API() + self.assertIn("test-cookie", api.cookies) + self.assertEqual(api.cookies["test-cookie"], "test-value") + + @mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/", + "access_token": "test", + } + ), + }, + ) + @mock.patch("ctfcli.core.api.Session.request") + def test_request_does_not_override_form_data_content_type(self, mock_request: MagicMock, *args, **kwargs): + api = API() + api.request("GET", "/test", data="some-file") + mock_request.assert_called_once_with("GET", "https://example.com/test", data="some-file") diff --git a/tests/core/test_challenge.py b/tests/core/test_challenge.py new file mode 100644 index 0000000..1168d49 --- /dev/null +++ b/tests/core/test_challenge.py @@ -0,0 +1,2181 @@ +import re +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import ANY, MagicMock, call, mock_open + +import yaml + +from ctfcli.core.challenge import Challenge +from ctfcli.core.exceptions import ( + InvalidChallengeFile, + LintException, + RemoteChallengeNotFound, +) +from ctfcli.core.image import Image + +BASE_DIR = Path(__file__).parent.parent + + +class TestLocalChallengeLoading(unittest.TestCase): + def test_determines_challenge_directory(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path) + + self.assertIsInstance(challenge.challenge_file_path, Path) + self.assertEqual(challenge.challenge_directory, BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal") + + def test_raises_if_challenge_yml_does_not_exist(self): + invalid_challenge_path = BASE_DIR / "fixtures" / "challenges" / "nonexistent" / "challenge.yml" + + with self.assertRaises( + InvalidChallengeFile, msg=f"Challenge file at {invalid_challenge_path} could not be found" + ): + Challenge(invalid_challenge_path) + + def test_raises_if_challenge_yml_is_invalid(self): + empty_challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-invalid" / "challenge-empty.yml" + with self.assertRaises(InvalidChallengeFile): + Challenge(empty_challenge_path) + + invalid_challenge_path = ( + BASE_DIR / "fixtures" / "challenges" / "test-challenge-invalid" / "challenge-invalid.yml" + ) # noqa + with self.assertRaises(InvalidChallengeFile): + Challenge(invalid_challenge_path) + + def test_accepts_path_as_string_and_pathlike(self): + challenge_pathlike = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + Challenge(challenge_pathlike) + + challenge_path = str(challenge_pathlike) + Challenge(challenge_path) + + def test_load_challenge(self): + challenge_path = str(BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml") + challenge = Challenge(challenge_path) + + self.assertEqual(challenge["name"], "Test Challenge") + + @mock.patch("ctfcli.core.challenge.subprocess.call") + def test_raises_if_image_defined_but_not_resolved(self, mock_call: MagicMock): + mock_call.return_value = 1 + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + + with self.assertRaises(InvalidChallengeFile): + Challenge(challenge_path, {"image": "test-challenge:latest"}) + + def test_recognizes_image_registry_prefix(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path, {"image": "registry://registry.example.com/my-org/test-challenge:latest"}) + + self.assertIsInstance(challenge.image, Image) + self.assertEqual(challenge.image.name, "registry.example.com/my-org/test-challenge:latest") + self.assertEqual(challenge.image.basename, "test-challenge") + self.assertTrue(challenge.image.built) + + def test_creates_image_if_specified(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" / "challenge.yml" + challenge = Challenge(challenge_path) + + self.assertIsInstance(challenge.image, Image) + self.assertEqual(challenge.image.name, "test-challenge") + self.assertFalse(challenge.image.built) + + def test_does_not_create_image_if_not_specified(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path) + + self.assertIsNone(challenge.image) + + def test_recognizes_registry_images(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path, {"image": "ghcr.io/ctfcli/test-challenge:latest"}) + + self.assertIsInstance(challenge.image, Image) + self.assertEqual(challenge.image.name, "ghcr.io/ctfcli/test-challenge:latest") + self.assertEqual(challenge.image.basename, "test-challenge") + self.assertTrue(challenge.image.built) + + def test_recognizes_library_images(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path, {"image": "library/test-challenge:latest"}) + + self.assertIsInstance(challenge.image, Image) + self.assertEqual(challenge.image.name, "docker.io/library/test-challenge:latest") + self.assertEqual(challenge.image.basename, "test-challenge") + self.assertTrue(challenge.image.built) + + @mock.patch("ctfcli.core.challenge.subprocess.call") + def test_recognizes_local_prebuilt_images(self, mock_call: MagicMock): + mock_call.return_value = 0 + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path, {"image": "test-challenge:latest"}) + + self.assertIsInstance(challenge.image, Image) + self.assertEqual(challenge.image.name, "test-challenge:latest") + self.assertEqual(challenge.image.basename, "test-challenge") + self.assertTrue(challenge.image.built) + + +class TestRemoteChallengeLoading(unittest.TestCase): + @mock.patch("ctfcli.core.challenge.API") + def test_load_installed_challenge(self, mock_api: MagicMock): + Challenge.load_installed_challenge(1) + + mock_get = mock_api.return_value.get + mock_get.assert_called_once_with("/api/v1/challenges/1?view=admin") + + @mock.patch("ctfcli.core.challenge.API") + def test_load_installed_challenges(self, mock_api: MagicMock): + Challenge.load_installed_challenges() + + mock_get = mock_api.return_value.get + mock_get.assert_called_once_with("/api/v1/challenges?view=admin") + + +class TestChallengeSolutions(unittest.TestCase): + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + solution_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-solution" / "challenge.yml" + + def test_resolves_solution_from_specified_path(self): + challenge = Challenge(self.minimal_challenge, {"solution": "challenge.yml"}) + solution_path, solution_state = challenge._resolve_solution_path() + self.assertEqual(solution_path, challenge.challenge_directory / "challenge.yml") + self.assertEqual(solution_state, "hidden") + + def test_resolves_solution_object_from_specified_path(self): + challenge = Challenge( + self.minimal_challenge, + { + "solution": { + "path": "challenge.yml", + "state": "solved", + } + }, + ) + solution_path, solution_state = challenge._resolve_solution_path() + self.assertEqual(solution_path, challenge.challenge_directory / "challenge.yml") + self.assertEqual(solution_state, "solved") + + def test_resolves_solution_object_with_state_from_specified_path(self): + challenge = Challenge( + self.minimal_challenge, + { + "solution": { + "path": "challenge.yml", + "state": "visible", + } + }, + ) + solution_path, solution_state = challenge._resolve_solution_path() + self.assertEqual(solution_path, challenge.challenge_directory / "challenge.yml") + self.assertEqual(solution_state, "visible") + + def test_does_not_resolve_solution_if_not_specified(self): + challenge = Challenge(self.minimal_challenge) + self.assertIsNone(challenge._resolve_solution_path()) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_does_not_resolve_solution_if_missing(self, mock_secho: MagicMock): + challenge = Challenge(self.minimal_challenge, {"solution": "writeup/WRITEUP.md"}) + self.assertIsNone(challenge._resolve_solution_path()) + mock_secho.assert_called_once_with( + f"Solution file 'writeup/WRITEUP.md' specified, but not found at " + f"{challenge.challenge_directory / 'writeup/WRITEUP.md'}", + fg="red", + ) + + @mock.patch("ctfcli.core.challenge.API") + def test_creates_solution_from_specified_path(self, mock_api_constructor: MagicMock): + challenge = Challenge(self.minimal_challenge, {"solution": "challenge.yml"}) + challenge.challenge_id = 1 + + def mock_get(*args, **kwargs): + path = args[0] + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + return MagicMock() + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 5}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + challenge._create_solution() + + mock_api.post.assert_has_calls( + [call("/api/v1/solutions", json={"challenge_id": 1, "state": "hidden", "content": ""})] + ) + mock_api.patch.assert_has_calls([call("/api/v1/solutions/5", json={"content": ANY})]) + + @mock.patch("ctfcli.core.challenge.API") + def test_creates_solution_from_object_with_state(self, mock_api_constructor: MagicMock): + challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "state": "visible"}}) + challenge.challenge_id = 1 + + def mock_get(*args, **kwargs): + path = args[0] + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + return MagicMock() + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 5}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + challenge._create_solution() + + mock_api.post.assert_has_calls( + [call("/api/v1/solutions", json={"challenge_id": 1, "state": "visible", "content": ""})] + ) + + @mock.patch("ctfcli.core.challenge.API") + def test_updates_existing_solution_instead_of_creating_duplicate(self, mock_api_constructor: MagicMock): + challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "state": "solved"}}) + challenge.challenge_id = 1 + + def mock_get(*args, **kwargs): + path = args[0] + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [{"id": 9, "challenge_id": 1, "state": "hidden", "content": "old"}], + } + return mock_response + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + + challenge._create_solution() + + mock_api.post.assert_not_called() + mock_api.patch.assert_has_calls( + [ + call("/api/v1/solutions/9", json={"state": "solved", "content": ""}), + call("/api/v1/solutions/9", json={"content": ANY}), + ], + any_order=True, + ) + + @mock.patch("ctfcli.core.challenge.API") + def test_does_not_create_solution_if_not_specified(self, mock_api_constructor: MagicMock): + challenge = Challenge(self.minimal_challenge) + challenge.challenge_id = 1 + + mock_api: MagicMock = mock_api_constructor.return_value + challenge._create_solution() + + mock_api.post.assert_not_called() + mock_api.patch.assert_not_called() + + @mock.patch("ctfcli.core.challenge.API") + def test_solution_uploads_markdown_images_and_inlines_snippets(self, mock_api_constructor: MagicMock): + challenge = Challenge(self.solution_challenge) + challenge.challenge_id = 1 + + def mock_get(*args, **kwargs): + path = args[0] + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + return MagicMock() + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 5}} + return mock_response + + if path == "/api/v1/files": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [{"location": "solution-uploaded/test.png"}], + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + challenge._create_solution() + + mock_api.post.assert_has_calls( + [ + call("/api/v1/solutions", json={"challenge_id": 1, "state": "hidden", "content": ""}), + call("/api/v1/files", files=ANY, data={"type": "solution", "solution_id": 5}), + ] + ) + mock_api.patch.assert_called_once() + patched_content = mock_api.patch.call_args.kwargs["json"]["content"] + + self.assertIn("![diagram](/files/solution-uploaded/test.png)", patched_content) + self.assertIn('print("snippet from python")', patched_content) + self.assertIn('', patched_content) + + +class TestSyncChallenge(unittest.TestCase): + installed_challenges = [ + { + "id": 1, + "type": "standard", + "name": "Test Challenge", + "value": 100, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "files": [ + "/files/e3a267d9cc21ae3051b6d7ea09e5c6cc/old-test.png", + "/files/37b9992954f1e6e64e46af6600fb2c0b/old-test.pdf", + ], + "template": "view.html", + "script": "view.js", + }, + { + "id": 2, + "type": "standard", + "name": "Other Test Challenge", + "value": 150, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + { + "id": 3, + "type": "standard", + "name": "Yet Another Test Challenge", + "value": 200, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + ] + + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + files_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-files" / "challenge.yml" + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_simple_properties(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "state": "visible", + "connection_info": "https://example.com", + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "connection_info": "https://example.com", + "max_attempts": 0, + } + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync() + + # expect GET calls loading existing resources to check if something needs to be deleted + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"next_id": None}), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_attempts(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"attempts": 5}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 5, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_extra_properties(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "type": "application_target", + "extra": {"application_spec": "application-spec", "application_name": "application-name"}, + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "value": 150, + "state": "hidden", + "type": "application_target", + "application_spec": "application-spec", + "application_name": "application-name", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_flags(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "flags": [ + "flag{test-flag}", + { + "type": "static", + "content": "flag{test-static}", + "data": "case_insensitive", + }, + { + "type": "regex", + "content": "flag{test-regex-.*}", + "data": "case_insensitive", + }, + ] + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"challenge_id": 1, "id": 1, "type": "static", "content": "flag{old-flag}", "data": "", "challenge": 1}, + { + "challenge_id": 1, + "id": 2, + "type": "regex", + "content": "flag{.*}", + "data": "case_insensitive", + "challenge": 1, + }, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/flags", json={"content": "flag{test-flag}", "type": "static", "challenge_id": 1}), + call().raise_for_status(), + call( + "/api/v1/flags", + json={ + "content": "flag{test-static}", + "type": "static", + "data": "case_insensitive", + "challenge_id": 1, + }, + ), + call().raise_for_status(), + call( + "/api/v1/flags", + json={ + "content": "flag{test-regex-.*}", + "type": "regex", + "data": "case_insensitive", + "challenge_id": 1, + }, + ), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/flags/1"), + call().raise_for_status(), + call("/api/v1/flags/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_topics(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"topics": ["new-topic-1", "new-topic-2"]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"id": 1, "challenge_id": 1, "topic_id": 1, "value": "topic-1"}, + {"id": 2, "challenge_id": 1, "topic_id": 2, "value": "topic-2"}, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/topics", json={"value": "new-topic-1", "type": "challenge", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/topics", json={"value": "new-topic-2", "type": "challenge", "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/topics?type=challenge&target_id=1"), + call().raise_for_status(), + call("/api/v1/topics?type=challenge&target_id=2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_tags(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"tags": ["new-tag-1", "new-tag-2"]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"id": 1, "challenge_id": 1, "tag_id": 1, "value": "tag-1"}, + {"id": 2, "challenge_id": 1, "tag_id": 2, "value": "tag-2"}, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/tags", json={"value": "new-tag-1", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/tags", json={"value": "new-tag-2", "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/tags/1"), + call().raise_for_status(), + call("/api/v1/tags/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_files(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.files_challenge) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges/1" or path == "/api/v1/challenges/1?view=admin": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": self.installed_challenges[0]} + return mock_response + + if path == "/api/v1/files?type=challenge": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + {"id": 1, "type": "challenge", "location": "e3a267d9cc21ae3051b6d7ea09e5c6cc/old-test.png"}, + {"id": 2, "type": "challenge", "location": "37b9992954f1e6e64e46af6600fb2c0b/old-test.pdf"}, + ], + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/files", files=ANY, data={"challenge_id": 1, "type": "challenge"}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/files/1"), + call().raise_for_status(), + call("/api/v1/files/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_updated_files_do_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"files": ["files/nonexistent.png"]}) + + mock_api: MagicMock = mock_api_constructor.return_value + + with self.assertRaises(InvalidChallengeFile) as e: + challenge.sync() + self.assertEqual(e.exception.message, "File files/nonexistent.png could not be loaded") + + mock_api.get.assert_not_called() + mock_api.patch.assert_not_called() + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_hints(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "hints": [ + "free hint", + { + "content": "paid hint", + "cost": 100, + }, + ] + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"challenge_id": 1, "id": 1, "content": "old free hint", "cost": 0, "challenge": 1}, + {"challenge_id": 1, "id": 2, "content": "old paid hint", "cost": 50, "challenge": 1}, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/hints", json={"content": "free hint", "title": "", "cost": 0, "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/hints", json={"content": "paid hint", "title": "", "cost": 100, "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/hints/1"), + call().raise_for_status(), + call("/api/v1/hints/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_requirements(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"requirements": ["Other Test Challenge", 3]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + ] + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + # challenge 2 retrieved by name, and challenge 3 retrieved by id + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [2, 3], "anonymize": False}}), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"next_id": None}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.click.secho") + @mock.patch("ctfcli.core.challenge.API") + def test_challenge_cannot_require_itself( + self, mock_api_constructor: MagicMock, mock_secho: MagicMock, *args, **kwargs + ): + challenge = Challenge(self.minimal_challenge, {"requirements": ["Test Challenge", 2]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges/1": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": self.installed_challenges[0]} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + + challenge.sync() + + mock_secho.assert_called_once_with( + "Challenge cannot require itself. Skipping invalid requirement.", fg="yellow" + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [2], "anonymize": False}}), + call().raise_for_status(), + ], + any_order=True, + ) + + # test invalid requirement has not been set + self.assertNotIn( + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [1, 2]}}), + mock_api.patch.call_args_list, + ) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + ] + ) + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_defaults_to_standard_challenge_type(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + del challenge["type"] + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_defaults_to_visible_state(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + del challenge["state"] + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "max_attempts": 0, + "connection_info": None, + # initial patch should set the state to hidden for the duration of the update + "state": "hidden", + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"next_id": None}), + call().raise_for_status(), + # this tests the real assigned state + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_does_not_update_dynamic_value(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "value": None, + "type": "dynamic", + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "dynamic", + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=[]) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_challenges_do_not_exist(self, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + + with self.assertRaises(RemoteChallengeNotFound) as e: + challenge.sync() + self.assertEqual(e.exception.message, "Could not load any remote challenges") + + @mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenges", + return_value=[{"id": 1337, "name": "Dummy Challenge"}], + ) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_challenge_does_not_exist(self, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + with self.assertRaises(RemoteChallengeNotFound) as e: + challenge.sync() + self.assertEqual(e.exception.message, "Could not load remote challenge with name 'Test Challenge'") + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_multiple_attributes_at_once(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.files_challenge, + { + "state": "visible", + "attempts": 5, + "connection_info": "https://example.com", + "flags": ["flag{test-flag}"], + # files are defined in the test challenge.yml, but they are provided here too for clarity + "files": ["files/test.png", "files/test.pdf"], + "topics": ["new-topic-1"], + "tags": ["new-tag-1"], + "hints": ["free hint"], + "requirements": [2], + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 5, + "connection_info": "https://example.com", + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1?view=admin"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [2], "anonymize": False}}), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"next_id": None}), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/flags", json={"content": "flag{test-flag}", "type": "static", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/topics", json={"value": "new-topic-1", "type": "challenge", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/tags", json={"challenge_id": 1, "value": "new-tag-1"}), + call().raise_for_status(), + call("/api/v1/files", files=ANY, data={"challenge_id": 1, "type": "challenge"}), + call().raise_for_status(), + call("/api/v1/files", files=ANY, data={"challenge_id": 1, "type": "challenge"}), + call().raise_for_status(), + call("/api/v1/hints", json={"title": "", "content": "free hint", "cost": 0, "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_not_called() + + def test_does_not_update_ignored_attributes(self): + properties = [ + # fmt: off + # simple types + "category", + "description", + "attribution", + "type", + "value", + "attempts", + "connection_info", + "state", + # complex types + "extra", + "flags", + "topics", + "tags", + "files", + "hints", + "requirements", + "solution", + # fmt: on + ] + + remote_installed_challenge = { + "name": "Test Challenge", + "category": "Old Category", + "description": "Old Description", + "attribution": "Old Attribution", + "type": "some-custom-type", + "value": 100, + "state": "visible", + "max_attempts": 0, + "connection_info": None, + } + + # This nightmare is necessary because on python 3.8 for whatever reason "with" with multiple context managers + # doesn't work + for p in properties: + with mock.patch("ctfcli.core.challenge.API") as mock_api_constructor: + with mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenge", return_value=remote_installed_challenge + ) as mock_load_installed_challenge: + with mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenges", + return_value=self.installed_challenges, + ) as mock_load_installed_challenges: + challenge = Challenge( + self.minimal_challenge, + { + "state": "visible", + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + # expect the payload to modify values with new ones from challenge.yml + # except the ignored property + + # expect these to be in the payload, with the values as on the remote (unchanged): + if p == "value": + expected_challenge_payload["value"] = remote_installed_challenge["value"] + challenge["value"] = 200 + + if p in ["category", "description", "attribution", "type"]: + expected_challenge_payload[p] = remote_installed_challenge[p] + challenge[p] = "new-value" + + # expect these are just not modified + # (not included in the payload or not modified with requests): + # in case of attempts and connection_info we have to explicitly delete them from the payload + # as they are expected to be present in their default value with all other requests + if p == "attempts": + challenge["attempts"] = 5 + del expected_challenge_payload["max_attempts"] + + if p == "connection_info": + challenge["connection_info"] = "https://example.com" + del expected_challenge_payload["connection_info"] + + if p == "state": + challenge[p] = "new-value" + + if p == "extra": + challenge["extra"] = {"new-value": "new-value"} + + if p in ["flags", "topics", "tags", "files", "hints", "requirements"]: + challenge[p] = ["new-value"] + + if p == "solution": + challenge[p] = "challenge.yml" + + challenge.sync(ignore=[p]) + + mock_api: MagicMock = mock_api_constructor.return_value + mock_load_installed_challenge.assert_has_calls([call(1)]) + mock_load_installed_challenges.assert_called_once_with() + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"next_id": None}), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + +class TestCreateChallenge(unittest.TestCase): + installed_challenges = [ + { + "id": 1, + "type": "standard", + "name": "Test Challenge", + "value": 150, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + { + "id": 2, + "type": "standard", + "name": "Other Test Challenge", + "value": 200, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + ] + + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + full_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-full" / "challenge.yml" + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_creates_standard_challenge(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.full_challenge) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "Test", + "description": "Test Description", + "attribution": "Test Attribution", + "value": 150, + "max_attempts": 5, + "type": "standard", + "connection_info": "https://example.com", + "extra_property": "extra_property_value", + "state": "hidden", + } + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 3}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.side_effect = mock_post + + challenge.create() + + mock_api.get.assert_not_called() + mock_api.post.assert_has_calls( + [ + call("/api/v1/challenges", json=expected_challenge_payload), + # flags + call("/api/v1/flags", json={"type": "static", "content": "flag{test-flag}", "challenge_id": 3}), + call( + "/api/v1/flags", + json={ + "type": "static", + "content": "flag{test-static}", + "data": "case_insensitive", + "challenge_id": 3, + }, + ), + call( + "/api/v1/flags", + json={ + "type": "regex", + "content": "flag{test-regex-.*}", + "data": "case_insensitive", + "challenge_id": 3, + }, + ), + # topics + call("/api/v1/topics", json={"value": "topic-1", "type": "challenge", "challenge_id": 3}), + call("/api/v1/topics", json={"value": "topic-2", "type": "challenge", "challenge_id": 3}), + # tags + call("/api/v1/tags", json={"challenge_id": 3, "value": "tag-1"}), + call("/api/v1/tags", json={"challenge_id": 3, "value": "tag-2"}), + # files + call("/api/v1/files", files=ANY, data={"challenge_id": 3, "type": "challenge"}), + # hints + call("/api/v1/hints", json={"title": "", "content": "free hint", "cost": 0, "challenge_id": 3}), + call("/api/v1/hints", json={"title": "", "content": "paid hint", "cost": 100, "challenge_id": 3}), + ] + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/3", json={"requirements": {"prerequisites": [1, 2], "anonymize": False}}), + call().raise_for_status(), + call("/api/v1/challenges/3", json={"next_id": None}), + call().raise_for_status(), + call("/api/v1/challenges/3", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_creates_solution_on_create(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"solution": "challenge.yml"}) + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 3}} + return mock_response + + if path == "/api/v1/solutions": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 5}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.side_effect = mock_post + + challenge.create() + + mock_api.post.assert_has_calls( + [ + call("/api/v1/challenges", json=ANY), + call("/api/v1/solutions", json={"challenge_id": 3, "state": "hidden", "content": ""}), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/solutions/5", json={"content": ANY}), + ], + any_order=True, + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_files_do_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"files": ["files/nonexistent.png"]}) + mock_api: MagicMock = mock_api_constructor.return_value + + with self.assertRaises(InvalidChallengeFile) as e: + challenge.create() + self.assertEqual(e.exception.message, "File files/nonexistent.png could not be loaded") + + mock_api.get.assert_not_called() + mock_api.patch.assert_not_called() + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + def test_does_not_set_ignored_attributes(self): + # fmt:off + properties = [ + "value", "category", "description", "attribution", "attempts", "connection_info", "state", # simple types + "extra", "flags", "topics", "tags", "files", "hints", "requirements", "solution" # complex types + ] + # fmt:on + + for p in properties: + with mock.patch("ctfcli.core.challenge.API") as mock_api_constructor: + with mock.patch("ctfcli.core.challenge.click.secho") as mock_secho: + with mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenges", + return_value=self.installed_challenges, + ): + challenge = Challenge(self.minimal_challenge, {"state": "visible"}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + # add a property that should be defined but ignored + + # expect a warning, and to disobey the ignore directive + if p == "value": + challenge["value"] = 200 + expected_challenge_payload["value"] = 200 + + # expect a warning, and to disobey the ignore directive + if p == "type": + challenge["type"] = "custom-type" + expected_challenge_payload[p] = "custom-type" + + # expect these to be in the payload, with the defaults or empty: + if p in ["category", "description", "attribution"]: + challenge[p] = "new-value" + expected_challenge_payload[p] = "" + + # expect these are just not modified + # (not included in the payload or not modified with requests): + if p == "attempts": + challenge["attempts"] = 5 + del expected_challenge_payload["max_attempts"] + + if p == "connection_info": + challenge["connection_info"] = "https://example.com" + del expected_challenge_payload["connection_info"] + + if p == "state": + challenge[p] = "new-value" + + if p == "extra": + challenge["extra"] = {"new-value": "new-value"} + + if p in ["flags", "topics", "tags", "files", "hints", "requirements"]: + challenge[p] = ["new-value"] + + if p == "solution": + challenge[p] = "challenge.yml" + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 3}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.side_effect = mock_post + + challenge.create(ignore=[p]) + + if p == "value" or p == "type": + mock_secho.assert_called_once_with( + f"Attribute '{p}' cannot be ignored when creating a challenge", fg="yellow" + ) + + # if the state is ignored, expect to default to visible and un-hide the challenge + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/3", json={"state": "visible"}), call().raise_for_status()] + ) + mock_api.post.assert_called_once_with("/api/v1/challenges", json=expected_challenge_payload) + mock_api.get.assert_not_called() + mock_api.delete.assert_not_called() + + +class TestLintChallenge(unittest.TestCase): + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + files_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-files" / "challenge.yml" + dockerfile_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" / "challenge.yml" + invalid_dockerfile_challenge = ( + BASE_DIR / "fixtures" / "challenges" / "test-challenge-invalid-dockerfile" / "challenge.yml" + ) + + def test_validates_required_fields(self): + required_fields = ["name", "author", "category", "description", "value"] + for field in required_fields: + challenge = Challenge(self.minimal_challenge) + del challenge[field] + + with self.assertRaises(LintException) as e: + challenge.lint() + + expected_lint_issues = { + "fields": [f"challenge.yml is missing required field: {field}"], + "dockerfile": [], + "hadolint": [], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + def test_validates_challenge_yml_does_not_point_to_dockerfile(self): + challenge = Challenge(self.dockerfile_challenge) + del challenge["image"] + + with self.assertRaises(LintException) as e: + challenge.lint() + + expected_lint_issues = { + "fields": [], + "dockerfile": ["Dockerfile exists but image field does not point to it"], + "hadolint": [], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_validates_dockerfile_exposes_port(self, mock_secho: MagicMock): + challenge = Challenge(self.invalid_dockerfile_challenge) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_lint_issues = { + "fields": [], + "dockerfile": ["Dockerfile is missing EXPOSE"], + "hadolint": [], + "files": [], + } + + mock_secho.assert_called_once_with("Skipping Hadolint", fg="yellow") + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.subprocess.run") + def test_runs_hadolint(self, mock_run: MagicMock): + class RunResult: + def __init__(self, return_code): + self.returncode = return_code + self.stdout = b"-:1 DL3006 warning: Always tag the version of an image explicitly" + + mock_run.return_value = RunResult(1) + challenge = Challenge(self.dockerfile_challenge) + + with self.assertRaises(LintException) as e: + challenge.lint() + + mock_run.assert_called_once_with( + ["docker", "run", "--rm", "-i", "hadolint/hadolint"], capture_output=True, input=ANY + ) + + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": ["-:1 DL3006 warning: Always tag the version of an image explicitly"], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.subprocess.run") + @mock.patch("ctfcli.core.challenge.click.secho") + def test_allows_for_skipping_hadolint(self, mock_secho: MagicMock, mock_run: MagicMock, *args, **kwargs): + challenge = Challenge(self.dockerfile_challenge) + result = challenge.lint(skip_hadolint=True) + + mock_secho.assert_called_once_with("Skipping Hadolint", fg="yellow") + mock_run.assert_not_called() + self.assertTrue(result) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_validates_files_exist(self, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"files": ["files/nonexistent.pdf"]}) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_file_path = (challenge.challenge_directory / "files" / "nonexistent.pdf").absolute() + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": [], + "files": [f"Challenge file 'files/nonexistent.pdf' specified, but not found at {expected_file_path}"], + } + + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_looks_for_flags_in_dist_files(self, *args, **kwargs): + challenge = Challenge(self.files_challenge, {"files": ["files/flag.txt"]}) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": [], + "files": ["Potential flag found in distributed file 'files/flag.txt':\n Whoopsie: flag{test-flag}"], + } + + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + def test_validates_solution_file_exists(self): + challenge = Challenge(self.minimal_challenge, {"solution": "writeup/WRITEUP.md"}) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_solution_path = (challenge.challenge_directory / "writeup" / "WRITEUP.md").absolute() + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": [], + "files": [f"Solution file 'writeup/WRITEUP.md' specified, but not found at {expected_solution_path}"], + } + + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + def test_validates_solution_state(self): + challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "state": "public"}}) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_lint_issues = { + "fields": ["The solution state must be one of: hidden, visible, solved"], + "dockerfile": [], + "hadolint": [], + "files": [], + } + + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + +class TestVerifyMirrorChallenge(unittest.TestCase): + installed_challenges = [ + { + "id": 1, + "type": "standard", + "name": "First Test Challenge", + "value": 150, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + { + "id": 2, + "type": "standard", + "name": "Other Test Challenge", + "value": 200, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + { + "id": 3, + "type": "standard", + "name": "Test Challenge", + "value": 150, + "solves": 0, + "solved_by_me": False, + "category": "Test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + ] + + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + full_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-full" / "challenge.yml" + + def mock_get(self, *args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges?view=admin" or path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": self.installed_challenges} + return mock_response + + if path == "/api/v1/challenges/3" or path == "/api/v1/challenges/3?view=admin": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": { + "id": 3, + "name": "Test Challenge", + "value": 150, + "description": "Test Description", + "attribution": "Test Attribution", + "connection_info": "https://example.com", + "next_id": None, + "category": "Test", + "state": "visible", + "max_attempts": 5, + "type": "standard", + "files": [ + "/files/6cccd16e23d7a7dd13f2ec4368be682b/test.png?token=jwt", + "/files/543543fd1697214513f241241212efaa/test.pdf?token=jwt", + ], + "tags": ["tag-1", "tag-2"], + "hints": [{"id": 1, "cost": 0}, {"id": 2, "cost": 100}], + "type_data": { + "id": "standard", + "name": "standard", + "templates": { + "create": "/plugins/challenges/assets/create.html", + "update": "/plugins/challenges/assets/update.html", + "view": "/plugins/challenges/assets/view.html", + }, + "scripts": { + "create": "/plugins/challenges/assets/create.js", + "update": "/plugins/challenges/assets/update.js", + "view": "/plugins/challenges/assets/view.js", + }, + }, + "solves": 0, + "solved_by_me": False, + "attempts": 0, + "view": "html view", + }, + } + return mock_response + + if path == "/api/v1/challenges/3/flags": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + { + "type": "static", + "challenge": 1, + "id": 1, + "content": "flag{test-flag}", + "data": None, + "challenge_id": 1, + }, + { + "type": "static", + "challenge": 1, + "id": 2, + "content": "flag{test-static}", + "data": "case_insensitive", + "challenge_id": 1, + }, + { + "type": "regex", + "challenge": 1, + "id": 3, + "content": "flag{test-regex-.*}", + "data": "case_insensitive", + "challenge_id": 1, + }, + ], + } + + return mock_response + + if path == "/api/v1/challenges/3/tags": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "challenge_id": 1, + "value": "tag-1", + }, + { + "id": 2, + "challenge_id": 1, + "value": "tag-2", + }, + ], + } + + return mock_response + + if path == "/api/v1/challenges/3/topics": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "challenge_id": 1, + "value": "topic-1", + "topic_id": 1, + }, + { + "id": 2, + "challenge_id": 1, + "value": "topic-2", + "topic_id": 2, + }, + ], + } + + return mock_response + + if path == "/api/v1/challenges/3/hints": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + { + "type": "standard", + "challenge": 1, + "id": 1, + "content": "free hint", + "cost": 0, + "challenge_id": 1, + "requirements": {"prerequisites": []}, + }, + { + "type": "standard", + "challenge": 1, + "id": 2, + "content": "paid hint", + "cost": 100, + "challenge_id": 1, + "requirements": {"prerequisites": []}, + }, + ], + } + + return mock_response + + if path == "/api/v1/challenges/3/requirements": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"prerequisites": [1, 2]}} + + return mock_response + + return MagicMock() + + maxDiff = 1000 + + @mock.patch("ctfcli.core.challenge.API") + def test_normalize_fetches_and_normalizes_challenge(self, mock_api_constructor: MagicMock): + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = self.mock_get + + # does not matter in this test + challenge = Challenge(self.full_challenge) + challenge.challenge_id = 3 + + mock_challenge_data = { + "name": "Test Challenge", + "category": "Test", + "description": "Test Description", + "attribution": "Test Attribution", + "value": 150, + "max_attempts": 5, + "type": "standard", + "connection_info": "https://example.com", + "state": "hidden", + "initial": 100, + "decay": 10, + "minimum": 10, + # not including flags, tags, topics, hints, requirements as they are fetched separately (see mock_get) + # so, there's no need to place them in the mock data + } + + normalized_data = challenge._normalize_challenge(mock_challenge_data) + self.assertDictEqual( + { + "name": "Test Challenge", + "category": "Test", + "value": 150, + "type": "standard", + "state": "hidden", + "connection_info": "https://example.com", + "description": "Test Description", + "attribution": "Test Attribution", + "attempts": 5, + "flags": [ + "flag{test-flag}", + {"content": "flag{test-static}", "type": "static", "data": "case_insensitive"}, + {"content": "flag{test-regex-.*}", "type": "regex", "data": "case_insensitive"}, + ], + "tags": ["tag-1", "tag-2"], + "hints": ["free hint", {"content": "paid hint", "cost": 100}], + "topics": ["topic-1", "topic-2"], + "next": None, + "requirements": {"prerequisites": ["First Test Challenge", "Other Test Challenge"], "anonymize": False}, + "extra": { + "initial": 100, + "decay": 10, + "minimum": 10, + }, + }, + normalized_data, + ) + + @mock.patch("ctfcli.core.challenge.API") + def test_verify_checks_if_challenge_is_the_same(self, mock_api_constructor: MagicMock): + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = self.mock_get + + challenge = Challenge(self.full_challenge) + + # pop keys with default values to see if they are ignored + for p in ["type", "state"]: + challenge.pop(p) + + challenge.challenge_id = 3 + self.assertTrue(challenge.verify(ignore=["files"])) + + @mock.patch("ctfcli.core.challenge.API") + def test_verify_checks_if_challenge_differs(self, mock_api_constructor: MagicMock): + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = self.mock_get + + challenge = Challenge(self.full_challenge, {"value": 200}) + challenge.challenge_id = 3 + self.assertFalse(challenge.verify(ignore=["files"])) + + @mock.patch("ctfcli.core.challenge.API") + def test_mirror_challenge(self, mock_api_constructor: MagicMock): + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = self.mock_get + + challenge = Challenge( + self.full_challenge, + { + "value": 200, + "description": "other description", + "attribution": "other attribution", + "connection_info": "https://other.example.com", + "flags": ["flag{other-flag}", "other-flag"], + "topics": ["other-topic-1", "other-topic-2"], + "tags": ["other-tag-1", "other-tag-2"], + "hints": ["other-free hint", {"content": "other-paid hint", "cost": 100}], + "requirements": ["Other Test Challenge"], + }, + ) + challenge.challenge_id = 3 + + with mock.patch("builtins.open", new_callable=mock_open()) as mock_open_file: + challenge.mirror(ignore=["files"]) + dumped_data = mock_open_file.return_value.__enter__().write.call_args_list[0].args[0] + + # adjust requirements for the test only, because they can be referenced as an ID and name, + # and ctfcli will update them to use the name + expected_challenge = Challenge( + self.full_challenge, + {"requirements": {"prerequisites": ["First Test Challenge", "Other Test Challenge"], "anonymize": False}}, + ) + + # pop keys with default values as they should not be in the loaded data + for k in ["state", "type"]: + expected_challenge.pop(k) + + loaded_data = yaml.safe_load(dumped_data) + self.assertDictEqual(expected_challenge, loaded_data) + + +class TestSaveChallenge(unittest.TestCase): + full_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-full" / "challenge.yml" + + def test_saved_content_is_valid(self): + challenge = Challenge(self.full_challenge) + + with mock.patch("builtins.open", new_callable=mock_open()) as mock_open_file: + challenge.save() + dumped_data = mock_open_file.return_value.__enter__().write.call_args_list[0].args[0] + + loaded_data = yaml.safe_load(dumped_data) + + # pop keys with default values as they should not be in the loaded data + for k in ["state", "type"]: + challenge.pop(k) + + self.assertDictEqual(challenge, loaded_data) + + def test_key_order_is_preserved(self): + challenge = Challenge(self.full_challenge) + + with mock.patch("builtins.open", new_callable=mock_open()) as mock_open_file: + challenge.save() + dumped_data = mock_open_file.return_value.__enter__().write.call_args_list[0].args[0] + + def check_order(yml: str, order: list[str]): + indices = {} + for key in order: + match = re.search(r"\b" + re.escape(key) + r"\b", yml) + + if match: + indices[key] = match.start() + else: + continue + + sorted_indices = sorted(indices.values()) + if sorted_indices == list(indices.values()): + return True + + return False + + key_order = challenge.key_order.copy() + for k in ["state", "type"]: + key_order.remove(k) + + self.assertTrue(check_order(dumped_data, key_order)) + + def test_additional_keys_are_appended(self): + challenge = Challenge(self.full_challenge, {"new-property": "some-value"}) + + with mock.patch("builtins.open", new_callable=mock_open()) as mock_open_file: + challenge.save() + dumped_data = mock_open_file.return_value.__enter__().write.call_args_list[0].args[0] + + # pop keys with default values as they should not be in the loaded data + for k in ["state", "type"]: + challenge.pop(k) + + loaded_data = yaml.safe_load(dumped_data) + self.assertDictEqual(challenge, loaded_data) diff --git a/tests/core/test_config.py b/tests/core/test_config.py new file mode 100644 index 0000000..1082d08 --- /dev/null +++ b/tests/core/test_config.py @@ -0,0 +1,193 @@ +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +from ctfcli.core.config import Config +from ctfcli.core.exceptions import ProjectNotInitialized + +BASE_DIR = Path(__file__).parent.parent + + +class TestConfig(unittest.TestCase): + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=Path(tempfile.mkdtemp())) + def test_raises_if_config_is_not_found(self, *args, **kwargs): + with self.assertRaises(ProjectNotInitialized): + Config() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_determines_project_path(self, *args, **kwargs): + config = Config() + expected_project_path = BASE_DIR / "fixtures" / "challenges" + self.assertEqual(expected_project_path, config.project_path) + self.assertEqual(expected_project_path, config.get_project_path()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_determines_base_path(self, *args, **kwargs): + config = Config() + expected_base_path = BASE_DIR.parent / "ctfcli" + self.assertEqual(expected_base_path, config.base_path) + self.assertEqual(expected_base_path, config.get_base_path()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_determines_config_path(self, *args, **kwargs): + config = Config() + expected_config_path = BASE_DIR / "fixtures" / "challenges" / ".ctf" / "config" + self.assertEqual(expected_config_path, config.config_path) + self.assertEqual(expected_config_path, config.get_config_path()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.config.configparser.ConfigParser") + def test_reads_config(self, mock_configparser: MagicMock, *args, **kwargs): + Config() + expected_config_path = BASE_DIR / "fixtures" / "challenges" / ".ctf" / "config" + mock_configparser.return_value.read.assert_called_once_with(expected_config_path) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.config.configparser.ConfigParser") + def test_writes_config(self, mock_configparser: MagicMock, *args, **kwargs): + config = Config() + + with tempfile.NamedTemporaryFile(delete=True) as tmp_config: + config.write(tmp_config) + mock_configparser.return_value.write.assert_called_once_with(tmp_config) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_returns_correct_json_representation(self, *args, **kwargs): + config = Config() + + expected_config = { + "config": {"access_token": "deadbeef", "url": "https://example.com/"}, + "challenges": { + "test-challenge-dockerfile": "user@host:example/test-challenge-dockerfile.git", + "test-challenge-files": "user@host:example/test-challenge-files.git", + "test-challenge-full": "user@host:example/test-challenge-full.git", + "test-challenge-minimal": "user@host:example/test-challenge-minimal.git", + }, + } + + config_data = json.loads(config.as_json()) + self.assertEqual(expected_config, config_data) + + config_data_pretty = json.loads(config.as_json(pretty=True)) + self.assertEqual(expected_config, config_data_pretty) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_overloads_getitem(self, *args, **kwargs): + config = Config() + + # test that config can be directly accessed like a dictionary + self.assertEqual(config["config"]["url"], "https://example.com/") + self.assertEqual( + config["challenges"]["test-challenge-minimal"], + "user@host:example/test-challenge-minimal.git", + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_overloads_contains(self, *args, **kwargs): + config = Config() + # test that config can be queried with 'in' + self.assertTrue("challenges" in config) + + def test_get_data_path_returns_path(self): + data_path = Config.get_data_path() + self.assertIsInstance(data_path, Path) + + def test_get_templates_path_returns_path(self): + templates_path = Config.get_templates_path() + self.assertIsInstance(templates_path, Path) + + @mock.patch("ctfcli.core.config.appdirs.user_data_dir") + def test_get_data_path_calls_appdirs(self, mock_user_data_path: MagicMock): + Config.get_data_path() + mock_user_data_path.assert_called_once_with(appname="ctfcli") + + @mock.patch( + "ctfcli.core.config.Config.get_data_path", + return_value=Path("/tmp/test/ctfcli-data"), + ) + @mock.patch.object(Path, "mkdir") + @mock.patch.object(Path, "exists") + def test_get_templates_path(self, mock_exists: MagicMock, mock_mkdir: MagicMock, *args, **kwargs): + expected_templates_path = Path("/tmp/test/ctfcli-data/templates") + mock_exists.return_value = False + + templates_path = Config.get_templates_path() + + self.assertEqual(templates_path, expected_templates_path) + mock_mkdir.assert_called_once() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_get_pages_path_returns_path(self, *args, **kwargs): + pages_path = Config.get_pages_path() + self.assertIsInstance(pages_path, Path) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_get_pages_path(self, *args, **kwargs): + expected_pages_path = BASE_DIR / "fixtures" / "challenges" / "pages" + pages_path = Config.get_pages_path() + self.assertEqual(expected_pages_path, pages_path) + + @mock.patch( + "ctfcli.core.config.Config.get_project_path", + return_value=Path("/tmp/test/ctfcli-project"), + ) + @mock.patch.object(Path, "mkdir") + @mock.patch.object(Path, "exists") + def test_get_pages_path_creates_directory(self, mock_exists: MagicMock, mock_mkdir: MagicMock, *args, **kwargs): + expected_pages_path = Path("/tmp/test/ctfcli-project/pages") + mock_exists.return_value = False + + pages_path = Config.get_pages_path() + + self.assertEqual(pages_path, expected_pages_path) + mock_mkdir.assert_called_once() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch( + "ctfcli.core.config.Config.get_data_path", + return_value=Path("/tmp/test/ctfcli-data"), + ) + def test_get_plugins_path(self, *args, **kwargs): + expected_plugins_path = Path("/tmp/test/ctfcli-data") / "plugins" + pages_path = Config.get_plugins_path() + self.assertEqual(expected_plugins_path, pages_path) + + @mock.patch( + "ctfcli.core.config.Config.get_data_path", + return_value=Path("/tmp/test/ctfcli-data"), + ) + @mock.patch.object(Path, "mkdir") + @mock.patch.object(Path, "exists") + def test_get_plugins_path_creates_directory(self, mock_exists: MagicMock, mock_mkdir: MagicMock, *args, **kwargs): + expected_plugins_path = Path("/tmp/test/ctfcli-data/plugins") + mock_exists.return_value = False + + plugins_path = Config.get_plugins_path() + + self.assertEqual(expected_plugins_path, plugins_path) + mock_mkdir.assert_called_once() + + @mock.patch.dict(os.environ, {"CTFCLI_PLUGIN_PATH": "/tmp/test/custom-plugins-directory"}) + @mock.patch.object(Path, "exists") + def test_get_plugins_path_uses_overriden_directory(self, mock_exists: MagicMock, *args, **kwargs): + expected_plugins_path = Path("/tmp/test/custom-plugins-directory") + mock_exists.return_value = True + + plugins_path = Config.get_plugins_path() + self.assertEqual(expected_plugins_path, plugins_path) + + @mock.patch.dict(os.environ, {"CTFCLI_PLUGIN_PATH": "custom-plugins-directory"}) + @mock.patch.object(Path, "exists") + def test_get_plugins_path_accepts_relative_directory_override(self, mock_exists: MagicMock, *args, **kwargs): + expected_plugins_path = BASE_DIR.parent / "ctfcli" / "custom-plugins-directory" + mock_exists.return_value = True + + plugins_path = Config.get_plugins_path() + + self.assertEqual(expected_plugins_path, plugins_path) diff --git a/tests/core/test_exceptions.py b/tests/core/test_exceptions.py new file mode 100644 index 0000000..5c96f49 --- /dev/null +++ b/tests/core/test_exceptions.py @@ -0,0 +1,55 @@ +import unittest +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.exceptions import LintException + + +class TestLintException(unittest.TestCase): + @mock.patch("ctfcli.core.exceptions.click.echo") + @mock.patch("ctfcli.core.exceptions.click.secho") + def test_print_summary(self, mock_secho: MagicMock, mock_echo: MagicMock): + test_issues = { + "fields": [ + "challenge.yml is missing required field: author", + "challenge.yml is missing required field: name", + "challenge.yml is missing required field: value", + ], + "dockerfile": ["Dockerfile exists but image field does not point to it", "Dockerfile is missing EXPOSE"], + "hadolint": ["-:1 DL3006 warning: Always tag the version of an image explicitly"], + "files": [ + "Challenge file 'files/test-file.png' specified, but not found at /challenge/files/test-file.png" + ], + } + + with self.assertRaises(LintException) as e: + raise LintException(issues=test_issues) + + self.assertDictEqual(test_issues, e.exception.issues) + e.exception.print_summary() + + mock_secho.assert_has_calls( + [ + call("Fields:", fg="yellow"), + call("Dockerfile:", fg="yellow"), + call("Hadolint:", fg="yellow"), + call("Files:", fg="yellow"), + ] + ) + + mock_echo.assert_has_calls( + [ + call(" - challenge.yml is missing required field: author"), + call(" - challenge.yml is missing required field: name"), + call(" - challenge.yml is missing required field: value"), + call(), + call(" - Dockerfile exists but image field does not point to it"), + call(" - Dockerfile is missing EXPOSE"), + call(), + call("-:1 DL3006 warning: Always tag the version of an image explicitly"), + call( + " - Challenge file 'files/test-file.png' specified, but not found at /challenge/files/test-file.png" + ), + call(), + ] + ) diff --git a/tests/core/test_image.py b/tests/core/test_image.py new file mode 100644 index 0000000..8e6ab37 --- /dev/null +++ b/tests/core/test_image.py @@ -0,0 +1,334 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.image import Image + +BASE_DIR = Path(__file__).parent.parent + + +class TestImage(unittest.TestCase): + def test_assigns_attributes(self): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + + self.assertEqual(image.name, "test-challenge") + self.assertEqual(image.build_path, build_path) + self.assertFalse(image.built) + + def test_extracts_correct_basename(self): + image_names = [ + "test-challenge", + "test-challenge:latest", + "test-challenge:1.0.0", + "registry.ctfd.io/example-project/test-challenge", + "registry.ctfd.io/example-project/test-challenge:latest", + "registry.ctfd.io/example-project/test-challenge:1.0.0", + "ghcr.io/example-org/test-challenge", + "ghcr.io/example-org/test-challenge:latest", + "ghcr.io/example-org/test-challenge:1.0.0", + "library/test-challenge", + "library/test-challenge:latest", + "library/test-challenge:1.0.0", + ] + + for name in image_names: + image = Image(name) + self.assertEqual(image.basename, "test-challenge") + + def test_accepts_path_as_string_and_pathlike(self): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertEqual(image.build_path, build_path) + + build_path_string = str(build_path) + image = Image("test-challenge", build_path_string) + self.assertEqual(image.build_path, build_path) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_build(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + + self.assertTrue(image.built) + self.assertEqual(image_name, "test-challenge") + mock_call.assert_called_once_with( + ["docker", "build", "--load", "-t", "test-challenge", "."], cwd=build_path.absolute() + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=1) + def test_build_returns_none_if_failed(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + + self.assertFalse(image.built) + self.assertIsNone(image_name) + mock_call.assert_called_once_with( + ["docker", "build", "--load", "-t", "test-challenge", "."], cwd=build_path.absolute() + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_push_built_image(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + image_location = image.push("registry.ctfd.io/example-project/test-challenge") + + self.assertEqual("test-challenge", image_name) + self.assertEqual("registry.ctfd.io/example-project/test-challenge", image_location) + self.assertTrue(image.built) + + mock_call.assert_has_calls( + [ + call( + [ + "docker", + "tag", + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + call( + [ + "docker", + "push", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + ] + ) + + # mock successful build but failed push + @mock.patch("ctfcli.core.image.subprocess.call", side_effect=[0, 1, 1]) + def test_push_returns_none_if_failed(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + image_location = image.push("registry.ctfd.io/example-project/test-challenge") + + self.assertEqual("test-challenge", image_name) + self.assertIsNone(image_location) + self.assertTrue(image.built) + + mock_call.assert_has_calls( + [ + call( + [ + "docker", + "tag", + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + call( + [ + "docker", + "push", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_builds_image_before_push(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_location = image.push("registry.ctfd.io/example-project/test-challenge") + self.assertEqual("registry.ctfd.io/example-project/test-challenge", image_location) + self.assertTrue(image.built) + + mock_call.assert_has_calls( + [ + call( + ["docker", "build", "--load", "-t", "test-challenge", "."], + cwd=build_path.absolute(), + ), + call( + [ + "docker", + "tag", + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + call( + [ + "docker", + "push", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_export_built_image(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image.build() + + mock_named_temporary_file = MagicMock() + mock_named_temporary_file.name = "/tmp/test-challenge.docker.tar" + + with mock.patch( + "ctfcli.core.image.tempfile.NamedTemporaryFile", + return_value=mock_named_temporary_file, + ): + export_path = image.export() + + self.assertTrue(image.built) + self.assertEqual(export_path, "/tmp/test-challenge.docker.tar") + mock_call.assert_has_calls( + [ + call( + [ + "docker", + "save", + "--output", + "/tmp/test-challenge.docker.tar", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_builds_image_before_export(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + mock_named_temporary_file = MagicMock() + mock_named_temporary_file.name = "/tmp/test-challenge.docker.tar" + + with mock.patch( + "ctfcli.core.image.tempfile.NamedTemporaryFile", + return_value=mock_named_temporary_file, + ): + export_path = image.export() + + self.assertTrue(image.built) + self.assertEqual(export_path, "/tmp/test-challenge.docker.tar") + mock_call.assert_has_calls( + [ + call( + ["docker", "build", "--load", "-t", "test-challenge", "."], + cwd=build_path.absolute(), + ), + call( + [ + "docker", + "save", + "--output", + "/tmp/test-challenge.docker.tar", + "test-challenge", + ] + ), + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + @mock.patch("ctfcli.core.image.subprocess.check_output", return_value='{"80/tcp":{}}') + def test_get_exposed_port(self, mock_check_output: MagicMock, *args, **kwargs): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + exposed_port = image.get_exposed_port() + self.assertEqual("80", exposed_port) + mock_check_output.assert_has_calls( + [ + call( + [ + "docker", + "inspect", + "--format={{json .Config.ExposedPorts}}", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + @mock.patch( + "ctfcli.core.image.subprocess.check_output", + return_value='{"80/tcp":{},"8000/tcp":{}}', + ) + def test_get_exposed_port_returns_first_port_if_multiple_exposed( + self, mock_check_output: MagicMock, *args, **kwargs + ): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + exposed_port = image.get_exposed_port() + self.assertEqual("80", exposed_port) + mock_check_output.assert_has_calls( + [ + call( + [ + "docker", + "inspect", + "--format={{json .Config.ExposedPorts}}", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + @mock.patch("ctfcli.core.image.subprocess.check_output", return_value="null") + def test_get_exposed_port_returns_none_if_no_ports_exposed(self, mock_check_output: MagicMock, *args, **kwargs): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + exposed_port = image.get_exposed_port() + self.assertIsNone(exposed_port) + mock_check_output.assert_has_calls( + [ + call( + [ + "docker", + "inspect", + "--format={{json .Config.ExposedPorts}}", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + def test_pull(self, mock_call: MagicMock): + image_names = [ + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + "ghcr.io/example-org/test-challenge", + "library/test-challenge", + "test-challenge:latest", + "registry.ctfd.io/example-project/test-challenge:latest", + "ghcr.io/example-org/test-challenge:latest", + "library/test-challenge:latest", + ] + + for image_name in image_names: + image = Image(image_name) + image.pull() + self.assertEqual(image_name, image.name) + mock_call.assert_called_with(["docker", "pull", image_name]) diff --git a/tests/core/test_page.py b/tests/core/test_page.py new file mode 100644 index 0000000..e522f69 --- /dev/null +++ b/tests/core/test_page.py @@ -0,0 +1,767 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +import frontmatter + +from ctfcli.core.exceptions import ( + IllegalPageOperation, + InvalidPageConfiguration, + InvalidPageFormat, +) +from ctfcli.core.page import Page + +BASE_DIR = Path(__file__).parent.parent + + +class TestPage(unittest.TestCase): + # use a test challenge path as cwd to avoid mocking config + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + def tearDown(self) -> None: + # reset class cache after each test + Page._remote_pages = None + Page._remote_page_ids = None + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_local_markdown_page(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "markdown-page.md" + page = Page(page_path=page_path) + + self.assertEqual("markdown-page", page.route) + self.assertEqual("Markdown Page", page.title) + self.assertEqual("# Hello World!", page.content) + self.assertEqual("markdown", page.format) + self.assertTrue(page.is_draft) + self.assertTrue(page.is_hidden) + self.assertTrue(page.is_auth_required) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_local_html_page(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + self.assertEqual("html-page", page.route) + self.assertEqual("HTML Page", page.title) + self.assertEqual("

Hello World!

", page.content) + self.assertEqual("html", page.format) + self.assertFalse(page.is_draft) + self.assertFalse(page.is_hidden) + self.assertFalse(page.is_auth_required) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_remote_markdown_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "content": "# Hello World!", + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + } + + page = Page(page_id=1) + + self.assertEqual("markdown-page", page.route) + self.assertEqual("Markdown Page", page.title) + self.assertEqual("# Hello World!", page.content) + self.assertEqual("markdown", page.format) + self.assertTrue(page.is_draft) + self.assertTrue(page.is_hidden) + self.assertTrue(page.is_auth_required) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_remote_html_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + } + + page = Page(page_id=1) + + self.assertEqual("html-page", page.route) + self.assertEqual("HTML Page", page.title) + self.assertEqual("

Hello World!

", page.content) + self.assertEqual("html", page.format) + self.assertFalse(page.is_draft) + self.assertFalse(page.is_hidden) + self.assertFalse(page.is_auth_required) + + def test_raises_if_no_path_or_id_provided(self): + with self.assertRaises(InvalidPageConfiguration): + Page( + page_id=1, + page_path=BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html", + ) + + def test_raises_if_both_path_and_id_provided(self): + with self.assertRaises(InvalidPageConfiguration): + Page() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_as_dict(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "markdown-page.md" + page = Page(page_path=page_path) + + expected_dict = { + "route": "markdown-page", + "title": "Markdown Page", + "content": "# Hello World!", + "format": "markdown", + "draft": True, + "hidden": True, + "auth_required": True, + } + + self.assertDictEqual(expected_dict, page.as_dict()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_as_frontmatter_post(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "markdown-page.md" + page = Page(page_path=page_path) + + expected_metadata = { + "route": "markdown-page", + "title": "Markdown Page", + "draft": True, + "hidden": True, + "auth_required": True, + } + + page_as_post = page.as_frontmatter_post() + self.assertIsInstance(page_as_post, frontmatter.Post) + self.assertEqual(page_as_post.content, "# Hello World!") + self.assertDictEqual(page_as_post.metadata, expected_metadata) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_syncs_local_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.get.return_value.json.side_effect = [ + # mock first call to /api/v1/pages + {"success": True, "data": [mock_page_data]}, + # mock second call to /api/v1/pages/1 + {"success": True, "data": mock_page_data}, + ] + + mock_api.post.return_value.json.return_value = { + "success": True, + "data": mock_page_data, + } + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + page.sync() + + expected_page_payload = { + "format": "html", + "draft": False, + "title": "HTML Page", + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.patch.assert_called_once_with("/api/v1/pages/1", json=expected_page_payload) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_sync_local_page_if_it_does_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + # mock call to /api/v1/page/1 + mock_api.get.return_value.json.return_value = { + "success": True, + "data": mock_page_data, + } + + page = Page(page_id=1) + + with self.assertRaises(IllegalPageOperation) as e: + page.sync() + + self.assertEqual( + "Cannot sync page 'html-page.html' - local version does not exists. Use pull first.", str(e.exception) + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_sync_local_page_if_remote_does_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + # mock call to /api/v1/pages + mock_api.get.return_value.json.return_value = {"success": True, "data": []} + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + with self.assertRaises(IllegalPageOperation) as e: + page.sync() + + self.assertEqual( + "Cannot sync page 'html-page.html' - remote version does not exists. Use push first.", str(e.exception) + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_downloads_remote_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Test Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "test-page", + }, + } + + page = Page(page_id=1) + page.pull() + + expected_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "test-page.html" + self.assertTrue(expected_path.exists()) + + expected_path.unlink() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_downloads_remote_nested_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Test Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "test/test-page", + }, + } + + page = Page(page_id=1) + page.pull() + + expected_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "test" / "test-page.html" + self.assertTrue(expected_path.exists()) + + expected_path.unlink() + expected_path.parent.rmdir() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_download_remote_page_if_file_exists(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": mock_page_data, + } + + page = Page(page_id=1) + with self.assertRaises(IllegalPageOperation) as e: + page.pull() + + self.assertEqual("Cannot pull page 'html-page.html' - file already exists.", str(e.exception)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.click.secho") + @mock.patch("ctfcli.core.page.API") + def test_downloads_remote_page_with_overwrite( + self, mock_api_constructor: MagicMock, mock_secho: MagicMock, *args, **kwargs + ): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + } + + page = Page(page_id=1) + page.pull(overwrite=True) + + mock_secho.assert_called_once_with("Overwriting page file 'html-page.html'", fg="yellow") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_download_remote_page_if_it_does_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = {"success": True, "data": []} + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + with self.assertRaises(IllegalPageOperation) as e: + page.pull() + + self.assertEqual("Cannot pull page 'html-page.html' - remote version does not exists.", str(e.exception)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_uploads_local_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + page.push() + + expected_page_payload = { + "route": "html-page", + "title": "HTML Page", + "content": "

Hello World!

", + "draft": False, + "hidden": False, + "auth_required": False, + "format": "html", + } + + mock_api.post.assert_called_once_with("/api/v1/pages", json=expected_page_payload) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_upload_local_page_if_remote_exists(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.get.return_value.json.side_effect = [ + # 1st call to /api/v1/pages + {"success": True, "data": [mock_page_data]}, + # 2nd call to /api/v1/pages/1 + {"success": True, "data": mock_page_data}, + ] + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + with self.assertRaises(IllegalPageOperation) as e: + page.push() + + self.assertEqual( + "Cannot push page 'html-page.html' - remote version exists. Use sync instead.", str(e.exception) + ) + + def test_get_format(self): + formats = { + ".md": "markdown", + ".html": "html", + ".htm": "html", + } + + for ext, fmt in formats.items(): + self.assertEqual(fmt, Page.get_format(ext)) + + def test_get_format_extension(self): + extensions = {"markdown": [".md"], "html": [".html", ".htm"]} + + for fmt, allowed_ext in extensions.items(): + self.assertIn(Page.get_format_extension(fmt), allowed_ext) + + def test_get_format_raises_on_unknown_format(self): + with self.assertRaises(InvalidPageFormat): + Page.get_format(".rst") + + def test_get_format_extension_raises_on_unknown_format(self): + with self.assertRaises(InvalidPageFormat): + Page.get_format("restructured-text") + + +class TestPageLoading(unittest.TestCase): + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + def tearDown(self) -> None: + # reset class cache after each test + Page._remote_pages = None + Page._remote_page_ids = None + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_local_pages(self, *args, **kwargs): + local_pages = Page.get_local_pages() + + # expect all pages to be of type Page, and expect to find all the titles + expected_page_titles = ["Nested HTML Page", "HTML Page", "Markdown Page"] + for page in local_pages: + expected_page_titles.pop(expected_page_titles.index(page.title)) + self.assertIsInstance(page, Page) + + self.assertEqual(3, len(local_pages)) + self.assertEqual(0, len(expected_page_titles)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_pages(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.side_effect = [ + { # 1st request - to /api/v1/pages + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + ], + }, + # subsequent request to fetch pages individually + { + "success": True, + "data": { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "content": "# Hello World!", + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "content": "

Hello Nested!

", + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + }, + ] + + remote_pages = Page.get_remote_pages() + + # expect all pages to be of type Page, and expect to find all the titles + expected_page_titles = ["Nested HTML Page", "HTML Page", "Markdown Page"] + for page in remote_pages: + expected_page_titles.pop(expected_page_titles.index(page.title)) + self.assertIsInstance(page, Page) + + self.assertEqual(3, len(remote_pages)) + self.assertEqual(0, len(expected_page_titles)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_page_id(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + ], + } + + page_id = Page.get_remote_page_id("nested-html-page") + self.assertEqual(3, page_id) + + mock_api.get.assert_called_once_with("/api/v1/pages") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_page_id_saves_found_id(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + ], + } + + markdown_page_id = Page.get_remote_page_id("markdown-page") + self.assertEqual(1, markdown_page_id) + + # expect _remote_pages to be None, as we didn't fetch any full pages - only ids + self.assertIsNone(Page._remote_pages) + + # expect to find all available page ids in the cache + self.assertDictEqual(Page._remote_page_ids, {"markdown-page": 1, "html-page": 2}) + + html_page_id = Page.get_remote_page_id("html-page") + self.assertEqual(2, html_page_id) + + # expect to have only made one call + self.assertEqual(1, mock_api.get.mock_calls.count(call("/api/v1/pages"))) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_pages_saves_pages(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.side_effect = [ + { # 1st request - to /api/v1/pages + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + ], + }, + # subsequent request to fetch pages individually + { + "success": True, + "data": { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "content": "# Hello World!", + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "content": "

Hello Nested!

", + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + }, + ] + + remote_pages = Page.get_remote_pages() + + self.assertEqual(3, len(remote_pages)) + # expect to have saved 3 pages + self.assertEqual(3, len(Page._remote_pages)) + + Page.get_remote_page_id("markdown-page") + + # check that /api/v1/pages has only been called once, even though we requested the id for a page + self.assertEqual(1, mock_api.get.mock_calls.count(call("/api/v1/pages"))) diff --git a/tests/core/test_plugins.py b/tests/core/test_plugins.py new file mode 100644 index 0000000..cdbc25d --- /dev/null +++ b/tests/core/test_plugins.py @@ -0,0 +1,22 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.config import Config +from ctfcli.core.plugins import load_plugins + + +class TestPlugins(unittest.TestCase): + @mock.patch.object(Config, "get_plugins_path") + @mock.patch("ctfcli.core.plugins.importlib.import_module") + def test_load_plugins(self, mock_import: MagicMock, mock_plugins_path: MagicMock): + mock_plugins_path.return_value = MagicMock() + mock_plugins_path.return_value.absolute.return_value = Path("/tmp/test") + mock_plugins_path.return_value.iterdir.return_value = [Path("/tmp/test/test_plugin")] + + test_commands = {"challenge": None, "pages": None} + load_plugins(test_commands) + + mock_import.assert_has_calls([call("test_plugin")]) + mock_import.return_value.load.assert_called_once_with(test_commands) diff --git a/tests/fixtures/challenges/.ctf/config b/tests/fixtures/challenges/.ctf/config new file mode 100644 index 0000000..923aedd --- /dev/null +++ b/tests/fixtures/challenges/.ctf/config @@ -0,0 +1,10 @@ +[config] +url = https://example.com/ +access_token = deadbeef + +[challenges] +test-challenge-minimal = user@host:example/test-challenge-minimal.git +test-challenge-full = user@host:example/test-challenge-full.git +test-challenge-files = user@host:example/test-challenge-files.git +test-challenge-dockerfile = user@host:example/test-challenge-dockerfile.git + diff --git a/tests/fixtures/challenges/pages/html-page.html b/tests/fixtures/challenges/pages/html-page.html new file mode 100644 index 0000000..f4c9b34 --- /dev/null +++ b/tests/fixtures/challenges/pages/html-page.html @@ -0,0 +1,9 @@ +--- +auth_required: false +draft: false +hidden: false +route: html-page +title: HTML Page +--- + +

Hello World!

\ No newline at end of file diff --git a/tests/fixtures/challenges/pages/markdown-page.md b/tests/fixtures/challenges/pages/markdown-page.md new file mode 100644 index 0000000..e0d1c99 --- /dev/null +++ b/tests/fixtures/challenges/pages/markdown-page.md @@ -0,0 +1,9 @@ +--- +route: markdown-page +title: Markdown Page +draft: true +hidden: true +auth_required: true +--- + +# Hello World! \ No newline at end of file diff --git a/tests/fixtures/challenges/pages/nested/html-page.htm b/tests/fixtures/challenges/pages/nested/html-page.htm new file mode 100644 index 0000000..5741c5e --- /dev/null +++ b/tests/fixtures/challenges/pages/nested/html-page.htm @@ -0,0 +1,9 @@ +--- +route: nested-html-page +title: Nested HTML Page +draft: false +hidden: false +auth_required: false +--- + +

Hello Nested!

\ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile b/tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile new file mode 100644 index 0000000..1b40618 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:mainline-alpine + +WORKDIR /usr/share/nginx/html +COPY src/ . + +EXPOSE 80 diff --git a/tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml b/tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml new file mode 100644 index 0000000..32fbf9e --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml @@ -0,0 +1,10 @@ +name: Test Challenge +category: New Test +description: New Test Description +attribution: New Test Attribution +value: 150 +author: Test +type: standard +state: hidden +image: . +protocol: http diff --git a/tests/fixtures/challenges/test-challenge-dockerfile/src/index.html b/tests/fixtures/challenges/test-challenge-dockerfile/src/index.html new file mode 100644 index 0000000..0cb0db1 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-dockerfile/src/index.html @@ -0,0 +1,13 @@ + + + + + + + Hello Test! + + +

Hello Test!

+ + \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-files/challenge.yml b/tests/fixtures/challenges/test-challenge-files/challenge.yml new file mode 100644 index 0000000..a562988 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-files/challenge.yml @@ -0,0 +1,12 @@ +name: Test Challenge +category: New Test +description: New Test Description +attribution: New Test Attribution +value: 150 +author: Test +type: standard +state: hidden + +files: + - files/test.png + - files/test.pdf diff --git a/tests/fixtures/challenges/test-challenge-files/files/flag.txt b/tests/fixtures/challenges/test-challenge-files/files/flag.txt new file mode 100644 index 0000000..bf67927 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-files/files/flag.txt @@ -0,0 +1 @@ +Whoopsie: flag{test-flag} \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-files/files/test.pdf b/tests/fixtures/challenges/test-challenge-files/files/test.pdf new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/challenges/test-challenge-files/files/test.png b/tests/fixtures/challenges/test-challenge-files/files/test.png new file mode 100644 index 0000000..de4ac2e Binary files /dev/null and b/tests/fixtures/challenges/test-challenge-files/files/test.png differ diff --git a/tests/fixtures/challenges/test-challenge-full/challenge.yml b/tests/fixtures/challenges/test-challenge-full/challenge.yml new file mode 100644 index 0000000..c1bc392 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-full/challenge.yml @@ -0,0 +1,43 @@ +name: Test Challenge +category: Test +description: Test Description +attribution: Test Attribution +value: 150 +author: Test +type: standard +state: visible +attempts: 5 +connection_info: https://example.com + +flags: + - "flag{test-flag}" + - type: static + content: "flag{test-static}" + data: case_insensitive + - type: regex + content: "flag{test-regex-.*}" + data: case_insensitive + +topics: + - topic-1 + - topic-2 + +tags: + - tag-1 + - tag-2 + +files: + - files/test.png + - files/test.pdf + +hints: + - free hint + - content: paid hint + cost: 100 + +requirements: + - 1 + - Other Test Challenge + +extra: + extra_property: extra_property_value diff --git a/tests/fixtures/challenges/test-challenge-full/files/test.pdf b/tests/fixtures/challenges/test-challenge-full/files/test.pdf new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/challenges/test-challenge-full/files/test.png b/tests/fixtures/challenges/test-challenge-full/files/test.png new file mode 100644 index 0000000..de4ac2e Binary files /dev/null and b/tests/fixtures/challenges/test-challenge-full/files/test.png differ diff --git a/tests/fixtures/challenges/test-challenge-invalid-dockerfile/Dockerfile b/tests/fixtures/challenges/test-challenge-invalid-dockerfile/Dockerfile new file mode 100644 index 0000000..34158eb --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-invalid-dockerfile/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:mainline-alpine + +WORKDIR /usr/share/nginx/html +COPY src/ . diff --git a/tests/fixtures/challenges/test-challenge-invalid-dockerfile/challenge.yml b/tests/fixtures/challenges/test-challenge-invalid-dockerfile/challenge.yml new file mode 100644 index 0000000..69db168 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-invalid-dockerfile/challenge.yml @@ -0,0 +1,9 @@ +name: Test Challenge +category: New Test +description: New Test Description +attribution: New Test Attribution +value: 150 +author: Test +type: standard +state: hidden +image: . diff --git a/tests/fixtures/challenges/test-challenge-invalid/challenge-empty.yml b/tests/fixtures/challenges/test-challenge-invalid/challenge-empty.yml new file mode 100644 index 0000000..f7d2101 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-invalid/challenge-empty.yml @@ -0,0 +1 @@ +# empty challenge.yml \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-invalid/challenge-invalid.yml b/tests/fixtures/challenges/test-challenge-invalid/challenge-invalid.yml new file mode 100644 index 0000000..64e098f --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-invalid/challenge-invalid.yml @@ -0,0 +1,3 @@ +- this is +- invalid +- challenge.yml diff --git a/tests/fixtures/challenges/test-challenge-minimal/challenge.yml b/tests/fixtures/challenges/test-challenge-minimal/challenge.yml new file mode 100644 index 0000000..6d5079a --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-minimal/challenge.yml @@ -0,0 +1,8 @@ +name: Test Challenge +category: New Test +description: New Test Description +attribution: New Test Attribution +value: 150 +author: Test +type: standard +state: hidden diff --git a/tests/fixtures/challenges/test-challenge-solution/challenge.yml b/tests/fixtures/challenges/test-challenge-solution/challenge.yml new file mode 100644 index 0000000..2f0ca83 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-solution/challenge.yml @@ -0,0 +1,9 @@ +name: Test Challenge Solution +category: Test +description: Test challenge with solution fixture +attribution: Test Attribution +value: 100 +author: Test +type: standard +state: hidden +solution: writeup/WRITEUP.md diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/WRITEUP.md b/tests/fixtures/challenges/test-challenge-solution/writeup/WRITEUP.md new file mode 100644 index 0000000..740009d --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-solution/writeup/WRITEUP.md @@ -0,0 +1,10 @@ +# Solution + +This markdown image should be uploaded and rewritten: +![diagram](images/test.png) + +This HTML image is currently not rewritten by ctfcli core: + + +Inline snippet: +--8<-- "src/example.py" diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/images/test-html.png b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test-html.png new file mode 100644 index 0000000..0040c1e --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test-html.png @@ -0,0 +1 @@ +fake-html-png-bytes diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/images/test.png b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test.png new file mode 100644 index 0000000..87c3e19 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test.png @@ -0,0 +1 @@ +fake-png-bytes diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/src/example.py b/tests/fixtures/challenges/test-challenge-solution/writeup/src/example.py new file mode 100644 index 0000000..b274895 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-solution/writeup/src/example.py @@ -0,0 +1 @@ +print("snippet from python") diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_git.py b/tests/utils/test_git.py new file mode 100644 index 0000000..fb1c7ca --- /dev/null +++ b/tests/utils/test_git.py @@ -0,0 +1,169 @@ +import subprocess +import unittest +from pathlib import Path +from unittest import mock + +from ctfcli.utils.git import check_if_dir_is_inside_git_repo, resolve_repo_url + + +class TestResolveRepoUrl(unittest.TestCase): + def test_parses_branch_from_https_url(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + url, branch = resolve_repo_url("https://github.com/user/repo.git@develop") + + self.assertEqual("https://github.com/user/repo.git", url) + self.assertEqual("develop", branch) + mock_check_output.assert_not_called() + + def test_parses_branch_from_ssh_url(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + url, branch = resolve_repo_url("git@github.com:user/repo.git@develop") + + self.assertEqual("git@github.com:user/repo.git", url) + self.assertEqual("develop", branch) + mock_check_output.assert_not_called() + + def test_explicit_branch_overrides_inline(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + url, branch = resolve_repo_url("https://github.com/user/repo.git@inline", branch="explicit") + + self.assertEqual("https://github.com/user/repo.git", url) + self.assertEqual("explicit", branch) + mock_check_output.assert_not_called() + + def test_explicit_branch_with_no_inline(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + url, branch = resolve_repo_url("https://github.com/user/repo.git", branch="develop") + + self.assertEqual("https://github.com/user/repo.git", url) + self.assertEqual("develop", branch) + mock_check_output.assert_not_called() + + def test_detects_head_branch_when_none_specified(self): + # example output taken from ctfcli repo + mock_output = b""" +ref: refs/heads/master HEAD +7b4a09af8414eb1f5f6da9a8422fb53b5e9cbc15 HEAD +0370595efd5e9a211b05c55778fc4c0ae2fe70af refs/heads/15-blank-challenge-template +""" + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + url, branch = resolve_repo_url("https://github.com/CTFd/ctfcli.git") + + self.assertEqual("https://github.com/CTFd/ctfcli.git", url) + self.assertEqual("master", branch) + mock_check_output.assert_called_once_with( + ["git", "ls-remote", "--symref", "https://github.com/CTFd/ctfcli.git", "HEAD"], + stderr=subprocess.DEVNULL, + ) + + def test_detects_head_branch_for_ssh_url(self): + mock_output = b"ref: refs/heads/main HEAD\nabc123 HEAD\n" + + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + url, branch = resolve_repo_url("git@github.com:user/repo.git") + + self.assertEqual("git@github.com:user/repo.git", url) + self.assertEqual("main", branch) + mock_check_output.assert_called_once_with( + ["git", "ls-remote", "--symref", "git@github.com:user/repo.git", "HEAD"], + stderr=subprocess.DEVNULL, + ) + + def test_trailing_at_falls_through_to_head_detection(self): + mock_output = b"ref: refs/heads/main HEAD\nabc123 HEAD\n" + + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + url, branch = resolve_repo_url("https://github.com/user/repo.git@") + + self.assertEqual("https://github.com/user/repo.git", url) + self.assertEqual("main", branch) + mock_check_output.assert_called_once() + + def test_returns_none_if_repository_not_found(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + mock_check_output.side_effect = subprocess.CalledProcessError(128, []) + url, branch = resolve_repo_url("https://github.com/example/does-not-exist.git") + + self.assertEqual("https://github.com/example/does-not-exist.git", url) + self.assertIsNone(branch) + + def test_returns_none_if_head_not_set(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=b"") as mock_check_output: + url, branch = resolve_repo_url("https://github.com/example/no-head.git") + + self.assertEqual("https://github.com/example/no-head.git", url) + self.assertIsNone(branch) + mock_check_output.assert_called_once() + + def test_returns_none_for_non_git_path_without_subprocess(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + url, branch = resolve_repo_url("some/local/path") + + self.assertEqual("some/local/path", url) + self.assertIsNone(branch) + mock_check_output.assert_not_called() + + +class TestCheckIfDirIsInsideGitRepo(unittest.TestCase): + def test_returns_true_if_inside_git_repo(self): + mock_output = b"true\n" + + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + inside_git_repo = check_if_dir_is_inside_git_repo() + + mock_check_output.assert_called_once_with( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=None, + stderr=subprocess.DEVNULL, + ) + self.assertTrue(inside_git_repo) + + def test_accepts_cwd(self): + mock_output = b"true\n" + + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + cwd_path = Path("/tmp/test/ctfcli/test-challenges-dir") + inside_git_repo = check_if_dir_is_inside_git_repo(cwd_path) + + mock_check_output.assert_called_once_with( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=cwd_path, + stderr=subprocess.DEVNULL, + ) + self.assertTrue(inside_git_repo) + + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + cwd_path = "/tmp/test/ctfcli/test-challenges-dir" + inside_git_repo = check_if_dir_is_inside_git_repo(cwd_path) + + mock_check_output.assert_called_once_with( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=cwd_path, + stderr=subprocess.DEVNULL, + ) + self.assertTrue(inside_git_repo) + + def test_returns_false_if_outside_git_repo(self): + mock_output = b"fatal: not a git repository (or any of the parent directories): .git\n" + + with mock.patch("ctfcli.utils.git.subprocess.check_output", return_value=mock_output) as mock_check_output: + inside_git_repo = check_if_dir_is_inside_git_repo() + + mock_check_output.assert_called_once_with( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=None, + stderr=subprocess.DEVNULL, + ) + self.assertFalse(inside_git_repo) + + def test_returns_false_if_subprocess_raises(self): + with mock.patch("ctfcli.utils.git.subprocess.check_output") as mock_check_output: + mock_check_output.side_effect = subprocess.CalledProcessError(128, []) + inside_git_repo = check_if_dir_is_inside_git_repo() + + mock_check_output.assert_called_once_with( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=None, + stderr=subprocess.DEVNULL, + ) + self.assertFalse(inside_git_repo) diff --git a/tests/utils/test_tools.py b/tests/utils/test_tools.py new file mode 100644 index 0000000..8e13ca5 --- /dev/null +++ b/tests/utils/test_tools.py @@ -0,0 +1,27 @@ +import unittest +from unittest import mock + +from ctfcli.utils.tools import strings + + +class TestStrings(unittest.TestCase): + @mock.patch("builtins.open", mock.mock_open(read_data="Hello\x02World\x88!")) + def test_returns_printable(self): + result = strings("/tmp/test/ctfcli/doesnotmatter.bin") + self.assertEqual(["Hello", "World"], list(result)) + + @mock.patch("builtins.open", mock.mock_open(read_data="Hello\x02Wor\x02ld\x88!")) + def test_does_not_catch_results_shorter_than_min_length(self): + result = strings("/tmp/test/ctfcli/doesnotmatter.bin", min_length=10) + self.assertEqual([], list(result)) + + result = strings("/tmp/test/ctfcli/doesnotmatter.bin") + self.assertEqual(["Hello"], list(result)) + + result = strings("/tmp/test/ctfcli/doesnotmatter.bin", min_length=2) + self.assertEqual(["Hello", "Wor", "ld"], list(result)) + + @mock.patch("builtins.open", mock.mock_open(read_data="\x88\x02\x02\x88")) + def test_returns_empty_generator_if_no_strings_found(self): + result = strings("/tmp/test/ctfcli/doesnotmatter.bin") + self.assertEqual([], list(result)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c1a1e26 --- /dev/null +++ b/uv.lock @@ -0,0 +1,713 @@ +version = 1 +requires-python = ">=3.10, <4" + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, +] + +[[package]] +name = "arrow" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "types-python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/00/0f6e8fcdb23ea632c866620cc872729ff43ed91d284c866b515c6342b173/arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85", size = 131960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80", size = 66419 }, +] + +[[package]] +name = "binaryornot" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cookiecutter" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c", size = 158767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d", size = 39177 }, +] + +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979 }, + { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496 }, + { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237 }, + { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061 }, + { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928 }, + { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931 }, + { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968 }, + { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972 }, + { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241 }, + { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847 }, + { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573 }, + { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509 }, + { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104 }, + { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606 }, + { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999 }, + { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925 }, + { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032 }, + { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134 }, + { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731 }, + { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795 }, + { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514 }, + { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424 }, + { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536 }, + { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206 }, + { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274 }, + { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638 }, + { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129 }, + { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974 }, + { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538 }, + { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912 }, + { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054 }, + { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619 }, + { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496 }, + { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808 }, + { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616 }, + { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261 }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297 }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673 }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652 }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251 }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492 }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850 }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633 }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586 }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412 }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191 }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829 }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640 }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269 }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990 }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340 }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638 }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705 }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125 }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844 }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700 }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321 }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222 }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411 }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505 }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569 }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841 }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343 }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672 }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715 }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225 }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559 }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724 }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582 }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538 }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349 }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011 }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091 }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904 }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480 }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074 }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342 }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713 }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825 }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233 }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779 }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700 }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302 }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136 }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467 }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875 }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982 }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016 }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "ctfcli" +version = "0.1.7" +source = { editable = "." } +dependencies = [ + { name = "appdirs" }, + { name = "click" }, + { name = "colorama" }, + { name = "cookiecutter" }, + { name = "fire" }, + { name = "python-frontmatter" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-sugar" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "appdirs", specifier = ">=1.4.4,<2" }, + { name = "click", specifier = ">=8.1.7,<9" }, + { name = "colorama", specifier = ">=0.4.6,<0.5" }, + { name = "cookiecutter", specifier = ">=2.3.0,<3" }, + { name = "fire", specifier = ">=0.7.0,<0.8" }, + { name = "python-frontmatter", specifier = ">=1.0.0,<2" }, + { name = "python-slugify", specifier = ">=8.0.4,<9" }, + { name = "pyyaml", specifier = ">=6.0.1,<7" }, + { name = "requests", specifier = ">=2.31.0,<3" }, + { name = "typing-extensions", specifier = ">=4.7.1,<5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2,<10" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8" }, + { name = "pytest-sugar", specifier = ">=1.1.1,<2" }, + { name = "ruff", specifier = ">=0.14.9,<0.15" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "fire" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/b6/82c7e601d6d3c3278c40b7bd35e17e82aa227f050aa9f66cb7b7fce29471/fire-0.7.0.tar.gz", hash = "sha256:961550f07936eaf65ad1dc8360f2b2bf8408fad46abbfa4d2a3794f8d2a95cdf", size = 87189 } + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206 }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079 }, + { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620 }, + { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818 }, + { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493 }, + { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630 }, + { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745 }, + { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021 }, + { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659 }, + { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213 }, + { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219 }, + { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098 }, + { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014 }, + { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220 }, + { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756 }, + { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988 }, + { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718 }, + { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317 }, + { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670 }, + { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224 }, + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-frontmatter" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834 }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "ruff" +version = "0.14.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541 }, + { url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363 }, + { url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292 }, + { url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894 }, + { url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482 }, + { url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100 }, + { url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729 }, + { url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386 }, + { url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124 }, + { url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343 }, + { url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425 }, + { url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768 }, + { url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939 }, + { url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888 }, + { url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473 }, + { url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651 }, + { url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079 }, + { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "termcolor" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/56/d7d66a84f96d804155f6ff2873d065368b25a07222a6fd51c4f24ef6d764/termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a", size = 12664 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5f/8c716e47b3a50cbd7c146f45881e11d9414def768b7cd9c5e6650ec2a80a/termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63", size = 7719 }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241206" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/60/47d92293d9bc521cd2301e423a358abfac0ad409b3a1606d8fbae1321961/types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb", size = 13802 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/b3/ca41df24db5eb99b00d97f89d7674a90cb6b3134c52fb8121b6d8d30f15c/types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53", size = 14384 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +]