├── .coveragerc ├── .github └── workflows │ ├── ci-v2.yml │ └── python-publish-package.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── LICENSE ├── README.md ├── docs ├── api.md ├── build.md ├── developer.md └── img │ ├── transcript-1.png │ ├── transcript-2.png │ ├── transcript-3.png │ └── transcript-4.png ├── examples ├── advanced_usage.py ├── simple_usage.py └── using_referer.py ├── ignition ├── __init__.py ├── __main__.py ├── exceptions.py ├── globals.py ├── python │ ├── __init__.py │ ├── python3_10 │ │ ├── LICENSE_NOTICE │ │ ├── Lib │ │ │ ├── __init__.py │ │ │ └── urllib │ │ │ │ ├── __init__.py │ │ │ │ └── parse.py │ │ └── __init__.py │ ├── python3_11 │ │ ├── LICENSE_NOTICE │ │ ├── Lib │ │ │ ├── __init__.py │ │ │ └── urllib │ │ │ │ ├── __init__.py │ │ │ │ └── parse.py │ │ └── __init__.py │ ├── python3_12 │ │ ├── Lib │ │ │ ├── __init__.py │ │ │ └── urllib │ │ │ │ ├── __init__.py │ │ │ │ └── parse.py │ │ └── __init__.py │ ├── python3_7 │ │ ├── LICENSE_NOTICE │ │ ├── Lib │ │ │ ├── __init__.py │ │ │ └── urllib │ │ │ │ ├── __init__.py │ │ │ │ └── parse.py │ │ └── __init__.py │ ├── python3_8 │ │ ├── LICENSE_NOTICE │ │ ├── Lib │ │ │ ├── __init__.py │ │ │ └── urllib │ │ │ │ ├── __init__.py │ │ │ │ └── parse.py │ │ └── __init__.py │ └── python3_9 │ │ ├── LICENSE_NOTICE │ │ ├── Lib │ │ ├── __init__.py │ │ └── urllib │ │ │ ├── __init__.py │ │ │ └── parse.py │ │ └── __init__.py ├── request.py ├── response.py ├── ssl │ ├── __init__.py │ ├── cert_record.py │ ├── cert_store.py │ └── cert_wrapper.py ├── url.py └── util.py ├── pyproject.toml └── tests ├── __init__.py ├── fixtures ├── sample_cert.der └── sample_cert.pem ├── helpers.py ├── ssl ├── __init__.py ├── test_cert_record.py └── test_cert_wrapper.py ├── test_ignition.py ├── test_request.py ├── test_response.py ├── test_url.py └── test_util.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = ignition/python/* 3 | -------------------------------------------------------------------------------- /.github/workflows/ci-v2.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: "CI v2" 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | lint: 14 | name: "Lint" 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | - name: Upgrade pip 21 | run: pip install --upgrade pip 22 | - name: Install dependencies 23 | run: python -m pip install .[lint] 24 | - name: black 25 | run: black --check . 26 | - name: ruff 27 | run: ruff check . 28 | test: 29 | name: "Python ${{ matrix.python-version}} Test" 30 | needs: [lint] 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Upgrade pip 42 | run: pip install --upgrade pip 43 | - name: Install dependencies 44 | run: python -m pip install .[test] 45 | - name: Test with pytest 46 | run: pytest --cov=ignition tests/ 47 | test-build: 48 | name: "Test package build" 49 | needs: [test] 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Set up Python 54 | uses: actions/setup-python@v5 55 | - name: Upgrade pip 56 | run: pip install --upgrade pip 57 | - name: Install dependencies 58 | run: python -m pip install .[build] 59 | - name: Build 60 | run: python -m build 61 | check_status: 62 | name: "All Tests Passed Gate" 63 | needs: [test-build] 64 | runs-on: ubuntu-latest 65 | steps: 66 | - run: echo "All tests completed" 67 | 68 | -------------------------------------------------------------------------------- /.github/workflows/python-publish-package.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | - name: Upgrade pip 20 | run: pip install --upgrade pip 21 | - name: Install dependencies 22 | run: python -m pip install .[build] 23 | - name: Build 24 | run: python -m build 25 | - name: Publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: twine upload dist/* 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.o 3 | *.a 4 | *.exe 5 | 6 | .DS_Store 7 | 8 | .venv 9 | *.pyc 10 | *.pyo 11 | __pycache__/ 12 | .pytest_cache/ 13 | .ruff_cache/ 14 | dist/ 15 | build/ 16 | *.egg-info/ 17 | .coverage 18 | .vscode 19 | 20 | venv/ 21 | 22 | .known_hosts -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | entry: black 7 | language: system 8 | types: [python] 9 | require_serial: true 10 | args: [--check, --diff] 11 | - repo: local 12 | hooks: 13 | - id: ruff 14 | name: ruff 15 | entry: ruff 16 | language: system 17 | types: [python] 18 | require_serial: true 19 | args: [check] -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.7 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ignition: Python3 Gemini Protocol Client Transport Library 2 | 3 | ![This is Gemini Control. We're at T-1 minute, T-60 seconds and counting. T-45 seconds and counting. The range holding a final status check. T-30 seconds. Recorders have gone to fast speed. Twenty seconds. Fifteen seconds. Ten, nine, eight, seven, six, five, four, three, two, one zero. Ignition.](docs/img/transcript-1.png) 4 | 5 | Ignition is a simple but powerful transport library for Python3 clients using the recently designed [Gemini protocol](https://geminiprotocol.net/). This project intends to implement all of the [transport specifications](https://geminiprotocol.net/docs/specification.gmi) (sections 1-4) of the Gemini protocol and provide an easy-to-use interface, so as to act as a building block in a larger application. 6 | 7 | If you're building a Python3 application that uses Gemini, Ignition is your gateway to the stars, in very much the same way that [requests](https://requests.readthedocs.io/en/master/) is for HTTP and **gopherlib** is for Gopher. 8 | 9 | In order to provide a best-in-class interface, this library does not implement the other parts of a typical client (including user interface and/or command line interface), and instead focuses on providing a robust programmatic API interface. This project also assumes that different user interfaces will have different requirements for their display of text/gemini files (.gmi), and/or other mime-types, and as such considers this portion of the specification beyond the scope of this project. 10 | 11 | In addition, in order to provide stability and simplicity, minimal third-party dependencies are required for Ignition. 12 | 13 | ## Project Status 14 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/cbrews/ignition?label=ignition) 15 | [![CI v2](https://github.com/cbrews/ignition/actions/workflows/ci-v2.yml/badge.svg)](https://github.com/cbrews/ignition/actions/workflows/ci-v2.yml) 16 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ignition-gemini) 17 | ![PyPI - License](https://img.shields.io/pypi/l/ignition-gemini) 18 | 19 | ![The status is good to go. This is Gemini Control.](docs/img/transcript-2.png) 20 | 21 | Ignition is no longer being updated. 22 | 23 | ## Installation 24 | ⚠ Ignition supports Python versions 3.7 - 3.12. 25 | 26 | Ignition can be installed via [PIP](https://pypi.org/project/ignition-gemini/). You should install it in alignment with your current development process; if you do not have a build process yet, I recommend you install within a [virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/) 27 | 28 | ```bash 29 | pip install ignition-gemini 30 | ``` 31 | 32 | If you prefer to install from source, you can clone and install the repository: 33 | 34 | ```bash 35 | git clone https://github.com/cbrews/ignition.git 36 | cd ignition 37 | pip install . 38 | ``` 39 | 40 | ## Simple Usage 41 | The most basic usage of Ignition allows the user to create a request and get a response back from a remote Gemini capsule: 42 | ```python 43 | import ignition 44 | 45 | # Fetch capsule content 46 | response = ignition.request('//geminiprotocol.net') 47 | 48 | # Get status from remote capsule 49 | print(response.status) 50 | 51 | # Get response information from remote capsule 52 | print(response.data()) 53 | ``` 54 | [source](examples/simple_usage.py) 55 | 56 | In **all** cases, Ignition assumes that the specified endpoint and protocol will respond over the Gemini protocol, so even if you provide a different protocol or port, it will assume that the endpoint is a Gemini capsule. 57 | 58 | ## Key Features 59 | 60 | ![This is Gemini Control. The conversation between pilot and ground so far in this filght has largely been confined to the normal type of test pilot talk that you would expect.](docs/img/transcript-3.png) 61 | 62 | ✅ Ignition supports the following features: 63 | * Basic request/response connectivity to a Gemini-enabled server. 64 | * Basic URL parsing mechanics to allow for specifying protocol, host, port, path, and query params, as per [RFC-3986](https://tools.ietf.org/html/rfc3986) 65 | * Optional referer URL handling. Ignition allows the user to pass a path & referer URL and can construct the new path, to simplifying the resolution of links on a Gemini capsule page. 66 | * Basic decoding of body responses on successful (20) response from Gemini servers. 67 | * Trust-on-first-use certificate verification handling scheme using key signatures. 68 | * Fully-featured response objects for each response type. 69 | * Standardized & robust, human-readable error management. 70 | * Custom error handling for networking failure cases beyond the scope of the protocol. 71 | 72 | ❌ The following Gemini features will *not* be supported by Ignition: 73 | * Behavioral processing/handling of specific response types from Gemini capsules, including: 74 | * Generation of client certificates & automatic resubmission. 75 | * Automatic redirection following on 3x responses. 76 | * Advanced body response rendering and/or display of text/gemini mime types. 77 | * Command line or GUI interface. 78 | * Advanced session & history management. 79 | * Support for other protocols. 80 | * Non-verified certificate scheme 81 | * Improved TOFU scenarios 82 | 83 | ## Advanced Usage 84 | More advanced request usage: 85 | 86 | ```python 87 | import ignition 88 | 89 | response = ignition.request('/software', referer='//geminiprotocol.net:1965') 90 | 91 | print("Got back response %s from %s" % (response.status, response.url)) 92 | # Got back a response 20 from gemini://geminiprotocol.net/software 93 | 94 | if not response.success(): 95 | print("There was an error on the response.") 96 | else: 97 | print(response.data()) 98 | ``` 99 | 100 | Passing a referer: 101 | ```python 102 | import ignition 103 | 104 | response1 = ignition.request('//geminiprotocol.net') 105 | response2 = ignition.request('software', referer=response1.url) 106 | 107 | print(response2) 108 | ``` 109 | [source](examples/using_referer.py) 110 | 111 | More advanced response validation: 112 | ```python 113 | import ignition 114 | 115 | url = '//geminiprotocol.net' 116 | response = ignition.request(url) 117 | 118 | if response.is_a(ignition.SuccessResponse): 119 | print('Success!') 120 | print(response.data()) 121 | 122 | elif response.is_a(ignition.InputResponse): 123 | print('Needs additional input: %s' % (response.data())) 124 | 125 | elif response.is_a(ignition.RedirectResponse): 126 | print('Received response, redirect to: %s' % (response.data())) 127 | 128 | elif response.is_a(ignition.TempFailureResponse): 129 | print('Error from server: %s' % (response.data())) 130 | 131 | elif response.is_a(ignition.PermFailureResponse): 132 | print('Error from server: %s' % (response.data())) 133 | 134 | elif response.is_a(ignition.ClientCertRequiredResponse): 135 | print('Client certificate required. %s' % (response.data())) 136 | 137 | elif response.is_a(ignition.ErrorResponse): 138 | print('There was an error on the request: %s' % (response.data())) 139 | ``` 140 | [source](examples/advanced_usage.py) 141 | 142 | Finally, the module exposes `DEBUG` level logging via standard python capabilities. If you are having trouble with the requests, enable debug-level logging with: 143 | 144 | ```python 145 | import logging 146 | logging.basicConfig(level=logging.DEBUG) 147 | ``` 148 | 149 | ## API Documentation 150 | Full API documentation for Ignition is available [here](./docs/api.md). 151 | 152 | ## Developers 153 | 154 | ![There are a few reports from the pilots. They are simply identifying their flight plan very carefully. Four minutes into the flight, Gordon Cooper just told Grissom that he is looking mighty good. Gus gave him a reasuring laugh. A very calm pilot in command of that spacecraft.](docs/img/transcript-4.png) 155 | 156 | Ignition is no longer accepting contributions. Please feel free to fork this repository. 157 | 158 | ## License 159 | Ignition is licensed under [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/). 160 | 161 | Copyright 2020-2024 by [Chris Brousseau](https://github.com/cbrews). 162 | 163 | ## Thank you 164 | * *solderpunk* for leading the design of the [Gemini protocol](https://geminiprotocol.net/docs/specification.html), without which this project would not have been possible. 165 | * *Sean Conman* for writing the [Gemini torture tests](gemini://gemini.conman.org/test/torture), which were instrumental in initial client testing. 166 | * *Michael Lazar* for his work on [Jetforce](https://github.com/michael-lazar/jetforce), which helped testing along the way. 167 | 168 | 🔭 Happy exploring! 169 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | The Ignition library exposes a package `ignition` that contains all of the functionality for interfacing with Gemini capsules. 3 | 4 | * [ignition](#ignition) 5 | * [ignition.BaseResponse](#ignitionbaseresponse) 6 | * [ignition.InputResponse](#ignitioninputresponse) 7 | * [ignition.SuccessResponse](#ignitionsuccessresponse) 8 | * [ignition.RedirectResponse](#ignitionredirectresponse) 9 | * [ignition.TempFailureResponse](#ignitiontempfailureresponse) 10 | * [ignition.PermFailureResponse](#ignitionpermfailureresponse) 11 | * [ignition.ClientCertRequiredResponse](#ignitionclientcertrequiredresponse) 12 | * [ignition.ErrorResponse](#ignitionerrorresponse) 13 | 14 | ## ignition 15 | *Source Code: [src/\_\_init\_\_.py](../src/__init__.py)* 16 | 17 | Load this with 18 | ```python 19 | import ignition 20 | ``` 21 | 22 | This class cannot be instantiated directly. 23 | 24 | ### Methods 25 | 26 | #### request(url: string, referer: string = None, timeout: float = None, raise_errors = False, ca_cert: Tuple[str, str] = None) -> ignition.BaseResponse 27 | Given a *url* to a Gemini capsule, this performs a request to the specified url and returns a response (as a subclass of [ignition.BaseResponse](#ignitionbaseresponse)) with the details associated to the response. This is the interface that most users should use. 28 | 29 | If a *referer* is provided, a dynamic URL is constructed by ignition to send a request to. (*referer* expectes a fully qualified url as returned by `ignition.BaseResponse.url` or (less prefered) `ignition.url()`). Typically, in order to simplify the browsing experience, you should pass the previously requested URL as the referer to simplify URL construction logic. 30 | 31 | *See `ignition.url()` for details around url construction with a referer.* 32 | 33 | If a *timeout* is provided, this will specify the client timeout (in seconds) for this request. The default is 30 seconds. See also `ignition.set_default_timeout` to change the default timeout. 34 | 35 | If a *ca_cert* is provided, the certificate will be sent to the server as a CA CERT. You will need to provide the paths to both the certificate and the key in this case. 36 | 37 | If *raise_errors* is `True` (default value = `False`), then non-protocol errors will bubble up and be raised as an exception instead of returning [ignition.ErrorResponse](#ignitionerrorresponse). 38 | 39 | Depending on the response from the server, as per Gemini specification, the corresponding response type will be returned. 40 | 41 | * If the response status begins with "1", the response type is `INPUT`, and will return a response of type [ignition.InputResponse](#ignitioninputresponse). 42 | * If the response status begins with "2", the response type is `STATUS`, and will return a response of type [ignition.SuccessResponse](#ignitionsuccessresponse). 43 | * If the response status begins with "3", the response type is `REDIRECT`, and will return a response of type [ignition.RedirectResponse](#ignitionredirectresponse). 44 | * If the response status begins with "4", the response type is `TEMPORARY FAILURE`, and will return a response of type [ignition.TempFailureResponse](#ignitiontempfailureresponse). 45 | * If the response status begins with "5", the response type is `PERMANENT FAILURE`, and will return a response of type [ignition.PermFailureResponse](#ignitionpermfailureresponse). 46 | * If the response status begins with "6", the response type is `CLIENT CERTIFICATE REQUIRED`, and will return a response of type [ignition.ClientCertRequiredResponse](#ignitionclientcertrequiredresponse). 47 | * If *no valid response* can be returned, ignition assigns a response type of "0" and returns a response of type [ignition.ErrorResponse](#ignitionerrorresponse). Note: if the user specifies `raise_errors=True`, then any errors will bubble up instead of returning this response type. 48 | 49 | Parameters: 50 | * url: `string` 51 | * referer: `string` (optional) 52 | * timeout: `float` (optional) 53 | * raise_errors: `bool` (optional) 54 | * ca_cert: `Tuple(cert_file, key_file)` (optional) 55 | 56 | Returns: `[ignition.BaseResponse](#ignitionbaseresponse)` 57 | 58 | #### url(url: string, referer: string = None) -> string 59 | Given a *url* to a Gemini capsule, this returns a standardized, fully-qualified url to the Gemini capsule. If a *referer* is provided, a dynamic URL is constructed by ignition to send a request to. This logic follows URL definition behavior outlined in [RFC-3986](https://tools.ietf.org/html/rfc3986). 60 | 61 | This allows for the bulk of URL generation logic to be handled without ignition as opposed to within the business logic of the client. Here are some sample use cases: 62 | 63 | *Use Case 1: Automatically populate URL protocol* 64 | ```python 65 | ignition.url('//geminiprotocol.net') # => gemini://geminiprotocol.net 66 | ``` 67 | 68 | *Use Case 2: Navigate to an absolute path* 69 | ```python 70 | ignition.url('/home', 'gemini://geminiprotocol.net') # => gemini://geminiprotocol.net/home 71 | ``` 72 | 73 | *Use Case 3: Navigate to a relative path* 74 | ```python 75 | ignition.url('2', 'gemini://geminiprotocol.net/home') # => gemini://geminiprotocol.net/home/2 76 | ``` 77 | 78 | *Use Case 4: Resolve paths with navigation* 79 | ```python 80 | ignition.url('../fun/', 'gemini://geminiprotocol.net/home/work/') # => gemini://geminiprotocol.net/home/fun/ 81 | ``` 82 | 83 | *Note:* if the user's intent is to generate a url to a Gemini capsule and then make a request, ignition recommends that you just provide the *url* and *referer* to `ignition.request()`, as that function encapsulates all of the logic within this method when making a request. If you want to retrieve a URL from an already processed request, it is recommended to use `ignition.BaseResponse.url`, as that will store the URL that was actually used. This method is only intended for use in constructing a URL but not generating a request. 84 | 85 | Parameters: 86 | * url: `string` 87 | * referer: `string` (optional) 88 | 89 | Returns: `string` 90 | 91 | #### set_default_timeout(timeout: float) 92 | Set the default timeout (in seconds) for all requests made via ignition. The default timeout is 30 seconds. 93 | 94 | Parameters: 95 | * timeout: `float` 96 | 97 | #### set_default_hosts_file(hosts_file: string) 98 | Set the default host file location where all of the certificate fingerprints are stored in order to support Trust-On-First-Use (TOFU) validation. By default, this file is stored in the same directory as your project in a file named `.known_hosts`. This can be updated to any readable location but should be stored somewhere persistent for security purposes. 99 | 100 | The format of this file is very similar to (but not identical to) the SSH `known_hosts` file. 101 | 102 | Parameters: 103 | * hosts_file: `string` 104 | 105 | ### Constants 106 | 107 | #### RESPONSE_STATUS_INPUT = "1" 108 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.InputResponse`. As per the Gemini documentation, this means that the requested resource requires a line of textual user input. The same resource should then be requested again with the user's input included as a query component. 109 | 110 | See `RESPONSE_STATUSDETAIL_INPUT*` for additional detailed responses for each potential response type. 111 | 112 | #### RESPONSE_STATUS_SUCCESS = "2" 113 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.SuccessResponse`. 114 | 115 | See `RESPONSE_STATUSDETAIL_SUCCESS*` for additional detailed responses for each potential response type. As per the Gemini documentation, the request was handled successfully and a response body is included, following the response header. The META line is a MIME media type which applies to the response body. 116 | 117 | #### RESPONSE_STATUS_REDIRECT = "3" 118 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.RedirectResponse`. As per the Gemini documentation, the server is redirecting the client to a new location for the requested resource. The URL may be absolute or relative. The redirect should be considered temporary (unless specied otherwise in the detailed status), i.e. clients should continue to request the resource at the original address and should not performance convenience actions like automatically updating bookmarks. 119 | 120 | There is currently no support for automatically following redirects in Ignition. 121 | 122 | See `RESPONSE_STATUSDETAIL_REDIRECT*` for additional detailed responses for each potential response type. 123 | 124 | #### RESPONSE_STATUS_TEMP_FAILURE = "4" 125 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.TempFailureResponse`. As per the Gemini documentation, the request has failed. The nature of the failure is temporary, i.e. an identical request MAY succeed in the future. 126 | 127 | See `RESPONSE_STATUSDETAIL_TEMP_FAILURE*` for additional detailed responses for each potential response type. 128 | 129 | #### RESPONSE_STATUS_PERM_FAILURE = "5" 130 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.PermFailureResponse`. As per the Gemini documentation, the request has failed. The nature of the failure is permanent, i.e. identical future requests will reliably fail for the same reason. Automatic clients such as aggregators or indexing crawlers should not repeat this request. 131 | 132 | See `RESPONSE_STATUSDETAIL_PERM_FAILURE*` for additional detailed responses for each potential response type. 133 | 134 | #### RESPONSE_STATUS_CLIENTCERT_REQUIRED = "6" 135 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.ClientCertRequiredResponse`. As per the Gemini documentation, the requested resource requires a client certificate to access. If the request was made without a certificate, it should be repeated with one. If the request was made with a certificate, the server did not accept it and the request should be repeated with a different certificate. 136 | 137 | See `RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED*` for additional detailed responses for each potential response type. 138 | 139 | #### RESPONSE_STATUS_ERROR = "0" 140 | Possible value for `ignition.BaseResponse.status`, and will appear in any response types of `ignition.ErrorResponse`. This status indicates that there was an error on transmission with the host and the request could not be completed. These response types are specific to Ignition because they are beyond the scope of the Gemini protocol and typically indicate an error with networking or communication between the client and the host. 141 | 142 | See `RESPONSE_STATUSDETAIL_ERROR*` for additional detailed responses for each potential response type. 143 | 144 | --- 145 | 146 | #### RESPONSE_STATUSDETAIL_INPUT = "10" 147 | This is a detailed status message for response type 1x (INPUT). 148 | 149 | As per the Gemini specification, this is the default response type and no special handling should be applied beyond what's handled by the INPUT response. 150 | 151 | See `RESPONSE_STATUS_INPUT` for additional details. 152 | 153 | #### RESPONSE_STATUSDETAIL_INPUT_SENSITIVE = "11" 154 | This is a detailed status message for response type 1x (INPUT). 155 | 156 | As per the Gemini specification, the client should request user input but should not echo that input to the screen, and keep it protected as if it were a password. 157 | 158 | See `RESPONSE_STATUS_INPUT` for additional details. 159 | 160 | #### RESPONSE_STATUSDETAIL_SUCCESS = "20" 161 | This is a detailed status message for response type 2x (SUCCESS). 162 | 163 | As per the Gemini specification, this is the default response type and no special handling should be applied beyond what's handled by the SUCCESS response. 164 | 165 | See `RESPONSE_STATUS_SUCCESS` for additional details. 166 | 167 | #### RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY = "30" 168 | This is a detailed status message for response type 3x (REDIRECT). 169 | 170 | As per the Gemini specification, this is the default response type and no special handling should be applied beyond what's handled by the REDIRECT response. 171 | 172 | See `RESPONSE_STATUS_REDIRECT` for additional details. 173 | 174 | #### RESPONSE_STATUSDETAIL_REDIRECT_PERMANENT = "31" 175 | This is a detailed status message for response type 3x (REDIRECT). 176 | 177 | As per the Gemini specification, the specified redirect is permanent. All indexes should be updated to avoid sending requests to the old URL. 178 | 179 | See `RESPONSE_STATUS_REDIRECT` for additional details. 180 | 181 | #### RESPONSE_STATUSDETAIL_TEMP_FAILURE = "40" 182 | This is a detailed status message for response type 4x (TEMPORARY FAILURE). 183 | 184 | As per the Gemini specification, this is the default response type and no special handling should be applied beyond what's handled by the TEMPORARY FAILURE response. 185 | 186 | See `RESPONSE_STATUS_TEMP_FAILURE` for additional details. 187 | 188 | #### RESPONSE_STATUSDETAIL_TEMP_FAILURE_UNAVAILABLE = "41" 189 | This is a detailed status message for response type 4x (TEMPORARY FAILURE). 190 | 191 | As per the Gemini specification, this represents a temporary failure due to a server issue. The request should be retried at a later time. 192 | 193 | See `RESPONSE_STATUS_TEMP_FAILURE` for additional details. 194 | 195 | #### RESPONSE_STATUSDETAIL_TEMP_FAILURE_CGI = "42" 196 | This is a detailed status message for response type 4x (TEMPORARY FAILURE). 197 | 198 | As per the Gemini specification, this represents a temporary failure of a CGI script. The request should be retried at a later time. 199 | 200 | See `RESPONSE_STATUS_TEMP_FAILURE` for additional details. 201 | 202 | #### RESPONSE_STATUSDETAIL_TEMP_FAILURE_PROXY = "43" 203 | This is a detailed status message for response type 4x (TEMPORARY FAILURE). 204 | 205 | As per the Gemini specification, this represents a temporary failure of a network proxy. The request should be retried at a later time. 206 | 207 | See `RESPONSE_STATUS_TEMP_FAILURE` for additional details. 208 | 209 | #### RESPONSE_STATUSDETAIL_TEMP_FAILURE_SLOW_DOWN = "44" 210 | This is a detailed status message for response type 4x (TEMPORARY FAILURE). 211 | 212 | As per the Gemini specification, this represents temporary failure due to rate limiting. The meta value will be an integer number of seconds which the client must wait before another request is made to this server. 213 | 214 | See `RESPONSE_STATUS_TEMP_FAILURE` for additional details. 215 | 216 | #### RESPONSE_STATUSDETAIL_PERM_FAILURE = "50" 217 | This is a detailed status message for response type 5x (PERMANENT FAILURE). 218 | 219 | As per the Gemini specification, this is the default response type and no special handling should be applied beyond what's handled by the PERMANENT FAILURE response. 220 | 221 | See `RESPONSE_STATUS_PERM_FAILURE` for additional details. 222 | 223 | #### RESPONSE_STATUSDETAIL_PERM_FAILURE_NOT_FOUND = "51" 224 | This is a detailed status message for response type 5x (PERMANENT FAILURE). 225 | 226 | As per the Gemini specification, the resource was not found. 227 | 228 | See `RESPONSE_STATUS_PERM_FAILURE` for additional details. 229 | 230 | #### RESPONSE_STATUSDETAIL_PERM_FAILURE_GONE = "52" 231 | This is a detailed status message for response type 5x (PERMANENT FAILURE). 232 | 233 | As per the Gemini specification, the resources was permanently removed. 234 | 235 | See `RESPONSE_STATUS_PERM_FAILURE` for additional details. 236 | 237 | #### RESPONSE_STATUSDETAIL_PERM_FAILURE_PROXY_REFUSED = "53" 238 | This is a detailed status message for response type 5x (PERMANENT FAILURE). 239 | 240 | As per the Gemini specification, the requested domain is not served by this server and the server does not accept proxy requests. 241 | 242 | See `RESPONSE_STATUS_PERM_FAILURE` for additional details. 243 | 244 | #### RESPONSE_STATUSDETAIL_PERM_FAILURE_BAD_REQUEST = "59" 245 | This is a detailed status message for response type 5x (PERMANENT FAILURE). 246 | 247 | As per the Gemini specification, the server could not process the client's request. Please fix and try again. 248 | 249 | See `RESPONSE_STATUS_PERM_FAILURE` for additional details. 250 | 251 | #### RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED = "60" 252 | This is a detailed status message for response type 6x (CLIENT CERTIFICATE REQUIRED). 253 | 254 | As per the Gemini specification, this is the default response type and no special handling should be applied beyond what's handled by the CLIENT CERTIFICATE REQUIRED response. 255 | 256 | See `RESPONSE_STATUS_CLIENTCERT_REQUIRED` for additional details. 257 | 258 | #### RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_AUTHORIZED = "61" 259 | This is a detailed status message for response type 6x (CLIENT CERTIFICATE REQUIRED). 260 | 261 | As per the Gemini specification, the supplied client certificate is not authorized. 262 | 263 | See `RESPONSE_STATUS_CLIENTCERT_REQUIRED` for additional details. 264 | 265 | #### RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_VALID = "62" 266 | This is a detailed status message for response type 6x (CLIENT CERTIFICATE REQUIRED). 267 | 268 | As per the Gemini specification, the supplied client certificate is not valid. 269 | 270 | See `RESPONSE_STATUS_CLIENTCERT_REQUIRED` for additional details. 271 | 272 | #### RESPONSE_STATUSDETAIL_ERROR_NETWORK = "00" 273 | This is a detailed status message for response type 0x (ERROR). 274 | 275 | This is a custom error type outside of the scope of the Gemini protocol. 00 errors represent any errors that occur at the network level, and prevented the client from making any connection with external services. See the message-level details in the `response.data()` to get additional information. 276 | 277 | #### RESPONSE_STATUSDETAIL_ERROR_DNS = "01" 278 | This is a detailed status message for response type 0x (ERROR). 279 | 280 | This is a custom error type outside of the scope of the Gemini protocol. 01 errors represent any errors at the DNS level. See the message-level details in the `response.data()` to get additional information. 281 | 282 | #### RESPONSE_STATUSDETAIL_ERROR_HOST = "02" 283 | This is a detailed status message for response type 0x (ERROR). 284 | 285 | This is a custom error type outside of the scope of the Gemini protocol. 02 errors represent any errors connecting to the host (timeout, refused, etc.). See the message-level details in the `response.data()` to get additional information. 286 | 287 | #### RESPONSE_STATUSDETAIL_ERROR_TLS = "03" 288 | This is a detailed status message for response type 0x (ERROR). 289 | 290 | This is a custom error type outside of the scope of the Gemini protocol. 03 errors represent any errors associated with TLS/SSL, including handshake errors, certificate expired errors, and security errors like certificate rejection errors. See the message-level details in the `response.data()` to get additional information. 291 | 292 | Note that ignition now only supports services with TLS versions >= 1.2. 293 | 294 | #### RESPONSE_STATUSDETAIL_ERROR_PROTOCOL = "04" 295 | This is a detailed status message for response type 0x (ERROR). 296 | 297 | This is a custom error type outside of the scope of the Gemini protocol. 04 errors represent any errors where a secure message is received from the server, but it does not conform to the Gemini protocol requirements and cannot be processed. See the message-level details in the `response.data()` to get additional information. 298 | 299 | --- 300 | 301 | ## ignition.BaseResponse 302 | *Source Code: [src/response.py](../src/response.py)* 303 | 304 | This class cannot be instantiated directly. 305 | 306 | ### Subclasses 307 | 308 | [InputResponse](#ignitioninputresponse), [SuccessResponse](#ignitionsuccessresponse), [RedirectResponse](#ignitionredirectresponse), [TempFailureResponse](#ignitiontempfailureresponse), [PermFailureResponse](#ignitionpermfailureresponse), [ClientCertRequiredResponse](#ignitionclientcertrequiredresponse), [ErrorResponse](#ignitionerrorresponse) 309 | 310 | ### Members 311 | 312 | #### basic_status 313 | *type: `string` (length: 1 character)* 314 | 315 | `basic_status` returns the raw one-character status response. It will be one of the following values: 316 | 317 | * `ignition.RESPONSE_STATUS_INPUT` = "1" 318 | * `ignition.RESPONSE_STATUS_SUCCESS` = "2" 319 | * `ignition.RESPONSE_STATUS_REDIRECT` = "3" 320 | * `ignition.RESPONSE_STATUS_TEMP_FAILURE` = "4" 321 | * `ignition.RESPONSE_STATUS_PERM_FAILURE` = "5" 322 | * `ignition.RESPONSE_STATUS_CLIENTCERT_REQUIRED` = "6" 323 | * `ignition.RESPONSE_STATUS_ERROR` = "0" 324 | 325 | It is not recommended to use this value, and instead leverage the `BaseResponse.is_a()` to check the response type; however, this option has been made available if preferred. 326 | 327 | #### status 328 | *type: `string` (length: 2 characters)* 329 | 330 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. The response will be one of the following values: 331 | 332 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_NETWORK` = "00" 333 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_DNS` = "01" 334 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_HOST` = "02" 335 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_TLS` = "03" 336 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_PROTOCOL` = "04" 337 | * `ignition.RESPONSE_STATUSDETAIL_INPUT` = "10" 338 | * `ignition.RESPONSE_STATUSDETAIL_INPUT_SENSITIVE` = "11" 339 | * `ignition.RESPONSE_STATUSDETAIL_SUCCESS` = "20" 340 | * `ignition.RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY` = "30" 341 | * `ignition.RESPONSE_STATUSDETAIL_REDIRECT_PERMANENT` = "31" 342 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE` = "40" 343 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_UNAVAILABLE` = "41" 344 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_CGI` = "42" 345 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_PROXY` = "43" 346 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_SLOW_DOWN` = "44" 347 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE` = "50" 348 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_NOT_FOUND` = "51" 349 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_GONE` = "52" 350 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_PROXY_REFUSED` = "53" 351 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_BAD_REQUEST` = "59" 352 | * `ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED` = "60" 353 | * `ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_AUTHORIZED` = "61" 354 | * `ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_VALID` = "62" 355 | 356 | This member SHOULD be used to facilitate status-specific behavior by a client. 357 | 358 | #### meta 359 | *type: `string` (length <= 1024 characters)* 360 | 361 | `meta` is the raw meta field returned in the response header from the Gemini server. In general, it is NOT RECOMMENDED to use this in favor of `BaseResponse.data()` method to retrieve the meta field for non-20 responses, but this can be used if preferred. 362 | 363 | #### raw_body 364 | *type: `bytes`* 365 | 366 | `raw_body` is the raw bytestring stored in the response, before encoding. This response SHOULD only be used for debug purposes, but it is typically preferred to use `BaseResponse.data()` for success messages as this will decode the response body according to the charset provided in the response META. 367 | 368 | #### url 369 | *type: `string`* 370 | 371 | Returns the fully qualified URL of the request after processing. This value MAY be passed to subsequent responses in the `referer` field in order to help to construct fully qualified URLs on the request. 372 | 373 | #### certificate 374 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)* 375 | 376 | Returns the remote server certificate on a successful response. If the type is [ignition.ErrorResponse](#ignitionerrorresponse), this will return `None`. 377 | 378 | ### Methods 379 | 380 | #### data() -> string 381 | Returns the user-facing data for each method. This is method is unique for each response type. 382 | 383 | * [ignition.ErrorResponse](#ignitionerrorresponse) 384 | * [ignition.InputResponse](#ignitioninputresponse) 385 | * [ignition.SuccessResponse](#ignitionsuccessresponse) 386 | * [ignition.RedirectResponse](#ignitionredirectresponse) 387 | * [ignition.TempFailureResponse](#ignitiontempfailureresponse) 388 | * [ignition.PermFailureResponse](#ignitionpermfailureresponse) 389 | * [ignition.ClientCertRequiredResponse](#ignitionclientcertrequiredresponse) 390 | 391 | Returns: `string` 392 | 393 | #### success() -> boolean 394 | Utility method to check if the response was successful, defined by a status response of `20`. 395 | 396 | This is useful if you just need to confirm that the message contains a body or not. 397 | 398 | Returns: `boolean` 399 | 400 | #### is_a(response_class_type: class) -> boolean 401 | Utility method to qualify the response type. The response type from a request will be one of: ignition.ErrorResponse, ignition.InputResponse, ignition.SuccessResponse, ignition.RedirectResponse, ignition.TempFailureResponse, ignition.TempFailureResponse, ignition.PermFailureResponse, ignition.ClientCertRequiredResponse. This will return `true` if the response type matches the current object type. 402 | 403 | This is recommended for use when routing behavior based on a response. 404 | 405 | *Note: This only qualifies the `basic_status` of a response. For any additional behavior it is required to check the `BaseResponse.status` for detailed status.* 406 | 407 | Parameters: 408 | * response_class_type: `ignition.BaseResponse` class 409 | 410 | Returns: `boolean` 411 | 412 | 413 | --- 414 | 415 | 416 | ## ignition.InputResponse 417 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 418 | 419 | *Source Code: [src/response.py](../src/response.py)* 420 | 421 | This class cannot be instantiated directly. 422 | 423 | ### Members 424 | 425 | #### basic_status 426 | *type: `string` (length: 1 character)* 427 | 428 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 429 | 430 | `basic_status` returns the raw one-character status response. In the case of the `InputResponse`, this value will always be: 431 | 432 | * `ignition.RESPONSE_STATUS_INPUT` = "1" 433 | 434 | It is not recommended to use this value, and instead leverage the `InputResponse.is_a()` to check the response type; however, this option has been made available if preferred. 435 | 436 | #### status 437 | *type: `string` (length: 2 characters)* 438 | 439 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 440 | 441 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `InputResponse`, this value will always be one of: 442 | 443 | * `ignition.RESPONSE_STATUSDETAIL_INPUT` = "10" 444 | * `ignition.RESPONSE_STATUSDETAIL_INPUT_SENSITIVE` = "11" 445 | 446 | This member SHOULD be used to facilitate status-specific behavior by a client. 447 | 448 | #### meta 449 | *type: `string` (length <= 1024 characters)* 450 | 451 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 452 | 453 | #### raw_body 454 | *type: `bytes`* 455 | 456 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 457 | 458 | #### url 459 | *type: `string`* 460 | 461 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 462 | 463 | #### certificate 464 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)*. 465 | 466 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 467 | 468 | ### Methods 469 | 470 | #### data() -> string 471 | This returns the prompt to display to the user in order to request the textual user input, which was provided as part of the META response to the user in the header. 472 | 473 | Returns: `string` 474 | 475 | #### success() -> boolean 476 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 477 | 478 | Returns: `boolean` 479 | 480 | #### is_a(response_class_type: class) -> boolean 481 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 482 | 483 | Returns: `boolean` 484 | 485 | 486 | --- 487 | 488 | 489 | ## ignition.SuccessResponse 490 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 491 | 492 | *Source Code: [src/response.py](../src/response.py)* 493 | 494 | This class cannot be instantiated directly. 495 | 496 | ### Members 497 | 498 | #### basic_status 499 | *type: `string` (length: 1 character)* 500 | 501 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 502 | 503 | `basic_status` returns the raw one-character status response. In the case of the `SuccessResponse`, this value will always be: 504 | 505 | * `ignition.RESPONSE_STATUS_SUCCESS` = "2" 506 | 507 | It is not recommended to use this value, and instead leverage the `SuccessResponse.is_a()` to check the response type; however, this option has been made available if preferred. 508 | 509 | #### status 510 | *type: `string` (length: 2 characters)* 511 | 512 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 513 | 514 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `SuccessResponse`, this value will always be: 515 | 516 | * `ignition.RESPONSE_STATUSDETAIL_SUCCESS` = "20" 517 | 518 | This member SHOULD be used to facilitate status-specific behavior by a client. 519 | 520 | #### meta 521 | *type: `string` (length <= 1024 characters)* 522 | 523 | In the context of a success response, this will include additional information including Mime Type and encoding. This is useful for a SuccessResponse. 524 | 525 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 526 | 527 | #### raw_body 528 | *type: `bytes`* 529 | 530 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 531 | 532 | #### url 533 | *type: `string`* 534 | 535 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 536 | 537 | #### certificate 538 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)*. 539 | 540 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 541 | 542 | ### Methods 543 | 544 | #### data() -> string 545 | Returns the full request body in the Success response, encoded according to the META mime type. Note: this function does not do any additional formatting of the response payload beyond mapping the encoding. 546 | 547 | Returns: `string` 548 | 549 | #### success() -> boolean 550 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 551 | 552 | Returns: `boolean` 553 | 554 | #### is_a(response_class_type: class) -> boolean 555 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 556 | 557 | Returns: `boolean` 558 | 559 | 560 | --- 561 | 562 | 563 | ## ignition.RedirectResponse 564 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 565 | 566 | *Source Code: [src/response.py](../src/response.py)* 567 | 568 | This class cannot be instantiated directly. 569 | 570 | ### Members 571 | 572 | #### basic_status 573 | *type: `string` (length: 1 character)* 574 | 575 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 576 | 577 | `basic_status` returns the raw one-character status response. In the case of the `RedirectResponse`, this value will always be: 578 | 579 | * `ignition.RESPONSE_STATUS_REDIRECT` = "3" 580 | 581 | It is not recommended to use this value, and instead leverage the `RedirectResponse.is_a()` to check the response type; however, this option has been made available if preferred. 582 | 583 | #### status 584 | *type: `string` (length: 2 characters)* 585 | 586 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 587 | 588 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `RedirectResponse`, this value will always be one of: 589 | 590 | * `ignition.RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY` = "30" 591 | * `ignition.RESPONSE_STATUSDETAIL_REDIRECT_PERMANENT` = "31" 592 | 593 | This member SHOULD be used to facilitate status-specific behavior by a client. 594 | 595 | #### meta 596 | *type: `string` (length <= 1024 characters)* 597 | 598 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 599 | 600 | #### raw_body 601 | *type: `bytes`* 602 | 603 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 604 | 605 | #### url 606 | *type: `string`* 607 | 608 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 609 | 610 | #### certificate 611 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)*. 612 | 613 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 614 | 615 | ### Methods 616 | 617 | #### data() -> string 618 | Returns the new url location for the requested response based on the META value for the Redirect response. 619 | 620 | Returns: `string` 621 | 622 | #### success() -> boolean 623 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 624 | 625 | Returns: `boolean` 626 | 627 | #### is_a(response_class_type: class) -> boolean 628 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 629 | 630 | Returns: `boolean` 631 | 632 | 633 | --- 634 | 635 | 636 | ## ignition.TempFailureResponse 637 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 638 | 639 | *Source Code: [src/response.py](../src/response.py)* 640 | 641 | This class cannot be instantiated directly. 642 | 643 | ### Members 644 | 645 | #### basic_status 646 | *type: `string` (length: 1 character)* 647 | 648 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 649 | 650 | `basic_status` returns the raw one-character status response. In the case of the `TempFailureResponse`, this value will always be: 651 | 652 | * `ignition.RESPONSE_STATUS_TEMP_FAILURE` = "4" 653 | 654 | It is not recommended to use this value, and instead leverage the `TempFailureResponse.is_a()` to check the response type; however, this option has been made available if preferred. 655 | 656 | #### status 657 | *type: `string` (length: 2 characters)* 658 | 659 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 660 | 661 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `TempFailureResponse`, this value will always be one of: 662 | 663 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE` = "40" 664 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_UNAVAILABLE` = "41" 665 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_CGI` = "42" 666 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_PROXY` = "43" 667 | * `ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_SLOW_DOWN` = "44" 668 | 669 | This member SHOULD be used to facilitate status-specific behavior by a client. 670 | 671 | #### meta 672 | *type: `string` (length <= 1024 characters)* 673 | 674 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 675 | 676 | #### raw_body 677 | *type: `bytes`* 678 | 679 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 680 | 681 | #### url 682 | *type: `string`* 683 | 684 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 685 | 686 | #### certificate 687 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)*. 688 | 689 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 690 | 691 | ### Methods 692 | 693 | #### data() -> string 694 | Returns the contents of the META field to provide additional information regarding the failure to the user. 695 | 696 | Returns: `string` 697 | 698 | #### success() -> boolean 699 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 700 | 701 | Returns: `boolean` 702 | 703 | #### is_a(response_class_type: class) -> boolean 704 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 705 | 706 | Returns: `boolean` 707 | 708 | 709 | --- 710 | 711 | 712 | ## ignition.PermFailureResponse 713 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 714 | 715 | *Source Code: [src/response.py](../src/response.py)* 716 | 717 | This class cannot be instantiated directly. 718 | 719 | ### Members 720 | 721 | #### basic_status 722 | *type: `string` (length: 1 character)* 723 | 724 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 725 | 726 | `basic_status` returns the raw one-character status response. In the case of the `PermFailureResponse`, this value will always be: 727 | 728 | * `ignition.RESPONSE_STATUS_PERM_FAILURE` = "5" 729 | 730 | It is not recommended to use this value, and instead leverage the `PermFailureResponse.is_a()` to check the response type; however, this option has been made available if preferred. 731 | 732 | #### status 733 | *type: `string` (length: 2 characters)* 734 | 735 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 736 | 737 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `PermFailureResponse`, this value will always be one of: 738 | 739 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE` = "50" 740 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_NOT_FOUND` = "51" 741 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_GONE` = "52" 742 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_PROXY_REFUSED` = "53" 743 | * `ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_BAD_REQUEST` = "59" 744 | 745 | This member SHOULD be used to facilitate status-specific behavior by a client. 746 | 747 | #### meta 748 | *type: `string` (length <= 1024 characters)* 749 | 750 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 751 | 752 | #### raw_body 753 | *type: `bytes`* 754 | 755 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 756 | 757 | #### url 758 | *type: `string`* 759 | 760 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 761 | 762 | #### certificate 763 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)*. 764 | 765 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 766 | 767 | ### Methods 768 | 769 | #### data() -> string 770 | Returns the contents of the META field to provide additional information regarding the failure to the user. 771 | 772 | Returns: `string` 773 | 774 | #### success() -> boolean 775 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 776 | 777 | Returns: `boolean` 778 | 779 | #### is_a(response_class_type: class) -> boolean 780 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 781 | 782 | Returns: `boolean` 783 | 784 | 785 | --- 786 | 787 | 788 | ## ignition.ClientCertRequiredResponse 789 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 790 | 791 | *Source Code: [src/response.py](../src/response.py)* 792 | 793 | This class cannot be instantiated directly. 794 | 795 | ### Members 796 | 797 | #### basic_status 798 | *type: `string` (length: 1 character)* 799 | 800 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 801 | 802 | `basic_status` returns the raw one-character status response. In the case of the `ClientCertRequiredResponse`, this value will always be: 803 | 804 | * `ignition.RESPONSE_STATUS_CLIENTCERT_REQUIRED` = "6" 805 | 806 | It is not recommended to use this value, and instead leverage the `ClientCertRequiredResponse.is_a()` to check the response type; however, this option has been made available if preferred. 807 | 808 | #### status 809 | *type: `string` (length: 2 characters)* 810 | 811 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 812 | 813 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `ClientCertRequiredResponse`, this value will always be one of: 814 | 815 | * `ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED` = "60" 816 | * `ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_AUTHORIZED` = "61" 817 | * `ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_VALID` = "62" 818 | 819 | This member SHOULD be used to facilitate status-specific behavior by a client. 820 | 821 | #### meta 822 | *type: `string` (length <= 1024 characters)* 823 | 824 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 825 | 826 | #### raw_body 827 | *type: `bytes`* 828 | 829 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 830 | 831 | #### url 832 | *type: `string`* 833 | 834 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 835 | 836 | #### certificate 837 | *type: [`cryptography.x509.Certificate`](https://cryptography.io/en/latest/x509/reference.html#x-509-certificate-object)*. 838 | 839 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 840 | 841 | ### Methods 842 | 843 | #### data() -> string 844 | Returns the additional information on certificate requirements, or the reason that the certificate was rejected. These are the contents of the META field for a Client Certificate Required response. 845 | 846 | Returns: `string` 847 | 848 | #### success() -> boolean 849 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 850 | 851 | Returns: `boolean` 852 | 853 | #### is_a(response_class_type: class) -> boolean 854 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 855 | 856 | Returns: `boolean` 857 | 858 | 859 | --- 860 | 861 | 862 | ## ignition.ErrorResponse 863 | **Extends [ignition.BaseResponse](#ignitionbaseresponse)** 864 | 865 | *Source Code: [src/response.py](../src/response.py)* 866 | 867 | This class cannot be instantiated directly. 868 | 869 | ### Members 870 | 871 | #### basic_status 872 | *type: `string` (length: 1 character)* 873 | 874 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 875 | 876 | `basic_status` returns the raw one-character status response. In the case of the `ErrorResponse`, this value will always be: 877 | 878 | * `ignition.RESPONSE_STATUS_ERROR` = "0" 879 | 880 | It is not recommended to use this value, and instead leverage the `ErrorResponse.is_a()` to check the response type; however, this option has been made available if preferred. 881 | 882 | #### status 883 | *type: `string` (length: 2 characters)* 884 | 885 | Extended from [ignition.BaseResponse](#ignitionbaseresponse) 886 | 887 | `status` returns the raw detailed two-character status response. This is useful when providing specific behavior depending on the response. In the case of the `ErrorResponse`, this value will always be one of: 888 | 889 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_NETWORK` = "00" 890 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_DNS` = "01" 891 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_HOST` = "02" 892 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_TLS` = "03" 893 | * `ignition.RESPONSE_STATUSDETAIL_ERROR_PROTOCOL` = "04" 894 | 895 | This member SHOULD be used to facilitate status-specific behavior by a client. 896 | 897 | #### meta 898 | *type: `string` (length <= 1024 characters)* 899 | 900 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 901 | 902 | #### raw_body 903 | *type: `bytes`* 904 | 905 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 906 | 907 | #### url 908 | *type: `string`* 909 | 910 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 911 | 912 | #### certificate 913 | *type: `None`*. 914 | 915 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 916 | 917 | ### Methods 918 | 919 | #### data() -> string 920 | Provides an error message related to the Error failure type as defined by ignition. See failure constants as defined in the `status` field for full details on each error type. 921 | 922 | Returns: `string` 923 | 924 | #### success() -> boolean 925 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 926 | 927 | Returns: `boolean` 928 | 929 | #### is_a(response_class_type: class) -> boolean 930 | Extended from [ignition.BaseResponse](#ignitionbaseresponse). See parent class for full details. 931 | 932 | Returns: `boolean` 933 | -------------------------------------------------------------------------------- /docs/build.md: -------------------------------------------------------------------------------- 1 | # Building ignition 2 | 3 | We have upgraded to pyproject.toml build configuration. 4 | 5 | Assuming you have already installed the virtual environment in the [developer steps](developer.md), all you need to do to build the package is to install the build dependencies: 6 | 7 | ```bash 8 | $ python -m pip install .[build] 9 | ``` 10 | 11 | And then build with the native `build` package: 12 | ```bash 13 | $ python -m build 14 | ``` 15 | 16 | Push to test pip: 17 | ```bash 18 | $ python -m twine upload --repository testpypi dist/* 19 | ``` 20 | 21 | Push to real pip: 22 | ```bash 23 | $ python -m twine upload dist/* 24 | ``` 25 | 26 | ## References 27 | 28 | * [Packaging Python Projects](https://packaging.python.org/tutorials/packaging-projects/) 29 | * [PyPi](https://pypi.org/) 30 | * [Test PyPi](test.pypi.org/) 31 | -------------------------------------------------------------------------------- /docs/developer.md: -------------------------------------------------------------------------------- 1 | # Developing for Ignition 2 | 3 | This section is a legacy document preserved for software developers who are interested in forking this project. 4 | 5 | ## Contributing 6 | 7 | Ignition is no longer accepting contributions. Please consider forking this library. 8 | 9 | ## Developing 10 | 11 | ### Local Environment Setup 12 | 13 | I recommend using `pyenv` and `virtualenv` locally to manage your python environment. Once you have [pyenv](https://github.com/pyenv/pyenv) installed (with [shell extensions](https://github.com/pyenv/pyenv#set-up-your-shell-environment-for-pyenv)), you can `cd` into the `ignition` directory you should be able to run: 14 | ```bash 15 | $ python --version 16 | Python 3.10.4 17 | ``` 18 | 19 | Run the following command to setup a local python3 virtual environment on first run: 20 | ```bash 21 | $ python -m venv venv 22 | created virtual environment ... 23 | ``` 24 | 25 | When starting development, initialize the virtual environment with: 26 | ``` 27 | $ . venv/bin/activate 28 | ``` 29 | 30 | Once your environment is running, you can install requirements: 31 | ``` 32 | $ pip install .[dev] 33 | ``` 34 | 35 | ### Code Formatting Style 36 | We are using a custom linting style enforced by `ruff` and `black`. In order to make your life easier, I've 37 | included pre-commit hooks that you can install once you've downloaded and installed requirements. 38 | 39 | ``` 40 | $ pre-commit install 41 | ``` 42 | 43 | Code style will be updated on commit. 44 | 45 | ### Unit Testing 46 | Unit testing is build through `pytest`. Unit test can be run by: 47 | ``` 48 | $ python -m pytest 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/img/transcript-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/docs/img/transcript-1.png -------------------------------------------------------------------------------- /docs/img/transcript-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/docs/img/transcript-2.png -------------------------------------------------------------------------------- /docs/img/transcript-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/docs/img/transcript-3.png -------------------------------------------------------------------------------- /docs/img/transcript-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/docs/img/transcript-4.png -------------------------------------------------------------------------------- /examples/advanced_usage.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import ignition 9 | 10 | url = "//geminiprotocol.net/" 11 | response = ignition.request(url) 12 | 13 | if response.is_a(ignition.SuccessResponse): 14 | print("Success!") 15 | print(response.data()) 16 | 17 | elif response.is_a(ignition.InputResponse): 18 | print(f"Needs additional input: {response.data()}") 19 | 20 | elif response.is_a(ignition.RedirectResponse): 21 | print(f"Received response, redirect to: {response.data()}") 22 | 23 | elif response.is_a(ignition.TempFailureResponse): 24 | print(f"Error from server: {response.data()}") 25 | 26 | elif response.is_a(ignition.PermFailureResponse): 27 | print(f"Error from server: {response.data()}") 28 | 29 | elif response.is_a(ignition.ClientCertRequiredResponse): 30 | print(f"Client certificate required. {response.data()}") 31 | 32 | elif response.is_a(ignition.ErrorResponse): 33 | print(f"There was an error on the request: {response.data()}") 34 | -------------------------------------------------------------------------------- /examples/simple_usage.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import ignition 9 | 10 | # Fetch capsule content 11 | response = ignition.request("//geminiprotocol.net/") 12 | 13 | # Get status from remote capsule 14 | print(response.status) 15 | 16 | # Get response information from remote capsule 17 | print(response.data()) 18 | -------------------------------------------------------------------------------- /examples/using_referer.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import ignition 9 | 10 | response1 = ignition.request("//geminiprotocol.net/") 11 | response2 = ignition.request("software/", referer=response1.url) 12 | 13 | print(response2) 14 | -------------------------------------------------------------------------------- /ignition/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | from .globals import * 9 | from .request import Request 10 | from .response import ( 11 | ClientCertRequiredResponse, 12 | ErrorResponse, 13 | InputResponse, 14 | PermFailureResponse, 15 | RedirectResponse, 16 | SuccessResponse, 17 | TempFailureResponse, 18 | ) 19 | from .ssl.cert_store import CertStore 20 | from .util import TimeoutManager 21 | 22 | __version__ = "1.0.0" 23 | 24 | __timeout = TimeoutManager(DEFAULT_REQUEST_TIMEOUT) 25 | __cert_store = CertStore(DEFAULT_HOSTS_FILE) 26 | 27 | 28 | def set_default_hosts_file(hosts_file): 29 | """ 30 | Set the default host file location where all of the certificate fingerprints 31 | are stored in order to support Trust-On-First-Use (TOFU) validation. 32 | By default, this file is stored in the root directory as your project in a 33 | file named `.known_hosts`. This can be updated to any readable location but 34 | should be stored somewhere persistent for security purposes. 35 | 36 | The format of this file is very similar to (but not identical to) 37 | the SSH `known_hosts` file. 38 | 39 | Parameters: 40 | * hosts_file: `string` 41 | 42 | """ 43 | __cert_store.set_hosts_file(hosts_file) 44 | 45 | 46 | def set_default_timeout(timeout): 47 | """ 48 | Set the default timeout (in seconds) for all requests made via ignition. 49 | The default timeout is 30 seconds. 50 | 51 | Parameters: 52 | * timeout: `float` 53 | """ 54 | __timeout.set_default_timeout(timeout) 55 | 56 | 57 | def url(request_url, referer=None): 58 | """ 59 | Given a *url* to a Gemini capsule, this returns a standardized, 60 | fully-qualified url to the Gemini capsule. If a *referer* is 61 | provided, a dynamic URL is constructed by ignition to send a request 62 | to. This logic follows URL definition behavior outlined in 63 | [RFC-3986](https://tools.ietf.org/html/rfc3986). 64 | 65 | This allows for the bulk of URL generation logic to be handled 66 | without ignition as opposed to within the business logic of the client. 67 | Here are some sample use cases: 68 | 69 | *Use Case 1: Automatically populate URL protocol* 70 | ```python 71 | ignition.url('//geminiprotocol.net') # => gemini://geminiprotocol.net 72 | ``` 73 | 74 | *Use Case 2: Navigate to an absolute path* 75 | ```python 76 | ignition.url('/home', 'gemini://geminiprotocol.net') # => gemini://geminiprotocol.net/home 77 | ``` 78 | 79 | *Use Case 3: Navigate to a relative path* 80 | ```python 81 | ignition.url('2', 'gemini://geminiprotocol.net/home') # => gemini://geminiprotocol.net/home/2 82 | ``` 83 | 84 | *Use Case 4: Resolve paths with navigation* 85 | ```python 86 | ignition.url('../fun/', 'gemini://geminiprotocol.net/home/work/') # => gemini://geminiprotocol.net/home/fun/ 87 | ``` 88 | 89 | *Note:* if the user's intent is to generate a url to a Gemini capsule and then make a request, 90 | ignition recommends that you just provide the *url* and *referer* to `ignition.request()`, as 91 | that function encapsulates all of the logic within this method when making a request. If you 92 | want to retrieve a URL from an already processed request, it is recommended to use 93 | `ignition.BaseResponse.url`, as that will store the URL that was actually used. This method 94 | is only intended for use in constructing a URL but not generating a request. 95 | 96 | Parameters: 97 | * url: `string` 98 | * referer: `string` (optional) 99 | 100 | Returns: `string` 101 | """ 102 | dummy_req = Request(request_url, referer=referer) 103 | return dummy_req.get_url() 104 | 105 | 106 | def request(request_url, referer=None, timeout=None, raise_errors=False, ca_cert=None): 107 | """ 108 | Given a *url* to a Gemini capsule, this performs a request to the specified 109 | url and returns a response (as a subclass of [ignition.BaseResponse](#ignitionbaseresponse)) 110 | with the details associated to the response. This is the interface that most 111 | users should use. 112 | 113 | If a *referer* is provided, a dynamic URL is constructed by ignition to send a 114 | request to. (*referer* expectes a fully qualified url as returned by 115 | `ignition.BaseResponse.url` or (less prefered) `ignition.url()`). 116 | Typically, in order to simplify the browsing experience, you should pass 117 | the previously requested URL as the referer to simplify URL construction logic. 118 | 119 | *See `ignition.url()` for details around url construction with a referer.* 120 | 121 | If *raise_errors* is `True` (default value = `False`), then non-protocol errors 122 | will bubble up and be raised as an exception instead of returning 123 | [ignition.ErrorResponse](#ignitionerrorresponse) 124 | 125 | If a *timeout* is provided, this will specify the client timeout (in seconds) 126 | for this request. The default is 30 seconds. See also `ignition.set_default_timeout` 127 | to change the default timeout. 128 | 129 | If a *ca_cert* is provided, the certificate will be sent to the server as a CA CERT. 130 | You will need to provide the paths to both the certificate and the key in this case. 131 | 132 | Depending on the response from the server, as per Gemini specification, the 133 | corresponding response type will be returned. 134 | 135 | * If the response status begins with "1", the response type is `INPUT`, 136 | and will return a response of type [ignition.InputResponse](#ignitioninputresponse). 137 | * If the response status begins with "2", the response type is `STATUS`, 138 | and will return a response of type [ignition.SuccessResponse](#ignitionsuccessresponse). 139 | * If the response status begins with "3", the response type is `REDIRECT`, 140 | and will return a response of type [ignition.RedirectResponse](#ignitionredirectresponse). 141 | * If the response status begins with "4", the response type is `TEMPORARY FAILURE`, 142 | and will return a response of type [ignition.TempFailureResponse](#ignitiontempfailureresponse). 143 | * If the response status begins with "5", the response type is `PERMANENT FAILURE`, 144 | and will return a response of type [ignition.PermFailureResponse](#ignitionpermfailureresponse). 145 | * If the response status begins with "6", the response type is `CLIENT CERTIFICATE REQUIRED`, 146 | and will return a response of type [ignition.ClientCertRequiredResponse](#ignitionclientcertrequiredresponse). 147 | * If *no valid response* can be returned, ignition assigns a response type of "0" 148 | and returns a response of type [ignition.ErrorResponse](#ignitionerrorresponse). 149 | Note: if the user specifies `raise_errors=True`, then any errors will bubble up instead of returning 150 | this response type. 151 | 152 | Parameters: 153 | * url: `string` 154 | * referer: `string` (optional) 155 | * timeout: `float` (optional) 156 | * raise_errors: `bool` (optional) 157 | * ca_cert: `Tuple(cert_file, key_file)` (optional) 158 | 159 | Returns: `[ignition.BaseResponse](#ignitionbaseresponse)` 160 | """ 161 | 162 | req = Request( 163 | request_url, 164 | cert_store=__cert_store, 165 | request_timeout=__timeout.get_timeout(timeout), 166 | referer=referer, 167 | ca_cert=ca_cert, 168 | raise_errors=raise_errors, 169 | ) 170 | 171 | return req.send() 172 | 173 | 174 | __all__ = [ 175 | "set_default_hosts_file", 176 | "set_default_timeout", 177 | "url", 178 | "request", 179 | "ClientCertRequiredResponse", 180 | "ErrorResponse", 181 | "InputResponse", 182 | "PermFailureResponse", 183 | "RedirectResponse", 184 | "SuccessResponse", 185 | "TempFailureResponse", 186 | "RESPONSE_STATUS_ERROR", 187 | "RESPONSE_STATUS_INPUT", 188 | "RESPONSE_STATUS_SUCCESS", 189 | "RESPONSE_STATUS_REDIRECT", 190 | "RESPONSE_STATUS_TEMP_FAILURE", 191 | "RESPONSE_STATUS_PERM_FAILURE", 192 | "RESPONSE_STATUS_CLIENTCERT_REQUIRED", 193 | "RESPONSE_STATUSDETAIL_ERROR_NETWORK", 194 | "RESPONSE_STATUSDETAIL_ERROR_DNS", 195 | "RESPONSE_STATUSDETAIL_ERROR_HOST", 196 | "RESPONSE_STATUSDETAIL_ERROR_TLS", 197 | "RESPONSE_STATUSDETAIL_ERROR_PROTOCOL", 198 | "RESPONSE_STATUSDETAIL_INPUT", 199 | "RESPONSE_STATUSDETAIL_INPUT_SENSITIVE", 200 | "RESPONSE_STATUSDETAIL_SUCCESS", 201 | "RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY", 202 | "RESPONSE_STATUSDETAIL_REDIRECT_PERMANENT", 203 | "RESPONSE_STATUSDETAIL_TEMP_FAILURE", 204 | "RESPONSE_STATUSDETAIL_TEMP_FAILURE_UNAVAILABLE", 205 | "RESPONSE_STATUSDETAIL_TEMP_FAILURE_CGI", 206 | "RESPONSE_STATUSDETAIL_TEMP_FAILURE_PROXY", 207 | "RESPONSE_STATUSDETAIL_TEMP_FAILURE_SLOW_DOWN", 208 | "RESPONSE_STATUSDETAIL_PERM_FAILURE", 209 | "RESPONSE_STATUSDETAIL_PERM_FAILURE_NOT_FOUND", 210 | "RESPONSE_STATUSDETAIL_PERM_FAILURE_GONE", 211 | "RESPONSE_STATUSDETAIL_PERM_FAILURE_PROXY_REFUSED", 212 | "RESPONSE_STATUSDETAIL_PERM_FAILURE_BAD_REQUEST", 213 | "RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED", 214 | "RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_AUTHORIZED", 215 | "RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_VALID", 216 | ] 217 | -------------------------------------------------------------------------------- /ignition/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | from . import __version__ 9 | 10 | if __name__ == "__main__": 11 | print(__version__) 12 | -------------------------------------------------------------------------------- /ignition/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | 9 | class RemoteCertificateExpired(Exception): 10 | """ 11 | An exception type to handle expired certificates from the remote server. 12 | This should throw if the remote certificate expiration date 13 | """ 14 | 15 | 16 | class TofuCertificateRejection(Exception): 17 | """ 18 | An exception type handle TOFU (trust-on-first-use rejection). 19 | """ 20 | 21 | 22 | class CertRecordParseException(Exception): 23 | """ 24 | An exception triggered on cert record parsing. 25 | """ 26 | 27 | 28 | class GeminiResponseParseError(Exception): 29 | """ 30 | Raised when the gemini protocol data response cannot be parsed. 31 | """ 32 | -------------------------------------------------------------------------------- /ignition/globals.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | CRLF = "\r\n" 9 | EOL = "\n" 10 | 11 | # Gemini-Protocol Mechanical Constants 12 | GEMINI_SCHEME = "gemini" 13 | GEMINI_PORT = 1965 14 | GEMINI_DEFAULT_MIME_TYPE = "text/gemini; charset=utf-8" 15 | GEMINI_DEFAULT_ENCODING = "utf-8" 16 | GEMINI_RESPONSE_HEADER_SEPARATOR = "\\s+" 17 | GEMINI_URL_MAXLENGTH = 1024 18 | GEMINI_RESPONSE_HEADER_META_MAXLENGTH = 1024 19 | 20 | # One-character response codes 21 | RESPONSE_STATUS_ERROR = "0" 22 | RESPONSE_STATUS_INPUT = "1" 23 | RESPONSE_STATUS_SUCCESS = "2" 24 | RESPONSE_STATUS_REDIRECT = "3" 25 | RESPONSE_STATUS_TEMP_FAILURE = "4" 26 | RESPONSE_STATUS_PERM_FAILURE = "5" 27 | RESPONSE_STATUS_CLIENTCERT_REQUIRED = "6" 28 | 29 | # Two-character response codes 30 | RESPONSE_STATUSDETAIL_ERROR_NETWORK = "00" 31 | RESPONSE_STATUSDETAIL_ERROR_DNS = "01" 32 | RESPONSE_STATUSDETAIL_ERROR_HOST = "02" 33 | RESPONSE_STATUSDETAIL_ERROR_TLS = "03" 34 | RESPONSE_STATUSDETAIL_ERROR_PROTOCOL = "04" 35 | RESPONSE_STATUSDETAIL_INPUT = "10" 36 | RESPONSE_STATUSDETAIL_INPUT_SENSITIVE = "11" 37 | RESPONSE_STATUSDETAIL_SUCCESS = "20" 38 | RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY = "30" 39 | RESPONSE_STATUSDETAIL_REDIRECT_PERMANENT = "31" 40 | RESPONSE_STATUSDETAIL_TEMP_FAILURE = "40" 41 | RESPONSE_STATUSDETAIL_TEMP_FAILURE_UNAVAILABLE = "41" 42 | RESPONSE_STATUSDETAIL_TEMP_FAILURE_CGI = "42" 43 | RESPONSE_STATUSDETAIL_TEMP_FAILURE_PROXY = "43" 44 | RESPONSE_STATUSDETAIL_TEMP_FAILURE_SLOW_DOWN = "44" 45 | RESPONSE_STATUSDETAIL_PERM_FAILURE = "50" 46 | RESPONSE_STATUSDETAIL_PERM_FAILURE_NOT_FOUND = "51" 47 | RESPONSE_STATUSDETAIL_PERM_FAILURE_GONE = "52" 48 | RESPONSE_STATUSDETAIL_PERM_FAILURE_PROXY_REFUSED = "53" 49 | RESPONSE_STATUSDETAIL_PERM_FAILURE_BAD_REQUEST = "59" 50 | RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED = "60" 51 | RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_AUTHORIZED = "61" 52 | RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_VALID = "62" 53 | 54 | # ignition application defaults 55 | DEFAULT_REQUEST_TIMEOUT = 30 56 | DEFAULT_HOSTS_FILE = ".known_hosts" 57 | -------------------------------------------------------------------------------- /ignition/python/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import sys 9 | 10 | # Polyfill to include gemini in urllib parsing 11 | if sys.version_info > (3, 13): 12 | raise Exception("Python versions > 3.12.x are not supported at this time.") 13 | if sys.version_info > (3, 12): 14 | from .python3_12.Lib import urllib 15 | if sys.version_info > (3, 11): 16 | from .python3_11.Lib import urllib 17 | if sys.version_info > (3, 10): 18 | from .python3_10.Lib import urllib 19 | if sys.version_info > (3, 9): 20 | from .python3_9.Lib import urllib 21 | elif sys.version_info > (3, 8): 22 | from .python3_8.Lib import urllib 23 | elif sys.version_info > (3, 7): 24 | from .python3_7.Lib import urllib 25 | else: 26 | raise Exception("Python versions < 3.7 are not supported at this time.") 27 | -------------------------------------------------------------------------------- /ignition/python/python3_10/LICENSE_NOTICE: -------------------------------------------------------------------------------- 1 | LICENSE NOTICE 2 | 3 | AS PER SECTION 3 OF PSF LICENSE AGREEMENT FOR PYTHON 3.10.0 4 | 5 | THE FOLLOWING CHANGES HAVE BEEN MADE TO THE PYTHON SOURCE CODE: 6 | 7 | 1. Changes to python library file at Lib/urllib/parse.py: 8 | * Added additional protocol scheme "gemini" to `uses_relative` list of protocols. 9 | * Added additional protocol scheme "gemini" to `uses_netloc` list of protocols. 10 | * Added additional protocol scheme "gemini" to `uses_params` list of protocols. -------------------------------------------------------------------------------- /ignition/python/python3_10/Lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_10/Lib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_10/Lib/urllib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_10/Lib/urllib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_10/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_10/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_11/LICENSE_NOTICE: -------------------------------------------------------------------------------- 1 | LICENSE NOTICE 2 | 3 | AS PER SECTION 3 OF PSF LICENSE AGREEMENT FOR PYTHON 3.11.0 4 | 5 | THE FOLLOWING CHANGES HAVE BEEN MADE TO THE PYTHON SOURCE CODE: 6 | 7 | 1. Changes to python library file at Lib/urllib/parse.py: 8 | * Added additional protocol scheme "gemini" to `uses_relative` list of protocols. 9 | * Added additional protocol scheme "gemini" to `uses_netloc` list of protocols. 10 | * Added additional protocol scheme "gemini" to `uses_params` list of protocols. -------------------------------------------------------------------------------- /ignition/python/python3_11/Lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_11/Lib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_11/Lib/urllib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_11/Lib/urllib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_11/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_11/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_12/Lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_12/Lib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_12/Lib/urllib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_12/Lib/urllib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_12/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_12/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_7/LICENSE_NOTICE: -------------------------------------------------------------------------------- 1 | LICENSE NOTICE 2 | 3 | AS PER SECTION 3 OF PSF LICENSE AGREEMENT FOR PYTHON 3.7.0 4 | 5 | THE FOLLOWING CHANGES HAVE BEEN MADE TO THE PYTHON SOURCE CODE: 6 | 7 | 1. Changes to python library file at Lib/urllib/parse.py: 8 | * Added additional protocol scheme "gemini" to `uses_relative` list of protocols. 9 | * Added additional protocol scheme "gemini" to `uses_netloc` list of protocols. 10 | * Added additional protocol scheme "gemini" to `uses_params` list of protocols. -------------------------------------------------------------------------------- /ignition/python/python3_7/Lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_7/Lib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_7/Lib/urllib/__init__.py: -------------------------------------------------------------------------------- 1 | from .parse import * 2 | -------------------------------------------------------------------------------- /ignition/python/python3_7/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_7/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_8/LICENSE_NOTICE: -------------------------------------------------------------------------------- 1 | LICENSE NOTICE 2 | 3 | AS PER SECTION 3 OF PSF LICENSE AGREEMENT FOR PYTHON 3.8.0 4 | 5 | THE FOLLOWING CHANGES HAVE BEEN MADE TO THE PYTHON SOURCE CODE: 6 | 7 | 1. Changes to python library file at Lib/urllib/parse.py: 8 | * Added additional protocol scheme "gemini" to `uses_relative` list of protocols. 9 | * Added additional protocol scheme "gemini" to `uses_netloc` list of protocols. 10 | * Added additional protocol scheme "gemini" to `uses_params` list of protocols. -------------------------------------------------------------------------------- /ignition/python/python3_8/Lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_8/Lib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_8/Lib/urllib/__init__.py: -------------------------------------------------------------------------------- 1 | from .parse import * 2 | -------------------------------------------------------------------------------- /ignition/python/python3_8/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_8/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_9/LICENSE_NOTICE: -------------------------------------------------------------------------------- 1 | LICENSE NOTICE 2 | 3 | AS PER SECTION 3 OF PSF LICENSE AGREEMENT FOR PYTHON 3.9.0 4 | 5 | THE FOLLOWING CHANGES HAVE BEEN MADE TO THE PYTHON SOURCE CODE: 6 | 7 | 1. Changes to python library file at Lib/urllib/parse.py: 8 | * Added additional protocol scheme "gemini" to `uses_relative` list of protocols. 9 | * Added additional protocol scheme "gemini" to `uses_netloc` list of protocols. 10 | * Added additional protocol scheme "gemini" to `uses_params` list of protocols. -------------------------------------------------------------------------------- /ignition/python/python3_9/Lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_9/Lib/__init__.py -------------------------------------------------------------------------------- /ignition/python/python3_9/Lib/urllib/__init__.py: -------------------------------------------------------------------------------- 1 | from .parse import * 2 | -------------------------------------------------------------------------------- /ignition/python/python3_9/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/ignition/python/python3_9/__init__.py -------------------------------------------------------------------------------- /ignition/request.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import logging 9 | import re 10 | import socket 11 | import ssl 12 | from socket import gaierror as SocketGaiErrorException 13 | from socket import herror as SocketHErrorException 14 | from socket import timeout as SocketTimeoutException 15 | 16 | import cryptography 17 | 18 | from .exceptions import ( 19 | GeminiResponseParseError, 20 | RemoteCertificateExpired, 21 | TofuCertificateRejection, 22 | ) 23 | from .globals import ( 24 | CRLF, 25 | GEMINI_DEFAULT_ENCODING, 26 | GEMINI_RESPONSE_HEADER_META_MAXLENGTH, 27 | GEMINI_RESPONSE_HEADER_SEPARATOR, 28 | RESPONSE_STATUSDETAIL_ERROR_DNS, 29 | RESPONSE_STATUSDETAIL_ERROR_HOST, 30 | RESPONSE_STATUSDETAIL_ERROR_PROTOCOL, 31 | RESPONSE_STATUSDETAIL_ERROR_TLS, 32 | ) 33 | from .response import BaseResponse, ResponseFactory 34 | from .ssl.cert_wrapper import CertWrapper 35 | from .url import URL 36 | 37 | logger = logging.getLogger(__name__) 38 | 39 | 40 | class Request: 41 | """ 42 | Handles a single request to a Gemini Server. 43 | 44 | The request handler has four key responsibilities: 45 | 46 | 1. It manages resolution of the requested URL for 47 | the remote server, by invoking underlying URL parse 48 | logic 49 | 2. It manages transmission of the request via TLS over a 50 | socket connection to the remote server. 51 | 3. It validates SSL certificate response using a TOFU 52 | (trust-on-first-use) validation paradigm 53 | 4. It manages raw response handling and designation to 54 | the Response object (or exception raising, if indicated) 55 | """ 56 | 57 | def __init__( 58 | self, 59 | url: str, 60 | raise_errors=False, 61 | referer=None, 62 | request_timeout=None, 63 | cert_store=None, 64 | ca_cert=None, 65 | ): 66 | """ 67 | Initializes Response with a url, referer, and timeout 68 | """ 69 | 70 | self.__url = URL(url, referer_url=referer) 71 | self.__raise_errors = raise_errors 72 | self.__timeout = request_timeout 73 | self.__cert_store = cert_store 74 | self.__ca_cert = ca_cert # This should be a tuple 75 | 76 | def get_url(self): 77 | """ 78 | Fetch the generated URL for the request (based on referer, if present) 79 | """ 80 | 81 | return str(self.__url) 82 | 83 | def send(self): 84 | """ 85 | Performes network communication and returns a Response object 86 | """ 87 | 88 | logger.debug(f"Attempting to create a connection to {self.__url.netloc()}") 89 | socket_result = self.__get_socket() 90 | if isinstance(socket_result, BaseResponse): 91 | return socket_result 92 | 93 | logger.debug( 94 | f"Attempting to negotiate SSL handshake with {self.__url.netloc()}" 95 | ) 96 | secure_socket_result = self.__negotiate_ssl(socket_result) 97 | if isinstance(secure_socket_result, BaseResponse): 98 | return secure_socket_result 99 | 100 | logger.debug(f"Validating server certificate to {self.__url.netloc()}") 101 | ssl_certificate_result = self.__validate_ssl_certificate(secure_socket_result) 102 | if isinstance(ssl_certificate_result, BaseResponse): 103 | return ssl_certificate_result 104 | 105 | logger.debug(f"Sending request header: {self.__url}") 106 | transport_result = self.__transport_payload(secure_socket_result, self.__url) 107 | if isinstance(transport_result, BaseResponse): 108 | return transport_result 109 | 110 | header, raw_body = transport_result 111 | logger.debug( 112 | f"Received response header: [{header}] and payload of length {len(raw_body)} bytes" 113 | ) 114 | return self.__handle_response( 115 | header, raw_body, ssl_certificate_result.certificate 116 | ) 117 | 118 | def __get_socket(self): 119 | """ 120 | Creates a socket connection and manages exceptions. 121 | """ 122 | 123 | try: 124 | sock = socket.create_connection( 125 | (self.__url.host(), self.__url.port()), timeout=self.__timeout 126 | ) 127 | logger.debug(f"Created socket connection: {sock}") 128 | return sock 129 | except ConnectionRefusedError as err: 130 | logger.debug( 131 | f"ConnectionRefusedError: Connection to {self.__url.netloc()} was refused. {err}" 132 | ) 133 | if self.__raise_errors: 134 | raise err 135 | return ResponseFactory.create( 136 | self.__url, RESPONSE_STATUSDETAIL_ERROR_HOST, "Connection refused" 137 | ) 138 | except ConnectionResetError as err: 139 | logger.debug( 140 | f"ConnectionResetError: Connection to {self.__url.netloc()} was reset. {err}" 141 | ) 142 | if self.__raise_errors: 143 | raise err 144 | return ResponseFactory.create( 145 | self.__url, RESPONSE_STATUSDETAIL_ERROR_HOST, "Connection reset" 146 | ) 147 | except SocketHErrorException as err: 148 | logger.debug( 149 | f"socket.herror: socket.gethostbyaddr returned for {self.__url.host()}. {err}" 150 | ) 151 | if self.__raise_errors: 152 | raise err 153 | return ResponseFactory.create( 154 | self.__url, RESPONSE_STATUSDETAIL_ERROR_HOST, "Host error" 155 | ) 156 | except SocketGaiErrorException as err: 157 | logger.debug( 158 | f"socket.gaierror: socket.getaddrinfo returned unknown host for {self.__url.host()}. {err}" 159 | ) 160 | if self.__raise_errors: 161 | raise err 162 | return ResponseFactory.create( 163 | self.__url, RESPONSE_STATUSDETAIL_ERROR_DNS, "Unknown host" 164 | ) 165 | except SocketTimeoutException as err: 166 | logger.debug( 167 | f"socket.timeout: socket timed out connecting to {self.__url.host()}. {err}" 168 | ) 169 | if self.__raise_errors: 170 | raise err 171 | return ResponseFactory.create( 172 | self.__url, RESPONSE_STATUSDETAIL_ERROR_HOST, "Socket timeout" 173 | ) 174 | except Exception as err: 175 | logger.error( 176 | f"Unknown exception encountered when connecting to {self.__url.netloc()} - {err}" 177 | ) 178 | raise err 179 | 180 | def __negotiate_ssl(self, socket_obj) -> ssl.SSLSocket: 181 | """ 182 | Negotiates a SSL handshake on the passed socket connection and returns the secure socket 183 | """ 184 | 185 | try: 186 | context = self.__setup_ssl_default_context() 187 | 188 | if self.is_using_ca_cert(): 189 | self.__setup_ssl_client_certificate_context(context) 190 | 191 | secure_socket_result = context.wrap_socket( 192 | socket_obj, server_hostname=self.__url.host() 193 | ) 194 | return secure_socket_result 195 | except ssl.SSLZeroReturnError as err: 196 | logger.debug(f"ssl.SSLZeroReturnError for {self.__url.host()} - {err}") 197 | if self.__raise_errors: 198 | raise err 199 | return ResponseFactory.create( 200 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "SSL Zero Return Error" 201 | ) 202 | except ssl.SSLWantReadError as err: 203 | logger.debug(f"ssl.SSLWantReadError for {self.__url.host()} - {err}") 204 | if self.__raise_errors: 205 | raise err 206 | return ResponseFactory.create( 207 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "SSL Read Error" 208 | ) 209 | except ssl.SSLWantWriteError as err: 210 | logger.debug(f"ssl.SSLWantWriteError for {self.__url.host()} - {err}") 211 | if self.__raise_errors: 212 | raise err 213 | return ResponseFactory.create( 214 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "SSL Write Error" 215 | ) 216 | except ssl.SSLSyscallError as err: 217 | logger.debug(f"ssl.SSLSyscallError for {self.__url.host()} - {err}") 218 | if self.__raise_errors: 219 | raise err 220 | return ResponseFactory.create( 221 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "SSL Syscall Error" 222 | ) 223 | except ssl.SSLEOFError as err: 224 | logger.debug(f"ssl.SSLEOFError for {self.__url.host()} - {err}") 225 | if self.__raise_errors: 226 | raise err 227 | return ResponseFactory.create( 228 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "SSL EOF Error" 229 | ) 230 | except ssl.SSLCertVerificationError as err: 231 | logger.debug( 232 | f"ssl.SSLCertVerificationError for {self.__url.host()} - {err}" 233 | ) 234 | if self.__raise_errors: 235 | raise err 236 | return ResponseFactory.create( 237 | self.__url, 238 | RESPONSE_STATUSDETAIL_ERROR_TLS, 239 | "SSL Certificate Verification Error", 240 | ) 241 | except ssl.SSLError as err: 242 | logger.debug(f"ssl.SSLError for {self.__url.host()} - {err}") 243 | if self.__raise_errors: 244 | raise err 245 | return ResponseFactory.create( 246 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "SSL Error" 247 | ) 248 | except SocketTimeoutException as err: 249 | logger.debug( 250 | f"socket.timeout: socket timed out connecting to {self.__url.host()}" 251 | ) 252 | if self.__raise_errors: 253 | raise err 254 | return ResponseFactory.create( 255 | self.__url, RESPONSE_STATUSDETAIL_ERROR_HOST, "Socket timeout" 256 | ) 257 | except Exception as err: 258 | logger.error( 259 | f"Unknown exception encountered when completing SSL handshake for {self.__url.host()} - {err}" 260 | ) 261 | raise err 262 | 263 | def __validate_ssl_certificate(self, secure_socket) -> CertWrapper: 264 | """ 265 | Trust-on-first-use (TOFU) validation on SSL certificate or throws exception 266 | """ 267 | 268 | try: 269 | certificate_wrapper = CertWrapper.parse(secure_socket.getpeercert(True)) 270 | self.__cert_store.validate_tofu_or_add( 271 | secure_socket.server_hostname, certificate_wrapper 272 | ) 273 | return certificate_wrapper 274 | except ValueError as err: 275 | logger.debug(f"ValueError: {self.__url.netloc()}. {err}") 276 | if self.__raise_errors: 277 | raise err 278 | return ResponseFactory.create( 279 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, err 280 | ) 281 | except RemoteCertificateExpired as err: 282 | logger.debug( 283 | f"RemoteCertificateExpired: {self.__url.netloc()} has an expired certificate. {err}" 284 | ) 285 | if self.__raise_errors: 286 | raise err 287 | return ResponseFactory.create( 288 | self.__url, RESPONSE_STATUSDETAIL_ERROR_TLS, "Certificate expired" 289 | ) 290 | except TofuCertificateRejection as err: 291 | logger.debug( 292 | f"TofuCertificateRejection: {self.__url.netloc()} has an untrusted, unknown certificate. {err}" 293 | ) 294 | if self.__raise_errors: 295 | raise err 296 | return ResponseFactory.create( 297 | self.__url, 298 | RESPONSE_STATUSDETAIL_ERROR_TLS, 299 | "Untrusted certificate (TOFU rejection)", 300 | ) 301 | except Exception as err: 302 | logger.error( 303 | f"Unknown exception encountered when validating ssl certificate on {self.__url.netloc()} - {err}" 304 | ) 305 | raise err 306 | 307 | def is_using_ca_cert(self): 308 | """ 309 | Returns if the request is using ca_cert 310 | """ 311 | return self.__ca_cert is not None 312 | 313 | def __setup_ssl_default_context(self): 314 | """ 315 | Setup an SSL default context (without a client certificate) 316 | This will bypass certificate validation against a CA. 317 | TOFU validation will be completed after the request is completed. 318 | """ 319 | 320 | context = ssl.create_default_context() 321 | context.minimum_version = ssl.TLSVersion.TLSv1_2 322 | context.check_hostname = False 323 | context.verify_mode = ssl.CERT_NONE 324 | return context 325 | 326 | def __setup_ssl_client_certificate_context(self, context): 327 | """ 328 | Load cert chain for client certificate 329 | TODO: Better error handling here? 330 | """ 331 | cert, key = self.__ca_cert 332 | context.load_cert_chain(cert, key) 333 | 334 | def __transport_payload(self, socket_obj, payload): 335 | """ 336 | Handles Gemini protocol negotiation over the socket 337 | """ 338 | 339 | try: 340 | socket_obj.sendall((f"{payload}{CRLF}").encode(GEMINI_DEFAULT_ENCODING)) 341 | fd = socket_obj.makefile("rb") 342 | return fd.readline().decode(GEMINI_DEFAULT_ENCODING).strip(), fd.read() 343 | except SocketTimeoutException: 344 | logger.debug( 345 | f"socket.timeout: socket timed out connecting to {self.__url.host()}" 346 | ) 347 | return ResponseFactory.create( 348 | self.__url, RESPONSE_STATUSDETAIL_ERROR_HOST, "Socket timeout" 349 | ) 350 | except Exception as err: 351 | logger.error( 352 | f"Unknown exception encountered when transporting data to {self.__url.netloc()} - {err}" 353 | ) 354 | raise err 355 | 356 | def __handle_response( 357 | self, header, raw_body, certificate: cryptography.x509.Certificate 358 | ): 359 | """ 360 | Handles basic response data from the remote server and hands off to the Response object 361 | """ 362 | try: 363 | status, meta = re.split( 364 | GEMINI_RESPONSE_HEADER_SEPARATOR, header, maxsplit=1 365 | ) 366 | 367 | if not re.match(r"^\d{2}$", status): 368 | raise GeminiResponseParseError( 369 | "Response status is not a two-digit code" 370 | ) 371 | 372 | if len(meta) > GEMINI_RESPONSE_HEADER_META_MAXLENGTH: 373 | raise GeminiResponseParseError("Header meta text is too long") 374 | 375 | return ResponseFactory.create( 376 | self.__url, status, meta.strip(), raw_body, certificate 377 | ) 378 | except GeminiResponseParseError as err: 379 | return ResponseFactory.create( 380 | self.__url, RESPONSE_STATUSDETAIL_ERROR_PROTOCOL, err 381 | ) 382 | -------------------------------------------------------------------------------- /ignition/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import cgi 9 | import logging 10 | 11 | from cryptography.x509 import Certificate 12 | 13 | from .globals import ( 14 | CRLF, 15 | GEMINI_DEFAULT_ENCODING, 16 | GEMINI_DEFAULT_MIME_TYPE, 17 | RESPONSE_STATUSDETAIL_ERROR_PROTOCOL, 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class ResponseFactory: 24 | """ 25 | Wrapper class for factory: 26 | Determines the approriate response type based on response status 27 | and generates the appropriate response type 28 | """ 29 | 30 | @classmethod 31 | def create(cls, url: str, status: str, meta=None, raw_body=None, certificate=None): 32 | """ 33 | Given a url, status, and response data, generates the appropriate response type 34 | """ 35 | basic_status_code = status[0] 36 | factories = { 37 | "0": ErrorResponse, 38 | "1": InputResponse, 39 | "2": SuccessResponse, 40 | "3": RedirectResponse, 41 | "4": TempFailureResponse, 42 | "5": PermFailureResponse, 43 | "6": ClientCertRequiredResponse, 44 | } 45 | 46 | factory_class = factories.get(basic_status_code, None) 47 | 48 | if factory_class is None: 49 | return ErrorResponse( 50 | url, 51 | RESPONSE_STATUSDETAIL_ERROR_PROTOCOL, 52 | f"Invalid response received from the server, status code: {status}", 53 | None, 54 | None, 55 | ) 56 | 57 | return factory_class(url, status, meta, raw_body, certificate) 58 | 59 | 60 | class BaseResponse: 61 | """ 62 | Abstract Base response type that all response types inherit from. 63 | Included public members: 64 | * url 65 | * basic_status 66 | * status 67 | * meta 68 | * raw_body 69 | * certificate 70 | """ 71 | 72 | url: str 73 | basic_status: str 74 | status: str 75 | meta: str 76 | raw_body: bytes 77 | certificate: Certificate 78 | 79 | def __init__( 80 | self, 81 | url: str, 82 | status: str, 83 | meta: str, 84 | raw_body: bytes, 85 | certificate: Certificate, 86 | ): 87 | """ 88 | Initializes a BaseResponse with the request url, status code, metadata, raw body string, and remote certificate 89 | """ 90 | self.url = str(url) 91 | self.basic_status = status[0] 92 | self.status = status 93 | self.meta = meta 94 | self.raw_body = raw_body 95 | self.certificate = certificate 96 | 97 | def is_a(self, response_class_type): 98 | """ 99 | Returns true if the response class type matches the current class 100 | """ 101 | return isinstance(self, response_class_type) 102 | 103 | def success(self): 104 | """ 105 | Returns true if the response is of the type success 106 | """ 107 | return self.is_a(SuccessResponse) 108 | 109 | def data(self): 110 | """ 111 | Fetches processed data from the response. This method should be overloaded in each specific response type. 112 | """ 113 | return self.raw_body 114 | 115 | def __str__(self): 116 | """ 117 | Returns a literal string representation of the response, including messageheader 118 | """ 119 | return f"{self.status} {self.meta}" 120 | 121 | def __repr__(self): 122 | """ 123 | A representation of the string for developers 124 | """ 125 | return f"" 126 | 127 | 128 | class ErrorResponse(BaseResponse): 129 | """ 130 | ErrorResponse 131 | This is a custom response type for ignition, to handle any responses representing request errors 132 | that are outside of the scope of the Gemini protocol. Included options are: 133 | 134 | 00: RESPONSE_STATUSDETAIL_ERROR_NETWORK 135 | Any errors that occur at the network level, and prevented the client from making any connection 136 | with external services. 137 | 138 | 01: RESPONSE_STATUSDETAIL_ERROR_DNS = "01" 139 | Any errors at the DNS level. 140 | 141 | 02: RESPONSE_STATUSDETAIL_ERROR_HOST 142 | Any errors connecting to the host (timeout, refused, etc.). 143 | 144 | 03: RESPONSE_STATUSDETAIL_ERROR_TLS 145 | Any errors associated with TLS/SSL, including handshake errors, certificate expired errors, 146 | and security errors like certificate rejection errors. 147 | 148 | 04: RESPONSE_STATUSDETAIL_ERROR_PROTOCOL 149 | Any errors where a secure message is received from the server, but it does not conform to the 150 | Gemini protocol requirements and cannot be processed. 151 | """ 152 | 153 | def data(self): 154 | """ 155 | Fetch data relevant to the ErrorResponse; in this case the metadata message from the response 156 | """ 157 | return self.meta 158 | 159 | 160 | class InputResponse(BaseResponse): 161 | """ 162 | InputRequest 163 | Meets Gemini specification: 3.2.1 1x (INPUT) 164 | 165 | Status codes beginning with 1 are INPUT status codes, meaning that 166 | the requested resource accepts a line of textual user input. 167 | 168 | The user should reissue a request to the url with parameters in the form: 169 | gemini://hostname/path?query 170 | """ 171 | 172 | def data(self): 173 | """ 174 | Returns the related instructions for the InputResponse. 175 | The line is a prompt which should be displayed to the user. 176 | """ 177 | return self.meta 178 | 179 | 180 | class SuccessResponse(BaseResponse): 181 | """ 182 | SuccessResponse 183 | Meets Gemini specification: 3.2.2 2x (SUCCESS) 184 | 185 | Status codes beginning with 2 are SUCCESS status codes. 186 | """ 187 | 188 | def data(self): 189 | """ 190 | Decode the success message body using metadata in the appropriate encoding type 191 | """ 192 | 193 | meta = self.meta or GEMINI_DEFAULT_MIME_TYPE 194 | _, options = cgi.parse_header(meta) 195 | encoding = ( 196 | options["charset"] if "charset" in options else GEMINI_DEFAULT_ENCODING 197 | ) 198 | try: 199 | return self.raw_body.decode(encoding) 200 | except LookupError: 201 | logger.warning( 202 | f"Could not decode response body using invalid encoding {encoding}" 203 | ) 204 | return self.raw_body 205 | except UnicodeDecodeError: 206 | logger.warning( 207 | f"Could not decode response body via encoding {encoding}, returning raw data" 208 | ) 209 | return self.raw_body 210 | 211 | def __str__(self): 212 | """ 213 | The string representation of the success message should be header + body 214 | """ 215 | return f"{self.status} {self.meta}{CRLF}{self.data()}" 216 | 217 | 218 | class RedirectResponse(BaseResponse): 219 | """ 220 | RedirectResponse 221 | Meets Gemini specification: 3.2.3 3x (REDIRECT) 222 | 223 | Status codes beginning with 3 are REDIRECT status codes. 224 | 225 | The server is redirecting the client to a new location for the requested resource 226 | """ 227 | 228 | def data(self): 229 | """ 230 | Returns the new destination for redirection from the server 231 | """ 232 | return self.meta 233 | 234 | 235 | class TempFailureResponse(BaseResponse): 236 | """ 237 | TempFailureResponse 238 | Meets Gemini specification: 3.2.4 4x (TEMPORARY FAILURE) 239 | 240 | Status codes beginning with 4 are TEMPORARY FAILURE status codes. 241 | 242 | The request has failed, but an identical request may success in the future. 243 | """ 244 | 245 | def data(self): 246 | """ 247 | Returns the data from the server in the META field, which may provide additional information to the user. 248 | """ 249 | return f"{self.status} {self.meta}" 250 | 251 | 252 | class PermFailureResponse(BaseResponse): 253 | """ 254 | PermFailureResponse 255 | Meets Gemini specification: 3.2.5 5x (PERMANENT FAILURE) 256 | 257 | Status codes beginning with 5 are PERMANENT FAILURE status codes. 258 | 259 | The request has failed, identical requests will likely fail in the future. 260 | """ 261 | 262 | def data(self): 263 | """ 264 | Returns the data from the server in the META field, which may provide additional information to the user. 265 | """ 266 | return f"{self.status} {self.meta}" 267 | 268 | 269 | class ClientCertRequiredResponse(BaseResponse): 270 | """ 271 | ClientCertRequiredResponse 272 | Meets Gemini specification: 3.2.6 6x (CLIENT CERTIFICATE REQUIRED) 273 | 274 | Status codes beginning with 6 are CLIENT CERTIFICATE REQUIRED status codes 275 | 276 | The request should be retried with a client certificate. 277 | """ 278 | 279 | def data(self): 280 | """ 281 | Return additional information from the server on certificate requirements 282 | or the reason a certificate was rejected 283 | """ 284 | return self.meta 285 | -------------------------------------------------------------------------------- /ignition/ssl/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | -------------------------------------------------------------------------------- /ignition/ssl/cert_record.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import datetime 9 | 10 | from ..exceptions import CertRecordParseException 11 | from ..globals import EOL 12 | 13 | 14 | class CertRecord: 15 | """ 16 | Manages a single Certificate Record with a hostfile, signature, and expiration 17 | """ 18 | 19 | hostname: str 20 | fingerprint: str 21 | expiration: datetime.datetime 22 | 23 | def __init__(self, hostname: str, fingerprint: str, expiration: datetime.datetime): 24 | """ 25 | Generate a CertRecord from logic; passing in the hostname, fingerprint, and expiration 26 | """ 27 | self.hostname = hostname 28 | self.fingerprint = fingerprint 29 | self.expiration = expiration 30 | 31 | @classmethod 32 | def from_string(cls, host_string: str): 33 | """ 34 | Generate a CertRecord from a string in the format: 35 | [HOSTNAME] [SSH-ALGORITHM PUBLIC_KEY];EXPIRES=[YYYY-MM-DDTHH:mm:ss.SSSZ] 36 | """ 37 | try: 38 | hostname, fingerprint_with_expiration = host_string.strip().split( 39 | " ", maxsplit=1 40 | ) 41 | fingerprint, expiration = fingerprint_with_expiration.split(";EXPIRES=") 42 | expiration_datetime = datetime.datetime.fromisoformat(expiration) 43 | 44 | return CertRecord(hostname, fingerprint, expiration_datetime) 45 | except Exception as e: 46 | raise CertRecordParseException() from e 47 | 48 | def to_string(self): 49 | """ 50 | Converts a CertRecord to a string in the format: 51 | [HOSTNAME] [SSH-ALGORITHM PUBLIC_KEY];EXPIRES=[YYYY-MM-DDTHH:mm:ss.SSSZ] 52 | """ 53 | return ( 54 | self.hostname 55 | + " " 56 | + self.fingerprint 57 | + ";EXPIRES=" 58 | + self.expiration.isoformat() 59 | + EOL 60 | ) 61 | 62 | def is_expired(self): 63 | """ 64 | Returns true if the expiration date on the cert record is before now 65 | """ 66 | return self.expiration < self.now() 67 | 68 | def now(self): 69 | """ 70 | Utility function to get current datetime, extracted for testing purposes 71 | Returns datetime 72 | """ 73 | return datetime.datetime.now() 74 | -------------------------------------------------------------------------------- /ignition/ssl/cert_store.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import logging 9 | from typing import Dict 10 | 11 | from ..exceptions import ( 12 | CertRecordParseException, 13 | RemoteCertificateExpired, 14 | TofuCertificateRejection, 15 | ) 16 | from .cert_record import CertRecord 17 | from .cert_wrapper import CertWrapper 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class CertStore: 23 | """ 24 | Data structure to store the certificates across visited hosts 25 | """ 26 | 27 | __hosts_file: str 28 | __cert_store_data: Dict[str, CertRecord] 29 | 30 | def __init__(self, hosts_file): 31 | """ 32 | Initializes a new cert store with a specified file to store the certificate fingerprint & expiration dates 33 | """ 34 | self.__cert_store_data = {} 35 | self.__hosts_file = hosts_file 36 | 37 | def set_hosts_file(self, hosts_file): 38 | """ 39 | Updates the specified file for certificate fingerprint storage 40 | """ 41 | self.__hosts_file = hosts_file 42 | 43 | def get_hosts_file(self): 44 | """ 45 | Returns the currently set hosts file location 46 | """ 47 | return self.__hosts_file 48 | 49 | def validate_tofu_or_add(self, hostname: str, cert: CertWrapper) -> bool: 50 | """ 51 | Given the hostname & correspoding certificate, this function: 52 | 1. Checks to see if the certificate is expired (if so, it throws a RemoteCertificateExpired exception) 53 | 2. Fetches a corresponding stored certificate record from the client to implement a TOFU check: 54 | a. If there is a local cert record, and it's not expired, and it matches the passed certificate, return success 55 | b. If there is not a local cert record, save the certificate record locally, and return success 56 | c. If there is a local cert record, but it's expired, save the certificate record locally, and return success 57 | d. If there is a local cert record, and it's not expired, but it does not match the passed certificate, 58 | throw TofuCertificateRejection 59 | """ 60 | remote_cert_record = CertRecord(hostname, cert.fingerprint(), cert.expiration()) 61 | 62 | if remote_cert_record.is_expired(): 63 | raise RemoteCertificateExpired 64 | 65 | local_cert_record = self.__get_cert_record(hostname) 66 | 67 | if ( 68 | local_cert_record 69 | and not local_cert_record.is_expired() 70 | and local_cert_record.fingerprint != remote_cert_record.fingerprint 71 | ): 72 | raise TofuCertificateRejection 73 | 74 | self.__add_cert_record(remote_cert_record) 75 | return True 76 | 77 | def __get_cert_record(self, hostname: str) -> CertRecord: 78 | """ 79 | Fetch the corresponding CertRecord for passed hostname from the local storage (file) 80 | TODO: smarter loading logic 81 | """ 82 | self.__load() 83 | return self.__cert_store_data.get(hostname, None) 84 | 85 | def __add_cert_record(self, cert_record: CertRecord): 86 | """ 87 | Add a CertRecord for the corresponding hostname to local storage (file) and save to file 88 | TODO: smarter saving logic 89 | """ 90 | self.__cert_store_data[cert_record.hostname] = cert_record 91 | self.__save() 92 | return self 93 | 94 | def __load(self): 95 | """ 96 | Reloads the hosts file from storage and copies that into memory 97 | """ 98 | file_lines = [] 99 | try: 100 | with open(self.__hosts_file, "r", encoding="utf-8") as f: 101 | file_lines = f.readlines() 102 | except FileNotFoundError: 103 | file_lines = [] 104 | 105 | for file_line in file_lines: 106 | cert_record = self.__load_record(file_line) 107 | if cert_record is not None: 108 | self.__cert_store_data[cert_record.hostname] = cert_record 109 | 110 | return self 111 | 112 | def __load_record(self, file_line): 113 | try: 114 | return CertRecord.from_string(file_line) 115 | except CertRecordParseException: 116 | logger.warning( 117 | f"Invalid TOFU record encountered: '{file_line.strip()}'. This record has been skipped." 118 | ) 119 | return None 120 | 121 | def __save(self): 122 | """ 123 | Saves the full set of host records back to file 124 | """ 125 | with open(self.__hosts_file, "w", encoding="utf-8") as f: 126 | for c in self.__cert_store_data.values(): 127 | f.write(c.to_string()) 128 | 129 | return self 130 | -------------------------------------------------------------------------------- /ignition/ssl/cert_wrapper.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import cryptography 9 | from cryptography import x509 10 | from cryptography.hazmat.backends import default_backend 11 | 12 | 13 | class CertWrapper: 14 | """ 15 | Certificate as defined by x509 16 | """ 17 | 18 | certificate: cryptography.x509.Certificate 19 | """ 20 | Certificate fingerprint, to be used in TOFU handling and response 21 | """ 22 | public_key_fingerprint: str 23 | 24 | def __init__(self, certificate: cryptography.x509.Certificate): 25 | """ 26 | Constructor 27 | """ 28 | self.certificate = certificate 29 | 30 | def expiration(self) -> str: 31 | """ 32 | Access function for certificate expiration date 33 | """ 34 | return self.certificate.not_valid_after 35 | 36 | def fingerprint(self) -> str: 37 | """ 38 | Extracts the public key & expiration date from the cert, 39 | and returns the public key openssh fingerprint 40 | """ 41 | return ( 42 | self.certificate.public_key() 43 | .public_bytes( 44 | cryptography.hazmat.primitives.serialization.Encoding.OpenSSH, 45 | cryptography.hazmat.primitives.serialization.PublicFormat.OpenSSH, 46 | ) 47 | .decode("utf-8") 48 | ) 49 | 50 | @classmethod 51 | def parse(cls, raw_certificate: bytes): 52 | """ 53 | Takes as input the raw certificate (originally from the TCP socket) 54 | Returns a certificate wrapper 55 | """ 56 | x509_certificate = x509.load_der_x509_certificate( 57 | raw_certificate, default_backend() 58 | ) 59 | return CertWrapper(x509_certificate) 60 | -------------------------------------------------------------------------------- /ignition/url.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import logging 9 | 10 | from .globals import GEMINI_PORT, GEMINI_SCHEME 11 | from .python import urllib 12 | from .util import normalize_path 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class URL: 18 | """ 19 | The URL class negotiates the correct URL based on passed in URL. 20 | 21 | This logic prepares the URL to be passed via the socket connector, 22 | as well as for the data payload for Gemini. 23 | """ 24 | 25 | def __init__(self, url, referer_url=None): 26 | """ 27 | Construct a protocool-safe URL based on the passed string. 28 | """ 29 | self.__parsed_url = self.__url_constructor(url, referer_url) 30 | 31 | logger.debug( 32 | ( 33 | f"Recieved url {url} for parsing, {f'with referer {referer_url}, ' if referer_url else ''} generated gemini url: {self}" 34 | ) 35 | ) 36 | 37 | def __url_constructor(self, url, referer_url): 38 | """ 39 | Constructs a protocol-safe URL based on the passed string. 40 | 41 | If referer_url is included (which should be the constructed 42 | URL from the last time this ran), the new url is joined onto 43 | the referer. This allows the user to pass in paths without a 44 | hostname. 45 | """ 46 | 47 | url = url.lstrip() 48 | base_url = url 49 | if referer_url: 50 | base_url = urllib.parse.urljoin(referer_url, url, False) 51 | 52 | return urllib.parse.urlsplit(base_url, GEMINI_SCHEME, False) 53 | 54 | def __str__(self): 55 | """ 56 | Custom logic to re-join the URL into a string 57 | TODO url = 'about:blank', 'example:test' RFC-6694 and RFC-7585 58 | """ 59 | 60 | return "".join( 61 | [ 62 | self.protocol(), 63 | self.host(), 64 | (f":{self.port()}" if self.port() != GEMINI_PORT else ""), 65 | self.path(), 66 | (f"?{self.query()}" if self.query() else ""), 67 | ] 68 | ) 69 | 70 | def path(self): 71 | """ 72 | Returns path portion of the url 73 | URL Schema: scheme://host:port/path?query 74 | """ 75 | 76 | return normalize_path(self.__parsed_url.path or "") 77 | 78 | def host(self): 79 | """ 80 | Returns host portion of the url 81 | URL Schema: scheme://host:port/path?query 82 | """ 83 | 84 | return self.__parsed_url.hostname or "" 85 | 86 | def port(self): 87 | """ 88 | Returns port portion of the url 89 | URL Schema: scheme://host:port/path?query 90 | """ 91 | 92 | try: 93 | return self.__parsed_url.port or GEMINI_PORT 94 | except ValueError: 95 | # https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlsplit 96 | logger.warning( 97 | f"There was an error reading the port from the url. Defaulting to {GEMINI_PORT}" 98 | ) 99 | return GEMINI_PORT 100 | 101 | def netloc(self): 102 | """ 103 | Returns netloc portion of the url, which is the host:port 104 | URL Schema: scheme://host:port/path?query 105 | """ 106 | 107 | return self.__parsed_url.netloc 108 | 109 | def protocol(self): 110 | """ 111 | Returns scheme portion of the url with the protocol designator "://" 112 | URL Schema: scheme://host:port/path?query 113 | """ 114 | 115 | return f"{self.__parsed_url.scheme}://" 116 | 117 | def query(self): 118 | """ 119 | Returns query portion of the url 120 | URL Schema: scheme://host:port/path?query 121 | """ 122 | return self.__parsed_url.query 123 | -------------------------------------------------------------------------------- /ignition/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import logging 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def normalize_path(path: str) -> str: 14 | """ 15 | Implements a normalized path for a string 16 | See RFC-3986 https://tools.ietf.org/html/rfc3986#section-5.2.4 17 | 5.2.4. Remove Dot Segments 18 | 19 | Example: 20 | STEP OUTPUT BUFFER INPUT BUFFER 21 | 22 | 1 : /a/b/c/./../../g 23 | 2E: /a /b/c/./../../g 24 | 2E: /a/b /c/./../../g 25 | 2E: /a/b/c /./../../g 26 | 2B: /a/b/c /../../g 27 | 2C: /a/b /../g 28 | 2C: /a /g 29 | 2E: /a/g 30 | """ 31 | result_stack = [] 32 | for component in path.split("/"): 33 | if component in (".", ""): 34 | continue # Do nothing 35 | if component == "..": 36 | if len(result_stack) > 0: 37 | result_stack.pop() 38 | else: 39 | result_stack.append(component) 40 | 41 | unescaped_path = "".join( 42 | [ 43 | ("/" if len(path) > 0 and path[0] == "/" else ""), 44 | ("/".join(result_stack)), 45 | ("/" if len(path) > 0 and path[len(path) - 1] == "/" else ""), 46 | ] 47 | ) 48 | 49 | return unescaped_path.replace("//", "/") 50 | 51 | 52 | class TimeoutManager: 53 | """ 54 | Timeout Manager for global timeout management at the top-level 55 | """ 56 | 57 | def __init__(self, default_timeout): 58 | """ 59 | Sets a default timeout on initialization 60 | """ 61 | self.set_default_timeout(default_timeout) 62 | 63 | def set_default_timeout(self, default_timeout): 64 | """ 65 | Allow the default timeout to be overwritten 66 | """ 67 | self.default_timeout = default_timeout 68 | 69 | def get_timeout(self, timeout): 70 | """ 71 | Takes in a timeout and returns that, or the default timeout 72 | """ 73 | if timeout is not None: 74 | return timeout 75 | return self.default_timeout 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ignition-gemini" 3 | authors = [ 4 | { name="Chris Brousseau", email="cbrews@users.noreply.github.com" }, 5 | ] 6 | description = "ignition - Gemini Protocol Client Transport Library" 7 | readme = "README.md" 8 | license = { text = "MPL 2.0" } 9 | requires-python = ">=3.7" 10 | classifiers = [ 11 | "Development Status :: 7 - Inactive", 12 | "Intended Audience :: Developers", 13 | "Topic :: Software Development :: Libraries", 14 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3 :: Only", 23 | ] 24 | keywords = [ 25 | "gemini", 26 | "client", 27 | "request", 28 | "socket", 29 | "networking" 30 | ] 31 | dynamic = ["version"] 32 | dependencies = [ 33 | "cryptography>=36.0.0", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "cryptography==42.0.5", 39 | "mock==5.1.0", 40 | "pytest==7.4.4", 41 | "pytest-cov==4.1.0", 42 | "pytest-mock==3.11.1", 43 | "black==24.4.0", 44 | "pre-commit==3.7.0", 45 | "ruff==0.4.0", 46 | ] 47 | lint = [ 48 | "ruff==0.4.0", 49 | "black==24.4.0", 50 | "mock==5.1.0", 51 | "pytest==7.4.4", 52 | ] 53 | test = [ 54 | "mock==5.1.0", 55 | "pytest==7.4.4", 56 | "pytest-cov==4.1.0", 57 | "pytest-mock==3.11.1", 58 | ] 59 | build = [ 60 | "build", 61 | "twine", 62 | ] 63 | 64 | [project.urls] 65 | "Homepage" = "https://github.com/cbrews/ignition" 66 | 67 | [build-system] 68 | requires = ['hatchling'] 69 | build-backend = 'hatchling.build' 70 | 71 | [tool.hatch.version] 72 | path = "ignition/__init__.py" 73 | 74 | [tool.hatch.build.targets.sdist] 75 | include = [ 76 | '/README.md', 77 | '/LICENSE', 78 | '/ignition', 79 | ] 80 | 81 | [tool.hatch.build.targets.wheel] 82 | packages = ["ignition"] 83 | 84 | [tool.black] 85 | exclude = "ignition/python" 86 | 87 | [tool.ruff] 88 | select = [ 89 | "I", # isort 90 | "PLC", # pylint conventions 91 | "PLE", # pylint errors 92 | "PLW", # pylint warnings 93 | ] 94 | exclude = [ 95 | "ignition/python", 96 | ] -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | -------------------------------------------------------------------------------- /tests/fixtures/sample_cert.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrews/ignition/522db8a7e12e3913e39471e73b00e5c5fc2d7cd7/tests/fixtures/sample_cert.der -------------------------------------------------------------------------------- /tests/fixtures/sample_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFhjCCA24CCQC/vsWKJqaoqzANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC 3 | VVMxCzAJBgNVBAgMAk5ZMREwDwYDVQQHDAhOZXcgWW9yazEZMBcGA1UECgwQVGVz 4 | dCBDZXJ0aWZpY2F0ZTEYMBYGA1UECwwPVGVzdCBDZXJ0ZmljYXRlMQwwCgYDVQQD 5 | DANuL2ExEjAQBgkqhkiG9w0BCQEWA24vYTAeFw0yMTAyMjAxNzUwNTRaFw0yMjAy 6 | MjAxNzUwNTRaMIGEMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTlkxETAPBgNVBAcM 7 | CE5ldyBZb3JrMRkwFwYDVQQKDBBUZXN0IENlcnRpZmljYXRlMRgwFgYDVQQLDA9U 8 | ZXN0IENlcnRmaWNhdGUxDDAKBgNVBAMMA24vYTESMBAGCSqGSIb3DQEJARYDbi9h 9 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA80dr6bZT3EFJQxy2p2PU 10 | bEsC4BR093nAslInG2OBwTr5akUS/ZXT0BhXG5cLNF7B3EzSQAQnuMYz4cxuJBWP 11 | 9u4XIL51olV+4Ftcpaln+vnT+5vnsJFN8Siv11ZstIQQCMbkKFIRc0gM6fpse5c9 12 | 7ZvTrTrNkBWbtFORYIVYIUrepVFiRQ5dpFsc18X7HJwg/cBbeT5XPY6MVhecRQXc 13 | eDnI/6qWqXWolaHr7BmDTSucAYrBtHoyj45hqOjC0GDLJUb8EWowEjDVXswz3XYK 14 | 2LAy3AhwitKYowNjDCp2WZSFUYIp/g0lRmn+JWMDylpX/EOlK+kEQp+zB2g5BBTA 15 | DLyEpLkG2giQk82ZlmClMT6JSlh/mKDguWJoaVTCbUdJ2karyWxi9taVoPEeDFwo 16 | 0DDmx3DGRKpeYBDMciWm1rkvfB7gjWn90s1UIKSNwiNz1cZh35WUcIsA7lFk1ydF 17 | LBT3pTdVc9F1c/NePMGFMRsgAZyEawBdGdYbZG94gRRaNkeDGj8C0LajV0AnQ5VF 18 | yh2uG1UkOge1Qay+Sng7Bbr2livoEPW356k/mElhk1Qtkcl7br0fhHtSRakHJBPs 19 | dhRm26eWQj5dT3YEKj4OTPr17i3Z15N09Uero0EzGC+NoAFJLR5AKS3DkEO8GBQg 20 | dSjV2MaDRrANKbTLNH7IHBkCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAlq2fOkUH 21 | C81jn1qLfYbCmu6Oa6iYEuzcjo8Pnu3p2iEqSN74LVShZ9D3xktpRsR1cgL6alrO 22 | udtScYvbWQ/GjzkGbl727Dw4hvY655POIFEyuy77OeNNOEwxPQtwoCbegkyHXT4j 23 | Ow3JeK8+NDvl/FXty9xrLdAA+1nQ2UeS5GqRICrzGDg+izzc+gu370rIkwf53No/ 24 | Gu8RwV9mBjGjaW79MamwqCfJc0ajM3sFu/ZJQIQwIMv3wFFj0/+tgpxM3mLq1GVY 25 | 2c9J1Yy3itZkoXXuJ/yInBpe7u7Ahx93SaJLLhMX8xf1JK9N+WMUdEyti+L8NQrl 26 | MsTxI8kBb17y9T+XUf284jguzB/LfE6rbaHuQy3qLPnlanO5+NYEtcpSHl2vrQQt 27 | VbF4bKkJUtFTTa9Ruu6jY/o7AKaUN01bx/r1B9JrC6tgfnHILibaYGrHAwWuaOdi 28 | wCR5HzKuGB2q37Ovr4Aw9I8/16q49KNdozc7SU8/hwcZvZqDlT6orSzAdqziNhMT 29 | rQOI9RA+d7JZAgtynWXixJ1ntkPikew5V6ixAhYghTsYVkDuWmMQcY7rx9SZdqFs 30 | AYvLRxxV+p0BAb8R0zZxbnQPAPZ8pFQzN/0oSNht9C3hHKPpAQFtBfrVYldU4FqN 31 | GA3stDgSgGRK1wxd3syMDTLddVOiPnEkljE= 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | import os 9 | 10 | 11 | def load_fixture_bytes(filename: str) -> bytes: 12 | """ 13 | Load a fixture as bytes 14 | """ 15 | fixture_path = os.path.join(os.path.dirname(__file__), "./fixtures", filename) 16 | with open(fixture_path, "rb") as fixture_handler: 17 | return fixture_handler.read() 18 | -------------------------------------------------------------------------------- /tests/ssl/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | -------------------------------------------------------------------------------- /tests/ssl/test_cert_record.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-class-docstring,missing-function-docstring 9 | 10 | import datetime 11 | 12 | import mock 13 | import pytest 14 | 15 | from ignition.exceptions import CertRecordParseException 16 | from ignition.ssl.cert_record import CertRecord 17 | 18 | test_datetime = datetime.datetime(2020, 11, 15, 12, 15, 2, 438000) 19 | test_past_datetime = datetime.datetime(2018, 1, 1, 0, 0, 0, 0) 20 | 21 | 22 | def test_initialize(): 23 | cert_record = CertRecord("myhostname.sample", "ssh-rsa fingerprint", test_datetime) 24 | assert cert_record.hostname == "myhostname.sample" 25 | assert cert_record.fingerprint == "ssh-rsa fingerprint" 26 | assert cert_record.expiration == test_datetime 27 | 28 | 29 | def test_from_string(): 30 | cert_record = CertRecord.from_string( 31 | "myhostname.sample ssh-rsa fingerprint;EXPIRES=2020-11-15T12:15:02.438000\n" 32 | ) 33 | assert cert_record.hostname == "myhostname.sample" 34 | assert cert_record.fingerprint == "ssh-rsa fingerprint" 35 | assert cert_record.expiration == test_datetime 36 | 37 | 38 | def test_from_string_invalid(): 39 | with pytest.raises(CertRecordParseException): 40 | CertRecord.from_string("myhost.com\n") 41 | 42 | with pytest.raises(CertRecordParseException): 43 | CertRecord.from_string("myhost.com ssh-rsa fingerprint;EXPIRES=invalid\n") 44 | 45 | with pytest.raises(CertRecordParseException): 46 | CertRecord.from_string("\n") 47 | 48 | 49 | def test_to_string(): 50 | cert_record = CertRecord("myhostname.sample", "ssh-rsa fingerprint", test_datetime) 51 | assert ( 52 | cert_record.to_string() 53 | == "myhostname.sample ssh-rsa fingerprint;EXPIRES=2020-11-15T12:15:02.438000\n" 54 | ) 55 | 56 | 57 | @mock.patch("ignition.ssl.cert_record.datetime") 58 | def test_is_expired(datetime_mock): 59 | mocked_date_value = datetime.datetime(2020, 1, 1, 0, 0, 0, 0) 60 | datetime_mock.datetime.now = mock.Mock(return_value=mocked_date_value) 61 | 62 | cert_record_not_expired = CertRecord( 63 | "myhostname.sample", "ssh-rsa fingerprint", test_datetime 64 | ) 65 | cert_record_expired = CertRecord( 66 | "expired.sample", "ssh-rsa fingerprint", test_past_datetime 67 | ) 68 | 69 | assert cert_record_not_expired.now() == mocked_date_value 70 | assert cert_record_expired.now() == mocked_date_value 71 | assert not cert_record_not_expired.is_expired() 72 | assert cert_record_expired.is_expired() 73 | -------------------------------------------------------------------------------- /tests/ssl/test_cert_wrapper.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-class-docstring,missing-function-docstring 9 | 10 | import datetime 11 | 12 | from ignition.ssl.cert_wrapper import CertWrapper 13 | 14 | from ..helpers import load_fixture_bytes 15 | 16 | 17 | def test_certificate_parse(): 18 | cert_wrapper = CertWrapper.parse(load_fixture_bytes("sample_cert.der")) 19 | 20 | assert ( 21 | cert_wrapper.certificate.subject.rfc4514_string() 22 | == "1.2.840.113549.1.9.1=n/a,CN=n/a,OU=Test Certficate,O=Test Certificate,L=New York,ST=NY,C=US" 23 | ) 24 | assert cert_wrapper.expiration() == datetime.datetime(2022, 2, 20, 17, 50, 54) 25 | assert ( 26 | cert_wrapper.fingerprint() 27 | == "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDzR2vptlPcQUlDHLanY9RsSwLgFHT3ecCyUicbY4HBOvlqRRL9l" 28 | + "dPQGFcblws0XsHcTNJABCe4xjPhzG4kFY/27hcgvnWiVX7gW1ylqWf6+dP7m+ewkU3xKK/XVmy0hBAIxuQoUhFzSA" 29 | + "zp+mx7lz3tm9OtOs2QFZu0U5FghVghSt6lUWJFDl2kWxzXxfscnCD9wFt5Plc9joxWF5xFBdx4Ocj/qpapdaiVoev" 30 | + "sGYNNK5wBisG0ejKPjmGo6MLQYMslRvwRajASMNVezDPddgrYsDLcCHCK0pijA2MMKnZZlIVRgin+DSVGaf4lYwPK" 31 | + "Wlf8Q6Ur6QRCn7MHaDkEFMAMvISkuQbaCJCTzZmWYKUxPolKWH+YoOC5YmhpVMJtR0naRqvJbGL21pWg8R4MXCjQM" 32 | + "ObHcMZEql5gEMxyJabWuS98HuCNaf3SzVQgpI3CI3PVxmHflZRwiwDuUWTXJ0UsFPelN1Vz0XVz8148wYUxGyABnI" 33 | + "RrAF0Z1htkb3iBFFo2R4MaPwLQtqNXQCdDlUXKHa4bVSQ6B7VBrL5KeDsFuvaWK+gQ9bfnqT+YSWGTVC2RyXtuvR+" 34 | + "Ee1JFqQckE+x2FGbbp5ZCPl1PdgQqPg5M+vXuLdnXk3T1R6ujQTMYL42gAUktHkApLcOQQ7wYFCB1KNXYxoNGsA0ptMs0fsgcGQ==" 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_ignition.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-function-docstring,redefined-outer-name 9 | 10 | import pytest 11 | 12 | import ignition 13 | 14 | 15 | @pytest.fixture 16 | def mock_request(mocker): 17 | yield mocker.patch("ignition.Request") 18 | 19 | 20 | def _destructure_request_args(mock_request): 21 | args, request_kwargs = mock_request.call_args 22 | (request_url,) = args 23 | return ( 24 | request_url, 25 | request_kwargs["cert_store"], 26 | request_kwargs["request_timeout"], 27 | request_kwargs["referer"], 28 | request_kwargs["ca_cert"], 29 | request_kwargs["raise_errors"], 30 | ) 31 | 32 | 33 | def test_request(mock_request): 34 | ignition.request("//test") 35 | 36 | mock_request.assert_called_once() 37 | 38 | ( 39 | request_url, 40 | cert_store, 41 | request_timeout, 42 | referer, 43 | ca_cert, 44 | raise_errors, 45 | ) = _destructure_request_args(mock_request) 46 | 47 | assert request_url == "//test" 48 | assert cert_store.get_hosts_file() == ignition.DEFAULT_HOSTS_FILE 49 | assert request_timeout == ignition.DEFAULT_REQUEST_TIMEOUT 50 | assert raise_errors is False 51 | assert referer is None 52 | assert ca_cert is None 53 | 54 | mock_request.return_value.send.assert_called_once() 55 | 56 | 57 | def test_request_with_values(mock_request): 58 | ignition.request( 59 | "path", referer="//test", timeout=10, raise_errors=True, ca_cert="string" 60 | ) 61 | 62 | mock_request.assert_called_once() 63 | 64 | ( 65 | request_url, 66 | cert_store, 67 | request_timeout, 68 | referer, 69 | ca_cert, 70 | raise_errors, 71 | ) = _destructure_request_args(mock_request) 72 | 73 | assert request_url == "path" 74 | assert cert_store.get_hosts_file() == ignition.DEFAULT_HOSTS_FILE 75 | assert request_timeout == 10 76 | assert raise_errors is True 77 | assert referer == "//test" 78 | assert ca_cert == "string" 79 | 80 | mock_request.return_value.send.assert_called_once() 81 | 82 | 83 | def test_request_with_default_timeout(mock_request): 84 | ignition.set_default_timeout(9) 85 | ignition.request("//test") 86 | 87 | mock_request.assert_called_once() 88 | 89 | ( 90 | _, 91 | _, 92 | request_timeout, 93 | _, 94 | _, 95 | _, 96 | ) = _destructure_request_args(mock_request) 97 | 98 | assert request_timeout == 9 99 | 100 | mock_request.return_value.send.assert_called_once() 101 | 102 | 103 | def test_request_with_overloaded_timeout(mock_request): 104 | ignition.set_default_timeout(8) 105 | ignition.request("//test", timeout=12) 106 | 107 | mock_request.assert_called_once() 108 | 109 | ( 110 | _, 111 | _, 112 | request_timeout, 113 | _, 114 | _, 115 | _, 116 | ) = _destructure_request_args(mock_request) 117 | 118 | assert request_timeout == 12 119 | 120 | mock_request.send_assert_called_once() 121 | 122 | 123 | def test_request_with_hosts_file(mock_request): 124 | ignition.set_default_hosts_file(".my_hosts_file") 125 | ignition.request("//test") 126 | 127 | mock_request.assert_called_once() 128 | 129 | ( 130 | _, 131 | cert_store, 132 | _, 133 | _, 134 | _, 135 | _, 136 | ) = _destructure_request_args(mock_request) 137 | 138 | assert cert_store.get_hosts_file() == ".my_hosts_file" 139 | 140 | mock_request.return_value.send.assert_called_once() 141 | 142 | 143 | def test_url(mock_request): 144 | ignition.url("//test") 145 | 146 | mock_request.assert_called_once_with("//test", referer=None) 147 | 148 | mock_request.return_value.get_url.assert_called_once() 149 | 150 | 151 | def test_url_with_referer(mock_request): 152 | ignition.url("path", referer="//test") 153 | 154 | mock_request.assert_called_once_with("path", referer="//test") 155 | 156 | mock_request.return_value.get_url.assert_called_once() 157 | 158 | 159 | def test_instance_objects(): 160 | assert ignition.InputResponse is not None 161 | assert ignition.SuccessResponse is not None 162 | assert ignition.RedirectResponse is not None 163 | assert ignition.TempFailureResponse is not None 164 | assert ignition.PermFailureResponse is not None 165 | assert ignition.ClientCertRequiredResponse is not None 166 | assert ignition.ErrorResponse is not None 167 | 168 | 169 | def test_constants(): 170 | assert ignition.RESPONSE_STATUS_ERROR == "0" 171 | assert ignition.RESPONSE_STATUS_INPUT == "1" 172 | assert ignition.RESPONSE_STATUS_SUCCESS == "2" 173 | assert ignition.RESPONSE_STATUS_REDIRECT == "3" 174 | assert ignition.RESPONSE_STATUS_TEMP_FAILURE == "4" 175 | assert ignition.RESPONSE_STATUS_PERM_FAILURE == "5" 176 | assert ignition.RESPONSE_STATUS_CLIENTCERT_REQUIRED == "6" 177 | 178 | # Lighter test assertions on the details is fine 179 | assert ignition.RESPONSE_STATUSDETAIL_ERROR_NETWORK is not None 180 | assert ignition.RESPONSE_STATUSDETAIL_ERROR_DNS is not None 181 | assert ignition.RESPONSE_STATUSDETAIL_ERROR_HOST is not None 182 | assert ignition.RESPONSE_STATUSDETAIL_ERROR_TLS is not None 183 | assert ignition.RESPONSE_STATUSDETAIL_ERROR_PROTOCOL is not None 184 | assert ignition.RESPONSE_STATUSDETAIL_INPUT is not None 185 | assert ignition.RESPONSE_STATUSDETAIL_INPUT_SENSITIVE is not None 186 | assert ignition.RESPONSE_STATUSDETAIL_SUCCESS is not None 187 | assert ignition.RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY is not None 188 | assert ignition.RESPONSE_STATUSDETAIL_REDIRECT_PERMANENT is not None 189 | assert ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE is not None 190 | assert ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_UNAVAILABLE is not None 191 | assert ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_CGI is not None 192 | assert ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_PROXY is not None 193 | assert ignition.RESPONSE_STATUSDETAIL_TEMP_FAILURE_SLOW_DOWN is not None 194 | assert ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE is not None 195 | assert ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_NOT_FOUND is not None 196 | assert ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_GONE is not None 197 | assert ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_PROXY_REFUSED is not None 198 | assert ignition.RESPONSE_STATUSDETAIL_PERM_FAILURE_BAD_REQUEST is not None 199 | assert ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED is not None 200 | assert ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_AUTHORIZED is not None 201 | assert ignition.RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED_NOT_VALID is not None 202 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-function-docstring 9 | 10 | from ignition.request import Request 11 | 12 | request = Request( 13 | "software/", referer="gemini://geminiprotocol.net/", request_timeout=30 14 | ) 15 | 16 | 17 | def test_get_url(): 18 | assert request.get_url() == "gemini://geminiprotocol.net/software/" 19 | 20 | 21 | def test_send(): 22 | print("Requests.send() test skipped; it's currently too complex to test.") 23 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-class-docstring,missing-function-docstring 9 | 10 | from unittest import TestCase 11 | 12 | from ignition.globals import ( 13 | RESPONSE_STATUS_CLIENTCERT_REQUIRED, 14 | RESPONSE_STATUS_ERROR, 15 | RESPONSE_STATUS_INPUT, 16 | RESPONSE_STATUS_PERM_FAILURE, 17 | RESPONSE_STATUS_REDIRECT, 18 | RESPONSE_STATUS_SUCCESS, 19 | RESPONSE_STATUS_TEMP_FAILURE, 20 | RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED, 21 | RESPONSE_STATUSDETAIL_ERROR_DNS, 22 | RESPONSE_STATUSDETAIL_ERROR_PROTOCOL, 23 | RESPONSE_STATUSDETAIL_INPUT, 24 | RESPONSE_STATUSDETAIL_PERM_FAILURE, 25 | RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY, 26 | RESPONSE_STATUSDETAIL_SUCCESS, 27 | RESPONSE_STATUSDETAIL_TEMP_FAILURE, 28 | ) 29 | from ignition.response import ( 30 | ClientCertRequiredResponse, 31 | ErrorResponse, 32 | InputResponse, 33 | PermFailureResponse, 34 | RedirectResponse, 35 | ResponseFactory, 36 | SuccessResponse, 37 | TempFailureResponse, 38 | ) 39 | 40 | 41 | class ResponseFactoryTests(TestCase): 42 | def test_creates_input_response(self): 43 | response_object1 = ResponseFactory.create( 44 | "url", "10", meta="Some input", raw_body=b"" 45 | ) 46 | response_object2 = ResponseFactory.create("url", "11", meta="", raw_body=b"") 47 | self.assertIsInstance(response_object1, InputResponse) 48 | self.assertIsInstance(response_object2, InputResponse) 49 | 50 | def test_creates_success_response(self): 51 | response_object = ResponseFactory.create( 52 | "url", "20", meta="text/gemini", raw_body=b"Some body" 53 | ) 54 | self.assertIsInstance(response_object, SuccessResponse) 55 | 56 | def test_creates_redirect_response(self): 57 | response_object1 = ResponseFactory.create("url", "30", meta="", raw_body=b"") 58 | response_object2 = ResponseFactory.create("url", "31", meta="", raw_body=b"") 59 | self.assertIsInstance(response_object1, RedirectResponse) 60 | self.assertIsInstance(response_object2, RedirectResponse) 61 | 62 | def test_creates_temp_failure_response(self): 63 | response_object1 = ResponseFactory.create("url", "40", meta="", raw_body=b"") 64 | response_object2 = ResponseFactory.create("url", "44", meta="", raw_body=b"") 65 | self.assertIsInstance(response_object1, TempFailureResponse) 66 | self.assertIsInstance(response_object2, TempFailureResponse) 67 | 68 | def test_creates_perm_failure_response(self): 69 | response_object1 = ResponseFactory.create("url", "50", meta="", raw_body=b"") 70 | response_object2 = ResponseFactory.create("url", "57", meta="", raw_body=b"") 71 | self.assertIsInstance(response_object1, PermFailureResponse) 72 | self.assertIsInstance(response_object2, PermFailureResponse) 73 | 74 | def test_creates_client_cert_required_response(self): 75 | response_object1 = ResponseFactory.create("url", "60", meta="", raw_body=b"") 76 | response_object2 = ResponseFactory.create("url", "61", meta="", raw_body=b"") 77 | self.assertIsInstance(response_object1, ClientCertRequiredResponse) 78 | self.assertIsInstance(response_object2, ClientCertRequiredResponse) 79 | 80 | def test_creates_error_response(self): 81 | response_object1 = ResponseFactory.create("url", "01", meta="", raw_body=b"") 82 | response_object2 = ResponseFactory.create("url", "08", meta="", raw_body=b"") 83 | response_object3 = ResponseFactory.create("url", "99", meta="", raw_body=b"") 84 | response_object4 = ResponseFactory.create("url", "ab", meta="", raw_body=b"") 85 | self.assertIsInstance(response_object1, ErrorResponse) 86 | self.assertIsInstance(response_object2, ErrorResponse) 87 | self.assertIsInstance(response_object3, ErrorResponse) 88 | self.assertIsInstance(response_object4, ErrorResponse) 89 | 90 | 91 | class InputResponseTests(TestCase): 92 | """ 93 | Handles InputResponse type 94 | """ 95 | 96 | def setUp(self): 97 | self.response = ResponseFactory.create( 98 | "gemini://test.com/", 99 | RESPONSE_STATUSDETAIL_INPUT, 100 | meta="Enter a username", 101 | certificate="dummy cert object", 102 | ) 103 | 104 | def test_url(self): 105 | self.assertEqual(self.response.url, "gemini://test.com/") 106 | 107 | def test_basic_status(self): 108 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_INPUT) 109 | 110 | def test_status(self): 111 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_INPUT) 112 | 113 | def test_meta(self): 114 | self.assertEqual(self.response.meta, "Enter a username") 115 | 116 | def test_raw_body(self): 117 | self.assertEqual(self.response.raw_body, None) 118 | 119 | def test_is_a(self): 120 | self.assertEqual(self.response.is_a(InputResponse), True) 121 | self.assertEqual(self.response.is_a(ErrorResponse), False) 122 | self.assertEqual(self.response.is_a(SuccessResponse), False) 123 | 124 | def test_success(self): 125 | self.assertEqual(self.response.success(), False) 126 | 127 | def test_data(self): 128 | self.assertEqual(self.response.data(), "Enter a username") 129 | 130 | def test_certificate(self): 131 | self.assertEqual(self.response.certificate, "dummy cert object") 132 | 133 | 134 | class SuccessResponseTests(TestCase): 135 | """ 136 | Handles SuccessResponse type 137 | """ 138 | 139 | def setUp(self): 140 | self.response = ResponseFactory.create( 141 | "gemini://test.com/", 142 | RESPONSE_STATUSDETAIL_SUCCESS, 143 | meta="text/gemini; charset=utf-8", 144 | raw_body=b"This is a sample body\r\n\r\nHello", 145 | certificate="dummy cert object", 146 | ) 147 | 148 | def test_url(self): 149 | self.assertEqual(self.response.url, "gemini://test.com/") 150 | 151 | def test_basic_status(self): 152 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_SUCCESS) 153 | 154 | def test_status(self): 155 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_SUCCESS) 156 | 157 | def test_meta(self): 158 | self.assertEqual(self.response.meta, "text/gemini; charset=utf-8") 159 | 160 | def test_raw_body(self): 161 | self.assertEqual(self.response.raw_body, b"This is a sample body\r\n\r\nHello") 162 | 163 | def test_is_a(self): 164 | self.assertEqual(self.response.is_a(SuccessResponse), True) 165 | self.assertEqual(self.response.is_a(ErrorResponse), False) 166 | 167 | def test_success(self): 168 | self.assertEqual(self.response.success(), True) 169 | 170 | def test_data(self): 171 | self.assertEqual(self.response.data(), "This is a sample body\r\n\r\nHello") 172 | 173 | def test_certificate(self): 174 | self.assertEqual(self.response.certificate, "dummy cert object") 175 | 176 | 177 | class RedirectResponseTests(TestCase): 178 | """ 179 | Handles RedirectResponse type 180 | """ 181 | 182 | def setUp(self): 183 | self.response = ResponseFactory.create( 184 | "gemini://test.com/", 185 | RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY, 186 | meta="gemini://test-new.com/", 187 | certificate="dummy cert object", 188 | ) 189 | 190 | def test_url(self): 191 | self.assertEqual(self.response.url, "gemini://test.com/") 192 | 193 | def test_basic_status(self): 194 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_REDIRECT) 195 | 196 | def test_status(self): 197 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_REDIRECT_TEMPORARY) 198 | 199 | def test_meta(self): 200 | self.assertEqual(self.response.meta, "gemini://test-new.com/") 201 | 202 | def test_raw_body(self): 203 | self.assertEqual(self.response.raw_body, None) 204 | 205 | def test_is_a(self): 206 | self.assertEqual(self.response.is_a(RedirectResponse), True) 207 | self.assertEqual(self.response.is_a(ErrorResponse), False) 208 | self.assertEqual(self.response.is_a(SuccessResponse), False) 209 | 210 | def test_success(self): 211 | self.assertEqual(self.response.success(), False) 212 | 213 | def test_data(self): 214 | self.assertEqual(self.response.data(), "gemini://test-new.com/") 215 | 216 | def test_certificate(self): 217 | self.assertEqual(self.response.certificate, "dummy cert object") 218 | 219 | 220 | class TempFailureResponseTests(TestCase): 221 | """ 222 | Handles TempFailureResponse type 223 | """ 224 | 225 | def setUp(self): 226 | self.response = ResponseFactory.create( 227 | "gemini://test.com/", 228 | RESPONSE_STATUSDETAIL_TEMP_FAILURE, 229 | meta="The server had trouble processing your response. Please try again.", 230 | certificate="dummy cert object", 231 | ) 232 | 233 | def test_url(self): 234 | self.assertEqual(self.response.url, "gemini://test.com/") 235 | 236 | def test_basic_status(self): 237 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_TEMP_FAILURE) 238 | 239 | def test_status(self): 240 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_TEMP_FAILURE) 241 | 242 | def test_meta(self): 243 | self.assertEqual( 244 | self.response.meta, 245 | "The server had trouble processing your response. Please try again.", 246 | ) 247 | 248 | def test_raw_body(self): 249 | self.assertEqual(self.response.raw_body, None) 250 | 251 | def test_is_a(self): 252 | self.assertEqual(self.response.is_a(TempFailureResponse), True) 253 | self.assertEqual(self.response.is_a(ErrorResponse), False) 254 | self.assertEqual(self.response.is_a(SuccessResponse), False) 255 | 256 | def test_success(self): 257 | self.assertEqual(self.response.success(), False) 258 | 259 | def test_data(self): 260 | self.assertEqual( 261 | self.response.data(), 262 | "40 The server had trouble processing your response. Please try again.", 263 | ) 264 | 265 | def test_certificate(self): 266 | self.assertEqual(self.response.certificate, "dummy cert object") 267 | 268 | 269 | class PermFailureResponseTests(TestCase): 270 | """ 271 | Handles PermFailureResponse type 272 | """ 273 | 274 | def setUp(self): 275 | self.response = ResponseFactory.create( 276 | "gemini://test.com/", 277 | RESPONSE_STATUSDETAIL_PERM_FAILURE, 278 | meta="There was a permanent error on this page.", 279 | certificate="dummy cert object", 280 | ) 281 | 282 | def test_url(self): 283 | self.assertEqual(self.response.url, "gemini://test.com/") 284 | 285 | def test_basic_status(self): 286 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_PERM_FAILURE) 287 | 288 | def test_status(self): 289 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_PERM_FAILURE) 290 | 291 | def test_meta(self): 292 | self.assertEqual( 293 | self.response.meta, "There was a permanent error on this page." 294 | ) 295 | 296 | def test_raw_body(self): 297 | self.assertEqual(self.response.raw_body, None) 298 | 299 | def test_is_a(self): 300 | self.assertEqual(self.response.is_a(PermFailureResponse), True) 301 | self.assertEqual(self.response.is_a(ErrorResponse), False) 302 | self.assertEqual(self.response.is_a(SuccessResponse), False) 303 | 304 | def test_success(self): 305 | self.assertEqual(self.response.success(), False) 306 | 307 | def test_data(self): 308 | self.assertEqual( 309 | self.response.data(), "50 There was a permanent error on this page." 310 | ) 311 | 312 | def test_certificate(self): 313 | self.assertEqual(self.response.certificate, "dummy cert object") 314 | 315 | 316 | class ClientCertRequiredResponseTests(TestCase): 317 | """ 318 | Handles ClientCertRequiredResponse type 319 | """ 320 | 321 | def setUp(self): 322 | self.response = ResponseFactory.create( 323 | "gemini://test.com/", 324 | RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED, 325 | meta="Please create a client certificate for this request.", 326 | certificate="dummy cert object", 327 | ) 328 | 329 | def test_url(self): 330 | self.assertEqual(self.response.url, "gemini://test.com/") 331 | 332 | def test_basic_status(self): 333 | self.assertEqual( 334 | self.response.basic_status, RESPONSE_STATUS_CLIENTCERT_REQUIRED 335 | ) 336 | 337 | def test_status(self): 338 | self.assertEqual( 339 | self.response.status, RESPONSE_STATUSDETAIL_CLIENTCERT_REQUIRED 340 | ) 341 | 342 | def test_meta(self): 343 | self.assertEqual( 344 | self.response.meta, "Please create a client certificate for this request." 345 | ) 346 | 347 | def test_raw_body(self): 348 | self.assertEqual(self.response.raw_body, None) 349 | 350 | def test_is_a(self): 351 | self.assertEqual(self.response.is_a(ClientCertRequiredResponse), True) 352 | self.assertEqual(self.response.is_a(ErrorResponse), False) 353 | self.assertEqual(self.response.is_a(SuccessResponse), False) 354 | 355 | def test_success(self): 356 | self.assertEqual(self.response.success(), False) 357 | 358 | def test_data(self): 359 | self.assertEqual( 360 | self.response.data(), "Please create a client certificate for this request." 361 | ) 362 | 363 | def test_certificate(self): 364 | self.assertEqual(self.response.certificate, "dummy cert object") 365 | 366 | 367 | class ErrorResponseTests(TestCase): 368 | """ 369 | Handles ErrorResponse type 370 | """ 371 | 372 | def setUp(self): 373 | self.response = ResponseFactory.create( 374 | "gemini://test.com/", 375 | RESPONSE_STATUSDETAIL_ERROR_DNS, 376 | meta="Could not find a host at test.com.", 377 | ) 378 | 379 | def test_url(self): 380 | self.assertEqual(self.response.url, "gemini://test.com/") 381 | 382 | def test_basic_status(self): 383 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_ERROR) 384 | 385 | def test_status(self): 386 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_ERROR_DNS) 387 | 388 | def test_meta(self): 389 | self.assertEqual(self.response.meta, "Could not find a host at test.com.") 390 | 391 | def test_raw_body(self): 392 | self.assertEqual(self.response.raw_body, None) 393 | 394 | def test_is_a(self): 395 | self.assertEqual(self.response.is_a(ErrorResponse), True) 396 | self.assertEqual(self.response.is_a(SuccessResponse), False) 397 | 398 | def test_success(self): 399 | self.assertEqual(self.response.success(), False) 400 | 401 | def test_data(self): 402 | self.assertEqual(self.response.data(), "Could not find a host at test.com.") 403 | 404 | def test_certificate(self): 405 | self.assertEqual(self.response.certificate, None) 406 | 407 | 408 | class ErrorResponseUnknownStatusTests(TestCase): 409 | """ 410 | Handles special ErrorResponse type for unmapped responses 411 | Note: other bad status responses (characters, not matching gemini scheme get caught upstream for now) 412 | """ 413 | 414 | def setUp(self): 415 | self.response = ResponseFactory.create( 416 | "gemini://test.com/", "99", meta="THIS IS A BAD RESPONSE" 417 | ) 418 | 419 | def test_url(self): 420 | self.assertEqual(self.response.url, "gemini://test.com/") 421 | 422 | def test_basic_status(self): 423 | self.assertEqual(self.response.basic_status, RESPONSE_STATUS_ERROR) 424 | 425 | def test_status(self): 426 | self.assertEqual(self.response.status, RESPONSE_STATUSDETAIL_ERROR_PROTOCOL) 427 | 428 | def test_meta(self): 429 | self.assertEqual( 430 | self.response.meta, 431 | "Invalid response received from the server, status code: 99", 432 | ) 433 | 434 | def test_raw_body(self): 435 | self.assertEqual(self.response.raw_body, None) 436 | 437 | def test_is_a(self): 438 | self.assertEqual(self.response.is_a(ErrorResponse), True) 439 | self.assertEqual(self.response.is_a(SuccessResponse), False) 440 | 441 | def test_success(self): 442 | self.assertEqual(self.response.success(), False) 443 | 444 | def test_data(self): 445 | self.assertEqual( 446 | self.response.data(), 447 | "Invalid response received from the server, status code: 99", 448 | ) 449 | 450 | def test_certificate(self): 451 | self.assertEqual(self.response.certificate, None) 452 | 453 | 454 | class SuccessResponseAdvancedTests(TestCase): 455 | # TODO: More advanced tests around the success body response. pylint: disable=fixme 456 | def test_default_metadata(self): 457 | pass 458 | 459 | def test_utf8_encoding(self): 460 | pass 461 | 462 | def test_other_encodings(self): 463 | pass 464 | -------------------------------------------------------------------------------- /tests/test_url.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-function-docstring 9 | 10 | import pytest 11 | 12 | from ignition.url import URL 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "test_url", 17 | [ 18 | "gemini://geminiprotocol.net/", 19 | "//geminiprotocol.net/", 20 | "gemini://geminiprotocol.net:1965/", 21 | "//geminiprotocol.net:1965/", 22 | " gemini://geminiprotocol.net:1965/", 23 | " //geminiprotocol.net:1965/", 24 | ], 25 | ) 26 | def test_standard_gemini_url(test_url): 27 | final_url = URL(test_url) 28 | assert str(final_url) == "gemini://geminiprotocol.net/" 29 | assert final_url.protocol() == "gemini://" 30 | assert final_url.host() == "geminiprotocol.net" 31 | assert final_url.port() == 1965 32 | assert final_url.path() == "/" 33 | assert final_url.query() == "" 34 | 35 | 36 | def test_url_with_different_scheme(): 37 | final_url = URL("https://geminiprotocol.net/") 38 | assert str(final_url) == "https://geminiprotocol.net/" 39 | assert final_url.protocol() == "https://" 40 | assert final_url.host() == "geminiprotocol.net" 41 | assert final_url.port() == 1965 42 | assert final_url.path() == "/" 43 | assert final_url.query() == "" 44 | 45 | 46 | def test_url_with_nonstandard_port(): 47 | final_url = URL("gemini://geminiprotocol.net:80/") 48 | assert str(final_url) == "gemini://geminiprotocol.net:80/" 49 | assert final_url.protocol() == "gemini://" 50 | assert final_url.host() == "geminiprotocol.net" 51 | assert final_url.port() == 80 52 | assert final_url.path() == "/" 53 | assert final_url.query() == "" 54 | 55 | 56 | @pytest.mark.parametrize("test_path", ["", "/", "/test/path.gmi"]) 57 | @pytest.mark.parametrize("test_query", ["", "abc", "user=name"]) 58 | def test_url_with_basic_paths_and_queries(test_path, test_query): 59 | test_url = "gemini://geminiprotocol.net" + test_path 60 | if test_query: 61 | test_url += "?" + test_query 62 | 63 | final_url = URL(test_url) 64 | 65 | assert str(final_url) == test_url 66 | assert final_url.protocol() == "gemini://" 67 | assert final_url.host() == "geminiprotocol.net" 68 | assert final_url.port() == 1965 69 | assert final_url.path() == test_path 70 | assert final_url.query() == test_query 71 | 72 | 73 | def test_url_with_convoluted_path(): 74 | final_url = URL("gemini://geminiprotocol.net/test/./test2/../path.gmi") 75 | assert str(final_url) == "gemini://geminiprotocol.net/test/path.gmi" 76 | assert final_url.protocol() == "gemini://" 77 | assert final_url.host() == "geminiprotocol.net" 78 | assert final_url.port() == 1965 79 | assert final_url.path() == "/test/path.gmi" 80 | assert final_url.query() == "" 81 | 82 | 83 | def test_standard_gemini_url_with_referer(): 84 | final_url = URL("gemini://gus.guru/", referer_url="gemini://geminiprotocol.net/") 85 | assert str(final_url) == "gemini://gus.guru/" 86 | assert final_url.protocol() == "gemini://" 87 | assert final_url.host() == "gus.guru" 88 | assert final_url.port() == 1965 89 | assert final_url.path() == "/" 90 | assert final_url.query() == "" 91 | 92 | 93 | def test_url_without_scheme_with_referer(): 94 | final_url = URL("//gus.guru/", referer_url="gemini://geminiprotocol.net/") 95 | assert str(final_url) == "gemini://gus.guru/" 96 | assert final_url.protocol() == "gemini://" 97 | assert final_url.host() == "gus.guru" 98 | assert final_url.port() == 1965 99 | assert final_url.path() == "/" 100 | assert final_url.query() == "" 101 | 102 | 103 | def test_absolute_path_url(): 104 | final_url = URL("/home", referer_url="gemini://gus.guru/search/page2") 105 | assert str(final_url) == "gemini://gus.guru/home" 106 | assert final_url.protocol() == "gemini://" 107 | assert final_url.host() == "gus.guru" 108 | assert final_url.port() == 1965 109 | assert final_url.path() == "/home" 110 | assert final_url.query() == "" 111 | 112 | 113 | def test_relative_path_url_with_referer(): 114 | final_url = URL("page1", referer_url="gemini://gus.guru/search/page2") 115 | assert str(final_url) == "gemini://gus.guru/search/page1" 116 | assert final_url.protocol() == "gemini://" 117 | assert final_url.host() == "gus.guru" 118 | assert final_url.port() == 1965 119 | assert final_url.path() == "/search/page1" 120 | assert final_url.query() == "" 121 | 122 | 123 | def test_relative_path_url_with_trailing_slash_with_referer(): 124 | final_url = URL("page1/", referer_url="gemini://gus.guru/") 125 | assert str(final_url) == "gemini://gus.guru/page1/" 126 | assert final_url.protocol() == "gemini://" 127 | assert final_url.host() == "gus.guru" 128 | assert final_url.port() == 1965 129 | assert final_url.path() == "/page1/" 130 | assert final_url.query() == "" 131 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | This Source Code Form is subject to the terms of the 3 | Mozilla Public License, v. 2.0. If a copy of the MPL 4 | was not distributed with this file, You can obtain one 5 | at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | # pylint:disable=missing-function-docstring 9 | 10 | from ignition.util import TimeoutManager, normalize_path 11 | 12 | 13 | def test_base_normalize_path(): 14 | # Base test case 15 | assert normalize_path("/abc/def") == "/abc/def" 16 | 17 | # . test 18 | assert normalize_path("/abc/./def") == "/abc/def" 19 | 20 | # .. test 21 | assert normalize_path("/abc/../def") == "/def" 22 | 23 | # End . test 24 | assert normalize_path("/abc/def/.") == "/abc/def" 25 | 26 | # End .. test 27 | assert normalize_path("/abc/def/..") == "/abc" 28 | 29 | # Start . test 30 | assert normalize_path("./abc/def") == "abc/def" 31 | 32 | # Start .. test 33 | assert normalize_path("../abc/def") == "abc/def" 34 | 35 | # Complex string 36 | assert normalize_path("/a/b/c/./../../g") == "/a/g" 37 | 38 | # Weird base cases 39 | assert normalize_path("") == "" 40 | assert normalize_path("/") == "/" 41 | 42 | 43 | def test_timeout_manager(): 44 | timeout_manager = TimeoutManager(10) 45 | 46 | # Handle initialization and override 47 | assert timeout_manager.get_timeout(None) == 10 48 | assert timeout_manager.get_timeout(20) == 20 49 | 50 | # Handle reset and override 51 | timeout_manager.set_default_timeout(12) 52 | 53 | assert timeout_manager.get_timeout(None) == 12 54 | assert timeout_manager.get_timeout(15) == 15 55 | --------------------------------------------------------------------------------