├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── qnxmount ├── __init__.py ├── __main__.py ├── efs │ ├── __init__.py │ ├── interface.py │ ├── mount.py │ ├── parser.ksy │ └── parser.py ├── etfs │ ├── __init__.py │ ├── interface.py │ ├── mount.py │ ├── parser.ksy │ └── parser.py ├── logger.py ├── qnx6 │ ├── __init__.py │ ├── interface.py │ ├── mount.py │ ├── parser.ksy │ └── parser.py └── stream.py └── tests ├── __init__.py ├── qnx6 ├── __init__.py ├── conftest.py ├── test_data │ ├── make_test_fs.sh │ ├── test_image.bin │ └── test_image.tar.gz └── test_qnx6fs.py ├── qnx_efs ├── __init__.py ├── conftest.py ├── test_data │ ├── efs.bld │ ├── make_test_fs.sh │ ├── test_image.bin │ └── test_image.tar.gz └── test_qnx_efs.py └── qnx_etfs ├── __init__.py ├── conftest.py ├── test_data ├── etfs.bld ├── make_test_fs.sh ├── old_test_image.bin ├── old_test_image.tar.gz ├── test_image.bin ├── test_image.tar.gz └── test_transactions.etfs └── test_qnx_etfs.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | dist/ 3 | .pytest_cache/ 4 | .idea/ 5 | .vscode/ 6 | *.egg-info/ 7 | .gitattributes 8 | test_env 9 | build/ 10 | venv* 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QNX Filesystems Mounter 2 | 3 | ## Project Discription 4 | 5 | This project contains code to parse and mount (read only) QNX filesystems in non-standard images (HDD / SSD / eMMC). 6 | 7 | Existing tools were not able to handle the exotic configurations of some of the filesystems that we encountered in vehicle forensics, for instance on blocksizes greater than 4K on qnx6 filesystems, or non-standard allignment on qnx efs filesystems. 8 | 9 | The description of the binary data structure of these filesystems is done with [kaitai](https://kaitai.io/) and this description can be found in the `.ksy` files in the folders for each respective qnx filesystem ([qnx6](qnxmount/qnx6/parser.ksy), [etfs](qnxmount/etfs/parser.ksy), and [efs](qnxmount/efs/parser.ksy)). With Kaitai, a Python based parser was generated. Mounting with these parsers is based on fuse. 10 | 11 | This project is only tested on Linux machines. 12 | 13 | 14 | ## Getting started 15 | 16 | Set up your Python virtual environment and activate the environment: 17 | ```commandline 18 | python3 -m venv venv 19 | source ./venv/bin/activate 20 | ``` 21 | Install qnxmount and fuse in the virtual environment: 22 | ```commandline 23 | pip install qnxmount 24 | sudo apt install fuse 25 | ``` 26 | 27 | 31 | 32 | 33 | ## Usage 34 | 35 | General use of the module is as follows: 36 | ```shell 37 | python3 -m qnxmount {fs_type} [options] /image /mountpoint 38 | ``` 39 | where `fs_type` is the filesystem type (qnx6, etfs, or efs) and options are the options for that filesystem type. 40 | 41 | The options are different for each filesystem type. An overview is given below. For more information use the help option. 42 | ```shell 43 | python3 -m qnxmount qnx6 [-o OFFSET] /image /mountpoint 44 | python3 -m qnxmount etfs [-o OFFSET] [-s PAGE_SIZE] /image /mountpoint 45 | python3 -m qnxmount efs /image /mountpoint 46 | ``` 47 | 48 | Note that the offset and page size can be entered in decimal, octal, binary, or hexadecimal format. For example, we can mount an image with a qnx6 filesystem at offset 0x1000 with: 49 | ```shell 50 | python3 -m qnxmount qnx6 -o 0x1000 /image /mountpoint 51 | ``` 52 | Using the option `-o 4096` would give the same result. 53 | 54 | If mounting succeeds you will see the log message `"Mounting image /image on mount point /mountpoint"` appear and the process will hang. Navigate to the given mount point with another terminal session or a file browser to access the file system. 55 | 56 | Unmounting can be done from the terminal with: 57 | ```shell 58 | sudo umount /mountpoint 59 | ``` 60 | The logs will show show that the image was successfully unmounted and qnxmount will exit. 61 | 62 | ## Contributing and Testing 63 | 64 | If you want develop the tool and run tests, first fork the repository. Contributions can be submitted as a merge request. 65 | 66 | To get started clone the forked repository and create a virtual environment. Install the test dependencies and fuse into the environment. 67 | ```commandline 68 | pip install .[test] 69 | sudo apt install fuse 70 | ``` 71 | 72 | The folder **tests** contains functional tests to test the different parsers. 73 | To run these tests you need a file system image and an accompanying tar archive. 74 | The tests run are functional tests that check whether the parsed data from the test image is equal to the data stored in the archive. 75 | Default test_images are located in the folders **test_data**. 76 | If you want to test your own image replace the files **test_image.bin** and **test_image.tar.gz** with your own. 77 | 78 | A test image can be created by running the script `make_test_fs.sh` inside a QNX Virtual Machine. 79 | Update the script with the (edge) cases you want to check and run the command below. 80 | This should create an _image.bin_ and _image.tar.gz_ into the specified directory. 81 | These can be used as test files. 82 | ```shell 83 | make_test_fs.sh /path/to/output/directory 84 | ``` 85 | 86 | To run the tests in this repo navigate to the main directory of the repo and run: 87 | ```shell 88 | pytest 89 | ``` 90 | 91 | [//]: # (Usually, tests can be run by directly calling `pytest tests --image ... --tar ...`, however this method fails here.) 92 | [//]: # (The reason is that the tests are located in a separate subfolder from the **qnx6_file_system.py**. ) 93 | [//]: # (The qnx6_file_system module cannot be imported because it is not located in the tests directory.) 94 | [//]: # (When python3 is called it adds '.' to the PATH and since the qnx6_file_system module is located in the working directory they can be found.) 95 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "attrs" 5 | version = "22.2.0" 6 | description = "Classes Without Boilerplate" 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=3.6" 10 | files = [ 11 | {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, 12 | {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, 13 | ] 14 | 15 | [package.extras] 16 | cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] 17 | dev = ["attrs[docs,tests]"] 18 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] 19 | tests = ["attrs[tests-no-zope]", "zope.interface"] 20 | tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] 21 | 22 | [[package]] 23 | name = "colorama" 24 | version = "0.4.6" 25 | description = "Cross-platform colored terminal text." 26 | category = "dev" 27 | optional = false 28 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 29 | files = [ 30 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 31 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 32 | ] 33 | 34 | [[package]] 35 | name = "crcmod" 36 | version = "1.7" 37 | description = "CRC Generator" 38 | category = "main" 39 | optional = false 40 | python-versions = "*" 41 | files = [ 42 | {file = "crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e"}, 43 | ] 44 | 45 | [[package]] 46 | name = "exceptiongroup" 47 | version = "1.1.0" 48 | description = "Backport of PEP 654 (exception groups)" 49 | category = "dev" 50 | optional = false 51 | python-versions = ">=3.7" 52 | files = [ 53 | {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, 54 | {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, 55 | ] 56 | 57 | [package.extras] 58 | test = ["pytest (>=6)"] 59 | 60 | [[package]] 61 | name = "fusepy" 62 | version = "3.0.1" 63 | description = "Simple ctypes bindings for FUSE" 64 | category = "main" 65 | optional = false 66 | python-versions = "*" 67 | files = [ 68 | {file = "fusepy-3.0.1.tar.gz", hash = "sha256:72ff783ec2f43de3ab394e3f7457605bf04c8cf288a2f4068b4cde141d4ee6bd"}, 69 | ] 70 | 71 | [[package]] 72 | name = "importlib-metadata" 73 | version = "6.0.0" 74 | description = "Read metadata from Python packages" 75 | category = "dev" 76 | optional = false 77 | python-versions = ">=3.7" 78 | files = [ 79 | {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, 80 | {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, 81 | ] 82 | 83 | [package.dependencies] 84 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 85 | zipp = ">=0.5" 86 | 87 | [package.extras] 88 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 89 | perf = ["ipython"] 90 | testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] 91 | 92 | [[package]] 93 | name = "iniconfig" 94 | version = "2.0.0" 95 | description = "brain-dead simple config-ini parsing" 96 | category = "dev" 97 | optional = false 98 | python-versions = ">=3.7" 99 | files = [ 100 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 101 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 102 | ] 103 | 104 | [[package]] 105 | name = "kaitaistruct" 106 | version = "0.10" 107 | description = "Kaitai Struct declarative parser generator for binary data: runtime library for Python" 108 | category = "main" 109 | optional = false 110 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 111 | files = [ 112 | {file = "kaitaistruct-0.10-py2.py3-none-any.whl", hash = "sha256:a97350919adbf37fda881f75e9365e2fb88d04832b7a4e57106ec70119efb235"}, 113 | {file = "kaitaistruct-0.10.tar.gz", hash = "sha256:a044dee29173d6afbacf27bcac39daf89b654dd418cfa009ab82d9178a9ae52a"}, 114 | ] 115 | 116 | [[package]] 117 | name = "packaging" 118 | version = "23.0" 119 | description = "Core utilities for Python packages" 120 | category = "dev" 121 | optional = false 122 | python-versions = ">=3.7" 123 | files = [ 124 | {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, 125 | {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, 126 | ] 127 | 128 | [[package]] 129 | name = "pluggy" 130 | version = "1.0.0" 131 | description = "plugin and hook calling mechanisms for python" 132 | category = "dev" 133 | optional = false 134 | python-versions = ">=3.6" 135 | files = [ 136 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 137 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 138 | ] 139 | 140 | [package.dependencies] 141 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 142 | 143 | [package.extras] 144 | dev = ["pre-commit", "tox"] 145 | testing = ["pytest", "pytest-benchmark"] 146 | 147 | [[package]] 148 | name = "pytest" 149 | version = "7.2.1" 150 | description = "pytest: simple powerful testing with Python" 151 | category = "dev" 152 | optional = false 153 | python-versions = ">=3.7" 154 | files = [ 155 | {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, 156 | {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, 157 | ] 158 | 159 | [package.dependencies] 160 | attrs = ">=19.2.0" 161 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 162 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 163 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 164 | iniconfig = "*" 165 | packaging = "*" 166 | pluggy = ">=0.12,<2.0" 167 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 168 | 169 | [package.extras] 170 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 171 | 172 | [[package]] 173 | name = "tomli" 174 | version = "2.0.1" 175 | description = "A lil' TOML parser" 176 | category = "dev" 177 | optional = false 178 | python-versions = ">=3.7" 179 | files = [ 180 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 181 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 182 | ] 183 | 184 | [[package]] 185 | name = "typing-extensions" 186 | version = "4.5.0" 187 | description = "Backported and Experimental Type Hints for Python 3.7+" 188 | category = "dev" 189 | optional = false 190 | python-versions = ">=3.7" 191 | files = [ 192 | {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, 193 | {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, 194 | ] 195 | 196 | [[package]] 197 | name = "zipp" 198 | version = "3.14.0" 199 | description = "Backport of pathlib-compatible object wrapper for zip files" 200 | category = "dev" 201 | optional = false 202 | python-versions = ">=3.7" 203 | files = [ 204 | {file = "zipp-3.14.0-py3-none-any.whl", hash = "sha256:188834565033387710d046e3fe96acfc9b5e86cbca7f39ff69cf21a4128198b7"}, 205 | {file = "zipp-3.14.0.tar.gz", hash = "sha256:9e5421e176ef5ab4c0ad896624e87a7b2f07aca746c9b2aa305952800cb8eecb"}, 206 | ] 207 | 208 | [package.extras] 209 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 210 | testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 211 | 212 | [metadata] 213 | lock-version = "2.0" 214 | python-versions = "^3.7" 215 | content-hash = "36eb129bef729e016df801b9b110a139287001d58ef5dd469b86ecb0c6a1f16e" 216 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "qnxmount" 3 | description = "read only mounters for qnx filesystems" 4 | version = "0.1.9" 5 | readme = "README.md" 6 | license = "Apache-2.0" 7 | authors = ["Francis Hoogendijk ", "Anda Knol "] 8 | repository = "https://github.com/NetherlandsForensicInstitute/qnxmount" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.7" 12 | crcmod = "^1.7" 13 | fusepy = "^3.0.1" 14 | kaitaistruct = "^0.10" 15 | 16 | [tool.poetry.group.test.dependencies] 17 | pytest = "^7.1.2" 18 | 19 | [build-system] 20 | requires = ["poetry-core"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /qnxmount/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/qnxmount/__init__.py -------------------------------------------------------------------------------- /qnxmount/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from argparse import ArgumentParser 4 | from pathlib import Path 5 | 6 | import qnxmount.efs.mount as efs 7 | import qnxmount.etfs.mount as etfs 8 | import qnxmount.qnx6.mount as qnx6 9 | from qnxmount.logger import setup_logging 10 | 11 | LOGGER = logging.getLogger("qnxmount") 12 | 13 | 14 | def mount(args): 15 | LOGGER.info(f"Selected mounter type: {args.type}") 16 | LOGGER.info(f"Mounting image {args.image} on mount point {args.mount_point}") 17 | if args.type == "qnx6": 18 | qnx6.mount(args.image, args.mount_point, args.offset) 19 | elif args.type == "efs": 20 | efs.mount(args.image, args.mount_point) 21 | elif args.type == "etfs": 22 | etfs.mount(args.image, args.mount_point, args.offset, args.page_size) 23 | LOGGER.info(f"Unmounting image {args.image} from mount point {args.mount_point}") 24 | 25 | 26 | if __name__ == "__main__": 27 | parent_parser = ArgumentParser(description="The parent parser", add_help=False) 28 | parent_parser.add_argument("image", type=Path, help="Path to image containing qnx file system") 29 | parent_parser.add_argument("mount_point", type=Path, help="Path to mount point") 30 | 31 | main_parser = ArgumentParser(prog="qnxmount") 32 | subparsers = main_parser.add_subparsers(title="file system types", required=True, dest="type") 33 | parser_qnx6 = subparsers.add_parser("qnx6", parents=[parent_parser], help="Parser for HDD/eMMC images") 34 | parser_qnx6.add_argument( 35 | "-o", "--offset", type=lambda x: int(x, 0), help="Offset of qnx partition in image", default=0 36 | ) 37 | parser_efs = subparsers.add_parser("efs", parents=[parent_parser], help="Parser for NOR flash images") 38 | parser_etfs = subparsers.add_parser("etfs", parents=[parent_parser], help="Parser for NAND flash images") 39 | parser_etfs.add_argument( 40 | "-o", "--offset", type=lambda x: int(x, 0), help="Offset of qnx partition in image", default=0 41 | ) 42 | parser_etfs.add_argument( 43 | "-s", "--page_size", type=lambda x: int(x, 0), help="Size of pages (clusters)", default=2048 44 | ) 45 | 46 | args = main_parser.parse_args() 47 | 48 | setup_logging(LOGGER) 49 | 50 | if not args.image.exists(): 51 | LOGGER.info(f"Image file {args.image} not found, exiting.") 52 | sys.exit(-1) 53 | 54 | if not args.mount_point.exists(): 55 | LOGGER.info(f"Mount point {args.mount_point} not found, exiting.") 56 | sys.exit(-2) 57 | 58 | sys.exit(mount(args)) 59 | -------------------------------------------------------------------------------- /qnxmount/efs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/qnxmount/efs/__init__.py -------------------------------------------------------------------------------- /qnxmount/efs/interface.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from qnxmount.efs.parser import Parser 3 | from kaitaistruct import KaitaiStream 4 | from functools import lru_cache 5 | import mmap 6 | import re 7 | import logging 8 | 9 | LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class KaitaiIO: 13 | """This class implements the basic functionality to create a Kaitai io stream. 14 | 15 | Args: 16 | path (Path): Path to the image. 17 | offset (int): Offset in the image to start the Kaitai stream at. 18 | 19 | Attributes: 20 | path (Path): Path to the image. 21 | f: File handle. 22 | mm (mmap): Memory map of the file. 23 | stream (KaitaiStream): Kaitai data stream of memory-mapped file. 24 | """ 25 | def __init__(self, path, offset=0): 26 | self.path = path 27 | self.f = open(self.path, 'rb') 28 | self.mm = mmap.mmap(self.f.fileno(), length=0, offset=offset, access=mmap.ACCESS_READ) 29 | self.stream = KaitaiStream(self.mm) 30 | 31 | def __del__(self): 32 | self.stream.close() 33 | self.mm.close() 34 | self.f.close() 35 | 36 | 37 | class EFS(KaitaiIO): 38 | """This class implements the logic of the EFS file system and uses the 39 | structures in the class :class:`Parser`. 40 | 41 | Args: 42 | path (Path): Path to image. 43 | boot: Bootinfo object of the EFS. 44 | offset: Start of first unit of the partition in image. 45 | 46 | Attributes: 47 | boot (Parser.BootInfo): Boot info. 48 | parser (Parser): Kaitai generated parser object. 49 | logi_map (Dict[int, Parser.Unit]: Logic mapping of units. 50 | root (Parser.DirEntry): Root of partition. 51 | """ 52 | def __init__(self, path: Path, boot: Parser.BootInfo, offset: int = 0): 53 | super().__init__(path, offset) 54 | self.boot = boot 55 | self.parser = Parser(self.stream) 56 | self.parser._m_units = self.parser.Units(boot.align_pow2, boot.unit_total, self.parser._io, _root=self.parser) 57 | self.logi_map = self.get_logi_map() 58 | root_ext = self.get_ext_ptr(self.boot.root) 59 | self.root = root_ext.as_dir_entry 60 | 61 | def get_logi_map(self): 62 | """Create a mapping of units in logical order. 63 | 64 | Returns: 65 | Dict[int, EfsParset.Unit]: Dictionary with logical mapping. 66 | """ 67 | logi_map = {unit.logi.logi: unit for unit in self.parser.units.unit_list if not is_spare(unit)} 68 | assert list(sorted(logi_map.keys())) == list(range(1, self.boot.unit_total - self.boot.unit_spare + 1)), 'Not all units are accounted for!' 69 | return logi_map 70 | 71 | def get_ext_ptr(self, ptr: Parser.ExtentPtr) -> Parser.Extent: 72 | """Get extent from extent pointer. 73 | 74 | Args: 75 | ptr: Extent pointer 76 | 77 | Returns: 78 | Parser.Extent: Extent 79 | """ 80 | extent = self.logi_map[ptr.logi_unit].extents[ptr.index] 81 | while not extent.header.status0.no_super: 82 | ptr = extent.header.super 83 | extent = self.get_ext_ptr(ptr) 84 | return extent 85 | 86 | def get_extents(self, dir_entry: Parser.DirEntry): 87 | """Get all extents from a directory entry. 88 | 89 | Args: 90 | dir_entry (Parser.DirEntry): Directory entry. 91 | 92 | Yields: 93 | Parser.Extent: All relevant extents in the directory entry. 94 | """ 95 | extent = self.get_ext_ptr(dir_entry.first) 96 | yield extent 97 | 98 | while not extent.header.status0.no_next: 99 | extent = self.get_ext_ptr(extent.header.next) 100 | yield extent 101 | 102 | def read_dir(self, dir_entry: Parser.DirEntry): 103 | """Read a directory. 104 | 105 | Args: 106 | dir_entry (Parser.DirEntry): Directory entry. 107 | 108 | Yields: 109 | Parser.DirEntry: Extent parsed as a directory entry. 110 | """ 111 | for extent in self.get_extents(dir_entry): 112 | if not extent.header.text_size: 113 | break 114 | yield extent.as_dir_entry 115 | 116 | def stat(self, dir_entry: Parser.DirEntry): 117 | """Return stat info of a directory entry. 118 | 119 | Args: 120 | dir_entry (Parser.DirEntry): Directory entry. 121 | 122 | Returns: 123 | int: Mode. 124 | """ 125 | mode = dir_entry.stat.mode 126 | return mode 127 | 128 | def read_file(self, dir_entry: Parser.DirEntry): 129 | """Read contents of a file. 130 | 131 | Args: 132 | dir_entry (Parser.DirEntry): Directory entry. 133 | 134 | Returns: 135 | bytes: Full file content. 136 | """ 137 | return b''.join(extent.text for extent in self.get_extents(dir_entry)) 138 | 139 | @lru_cache(maxsize=128) 140 | def get_dir_entry_from_path(self, path: Path) -> Parser.DirEntry: 141 | """Get dir entry from path. 142 | 143 | Args: 144 | path (Path): path. 145 | 146 | Returns: 147 | Parser.DirEntry: Directory entry. 148 | """ 149 | if not path.name: 150 | return self.root 151 | parent_entry = self.get_dir_entry_from_path(path.parent) 152 | for entry in self.read_dir(parent_entry): 153 | if entry.name == path.name: 154 | return entry 155 | 156 | 157 | def scan_partitions(path): 158 | """Scans a flash image for valid EFS partitions based on the boot_info. 159 | 160 | Args: 161 | path(Path): Path to the image. 162 | 163 | Yields: 164 | EFS: An initialized EFS parser object for each valid partition found. 165 | """ 166 | kaitai_io = KaitaiIO(path) 167 | sig_pattern = re.compile(b'QSSL_F3S', re.MULTILINE) 168 | for hit in re.finditer(sig_pattern, kaitai_io.mm): 169 | stream = kaitai_io.stream 170 | stream.seek(hit.start() - 4) 171 | boot = Parser.BootInfo(stream) 172 | if not boot.is_valid: 173 | continue 174 | 175 | boot_unit_start = hit.start() & 0xfffff000 176 | stream.seek(boot_unit_start) 177 | unit_info = Parser.UnitInfo(stream) 178 | 179 | # TODO maybe add stricter checking if partition is valid 180 | start_offset = boot_unit_start - boot.unit_index * unit_info.unit_size 181 | LOGGER.info(f'Valid partition found at {hex(start_offset)} (with boot entry located at {hex(hit.start())})') 182 | yield EFS(kaitai_io.path, boot, offset=start_offset) 183 | 184 | 185 | def is_spare(unit: Parser.Unit): 186 | """Check if EFS unit is a spare unit. 187 | 188 | Args: 189 | unit (Parser.Unit): EFS unit. 190 | 191 | Returns: 192 | bool: Boolean answer. 193 | """ 194 | if ((len(unit.extents) == 1) or 195 | (len(unit.extents) == 2 and unit.extents[1].header.status1 == 0xffffffff)): 196 | return True 197 | return False 198 | -------------------------------------------------------------------------------- /qnxmount/efs/mount.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import logging 3 | import os 4 | from pathlib import Path 5 | 6 | from fuse import FUSE, FuseOSError, Operations 7 | 8 | from qnxmount.efs.interface import EFS, scan_partitions 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class FuseEFS(Operations): 14 | """Fuse implementation of the EFS file system. 15 | 16 | Args: 17 | partitions (Dict[Path, EFS]): Dictionary of efs partitions 18 | fuse_mount_point (Path): Mount point of FUSE on the host OS. 19 | mount_point_dir_entries (Dict[Path, set]): Dictionary for paths contained in the 20 | mount point path of the partitions. 21 | """ 22 | 23 | def __init__(self, partitions: list[Path, EFS], fuse_mount_point: Path): 24 | self.partitions = partitions 25 | self.fuse_mount_point = fuse_mount_point 26 | self.tree = self.build_dir_entry_tree() 27 | 28 | def build_dir_entry_tree(self): 29 | """Build a default tree for the dummy directory entries on the hard coded mount path to a partition. 30 | 31 | Returns: 32 | Dict[Path, set]: Dictionary with directory entries. 33 | """ 34 | tree = dict() 35 | for partition in self.partitions: 36 | mountpoint = Path(partition.root.name) 37 | t = tree 38 | for path_part in mountpoint.parts[:-1]: 39 | if path_part not in t: 40 | sub_t = dict() 41 | t[path_part] = sub_t 42 | else: 43 | sub_t = t[path_part] 44 | t = sub_t 45 | if mountpoint.parts: 46 | t[mountpoint.parts[-1]] = {"__parser__": partition} 47 | else: 48 | # No hardcoded mountpoint present in partition, use sane default 49 | t['/'] = {"__parser__": partition} 50 | return tree 51 | 52 | @staticmethod 53 | def _step_layer(path_part: str, layer: list): 54 | found_next_layer = False 55 | for item in layer: 56 | if isinstance(item, dict) and path_part in item: 57 | found_next_layer = True 58 | layer = item[path_part] 59 | break 60 | return found_next_layer, layer 61 | 62 | def _fake_stat(self): 63 | # Just return stat of actual host mountpoint 64 | st = os.lstat(self.fuse_mount_point.parent) 65 | return dict( 66 | (key, getattr(st, key)) 67 | for key in ( 68 | "st_atime", 69 | "st_ctime", 70 | "st_gid", 71 | "st_mode", 72 | "st_mtime", 73 | "st_nlink", 74 | "st_size", 75 | "st_uid", 76 | ) 77 | ) 78 | 79 | def _resolve(self, path: str): 80 | path = Path(path) 81 | tree = self.tree 82 | remaining_path = "" 83 | for i, part in enumerate(path.parts): 84 | if part in tree: 85 | tree = tree[part] 86 | else: 87 | remaining_path = "/".join(path.parts[i:]) 88 | break 89 | return remaining_path, tree 90 | 91 | def getattr(self, path, fh=None): 92 | """Get directory with stat information. 93 | 94 | Args: 95 | path (Path): Path. 96 | fh: file handle. 97 | 98 | Returns: 99 | dict: dictionary with keys identical to the stat C structure of stat(2). 100 | """ 101 | LOGGER.debug(f"getattr({path})") 102 | path = Path(path) 103 | remaining_path, tree = self._resolve(path) 104 | if not remaining_path: 105 | return self._fake_stat() 106 | if "__parser__" in tree: 107 | partition = tree["__parser__"] 108 | dir_entry = partition.get_dir_entry_from_path(Path(f"/{remaining_path}")) 109 | if dir_entry: 110 | return dict( 111 | st_size=len(partition.read_file(dir_entry)), 112 | st_nlink=1, 113 | st_mode=dir_entry.stat.mode, 114 | st_ctime=dir_entry.stat.ctime, 115 | st_mtime=dir_entry.stat.mtime, 116 | st_uid=dir_entry.stat.uid, 117 | st_gid=dir_entry.stat.gid, 118 | ) 119 | raise FuseOSError(errno.ENOENT) 120 | 121 | def readdir(self, path, fh): 122 | """Read content from directory. 123 | 124 | Args: 125 | path (Path): Path to directory. 126 | fh: file handle. 127 | 128 | Returns: 129 | dict: dictionary with keys identical to the stat C structure of stat(2). 130 | """ 131 | LOGGER.debug(f"readdir({path})") 132 | path = Path(path) 133 | remaining_path, tree = self._resolve(path) 134 | output = set() 135 | for entry, partition in tree.items(): 136 | if entry == "__parser__" and isinstance(partition, EFS): 137 | dir_entry = partition.get_dir_entry_from_path( 138 | Path(f"/{remaining_path}") 139 | ) 140 | for e in partition.read_dir(dir_entry): 141 | output.add(e.name) 142 | elif isinstance(partition, dict) and not remaining_path: 143 | output.add(entry) 144 | return list(output) 145 | 146 | def read(self, path, size, offset, fh): 147 | """Read content from an object. 148 | 149 | Args: 150 | path (Path): Path to object. 151 | size (int): size of content to be read. 152 | offset (int): starting offset in the file. 153 | fh: file handle. 154 | 155 | Returns: 156 | bytes: Raw file content. 157 | """ 158 | LOGGER.debug(f"read({path}, {size}, {offset})") 159 | path = Path(path) 160 | remaining_path, tree = self._resolve(path) 161 | if not "__parser__" in tree: 162 | return b"" 163 | partition = tree["__parser__"] 164 | dir_entry = partition.get_dir_entry_from_path(Path(f"/{remaining_path}")) 165 | if not dir_entry: 166 | return b"" 167 | return partition.read_file(dir_entry)[offset : offset + size] 168 | 169 | def readlink(self, path): 170 | """Read content from a link given the path. 171 | 172 | Args: 173 | path (Path): Path to symlink. 174 | 175 | Returns: 176 | str: symlink target 177 | """ 178 | LOGGER.debug(f"readlink({path})") 179 | path = Path(path) 180 | remaining_path, tree = self._resolve(path) 181 | if not "__parser__" in tree: 182 | return b"" 183 | partition = tree["__parser__"] 184 | dir_entry = partition.get_dir_entry_from_path(Path(f"/{remaining_path}")) 185 | if not dir_entry: 186 | return b"" 187 | return partition.read_file(dir_entry).decode("utf-8") 188 | 189 | 190 | def initialise_fuse_efs(image, mount_point): 191 | """Initialise the FuseEFS object. 192 | 193 | Args: 194 | image (Path): Path to the image. 195 | mount_point (Path): Path to the mount point on the host system. 196 | 197 | Returns: 198 | FuseEFS: FuseEFS object. 199 | """ 200 | partitions = list(scan_partitions(image)) 201 | return FuseEFS(partitions, mount_point) 202 | 203 | 204 | def mount(image, mount_point): 205 | efs = initialise_fuse_efs(image, mount_point) 206 | FUSE(efs, str(mount_point), nothreads=True, foreground=True) 207 | -------------------------------------------------------------------------------- /qnxmount/efs/parser.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: parser 3 | title: efs_parser 4 | endian: le 5 | bit-endian: le 6 | xref: 7 | headers: 'https://github.com/RunZeJustin/qnx660/blob/47c4158e3993d7536170b649e6c1e09552318fb4/target/qnx6/usr/include/fs/f3s_spec.h' 8 | 9 | instances: 10 | unit_size: 11 | value: unit_shortcut.unit_size 12 | unit_shortcut: 13 | pos: 0 14 | type: unit_info 15 | doc: Needed for bootstrapping the unit size value. 16 | units: 17 | type: units(1,1) 18 | doc: Dummy values to satisfy the compiler, we will manually instantiate it anyway. 19 | 20 | types: 21 | units: 22 | params: 23 | - id: align_pow2 24 | type: u4 25 | - id: num_units 26 | type: u4 27 | seq: 28 | - id: unit_list 29 | size: _root.unit_size 30 | type: unit 31 | repeat: expr 32 | repeat-expr: num_units 33 | 34 | unit: 35 | instances: 36 | raw: 37 | pos: 0 38 | size: _root.unit_size 39 | extents: 40 | type: extent(_index) 41 | repeat: until 42 | repeat-until: '_.header.status0.ext_last' # F3S_EXT_LAST 43 | info: 44 | value: extents[0].special_text.body 45 | logi: 46 | value: extents[1].special_text.body 47 | # boot: 48 | # value: extents[2].special_text.body 49 | # root: 50 | # value: extents[3].special_text.body 51 | # first: 52 | # value: extents[4].special_text.body 53 | 54 | extent: 55 | params: 56 | - id: i 57 | type: u4 58 | instances: 59 | header: 60 | pos: _root.unit_size - sizeof * (i + 1) 61 | size: sizeof 62 | type: extent_header 63 | text_offset: 64 | value: '((header.text_offset_hi << 16) + header.text_offset_lo) << _parent._parent.align_pow2' 65 | text: 66 | pos: text_offset 67 | size: header.text_size 68 | special_text: 69 | pos: text_offset 70 | size: header.text_size 71 | type: special_extent_p(i) 72 | as_dir_entry: 73 | pos: text_offset 74 | size: header.text_size 75 | type: dir_entry 76 | 77 | special_extent_p: 78 | params: 79 | - id: idx 80 | type: u4 81 | seq: 82 | - id: body 83 | type: 84 | switch-on: idx 85 | cases: 86 | 0 : unit_info 87 | 1 : unit_logi 88 | 2 : boot_info 89 | 3 : dir_entry 90 | 4 : dir_entry 91 | doc: Can not cast index to enum 92 | 93 | extent_header: 94 | seq: 95 | - id: status0 96 | type: header_status 97 | - id: status1 98 | type: u4 99 | - id: status2 100 | type: u4 101 | - id: ecc 102 | size: 6 103 | - id: reserve 104 | contents: [0xff] 105 | - id: text_offset_hi 106 | type: u1 107 | - id: text_offset_lo 108 | type: u2 109 | - id: text_size 110 | type: u2 111 | - id: next 112 | type: extent_ptr 113 | - id: super 114 | type: extent_ptr 115 | 116 | header_status: 117 | seq: 118 | - id: no_write 119 | type: b1 120 | - id: no_next 121 | type: b1 122 | - id: no_super 123 | type: b1 124 | - id: no_split 125 | type: b1 126 | - id: condition 127 | type: b3 128 | - id: ext_last 129 | type: b1 130 | - id: type 131 | type: b2 132 | - id: basic 133 | type: b1 134 | - id: pad 135 | type: b21 # 5 + 16 136 | instances: 137 | is_file: 138 | value: 'type == 3' # F3S_EXT_FILE 139 | is_dir: 140 | value: 'type == 2' # F2S_EXT_DIR 141 | is_sys: 142 | value: 'type == 1' # F2S_EXT_SYS 143 | 144 | extent_ptr: 145 | seq: 146 | - id: logi_unit 147 | type: u2 148 | - id: index 149 | type: u2 150 | 151 | unit_info: 152 | seq: 153 | - id: struct_size 154 | type: u2 155 | - id: endian 156 | size: 1 157 | - id: pad 158 | contents: [0xff] 159 | - id: unit_pow2 160 | type: u2 161 | - id: reserve 162 | contents: [0xff, 0xff] 163 | - id: erase_count 164 | type: u4 165 | - id: boot 166 | type: extent_ptr 167 | instances: 168 | unit_size: 169 | value: 1 << unit_pow2 170 | 171 | unit_logi: 172 | seq: 173 | - id: struct_size 174 | type: u2 175 | - id: logi 176 | type: u2 177 | - id: age 178 | type: u4 179 | - id: pad 180 | size: struct_size - 0x18 181 | doc: Default struct size is 0x18. 182 | - id: md5 183 | size: 16 184 | 185 | boot_info: 186 | seq: 187 | - id: struct_size 188 | type: u2 189 | - id: rev_major 190 | type: u1 191 | - id: rev_minor 192 | type: u1 193 | - id: sig 194 | contents: 'QSSL_F3S' 195 | - id: unit_index 196 | type: u2 197 | - id: unit_total 198 | type: u2 199 | - id: unit_spare 200 | type: u2 201 | - id: align_pow2 202 | type: u2 203 | - id: root 204 | type: extent_ptr 205 | instances: 206 | is_valid: 207 | value: 'struct_size == 0x18 and rev_major == 3 and rev_minor == 0' 208 | 209 | stat: 210 | seq: 211 | - id: struct_size 212 | type: u2 213 | - id: mode 214 | type: u2 215 | - id: uid 216 | type: u4 217 | - id: gid 218 | type: u4 219 | - id: mtime 220 | type: u4 221 | - id: ctime 222 | type: u4 223 | 224 | dir_entry: 225 | seq: 226 | - id: struct_size 227 | type: u2 228 | - id: moves 229 | type: u1 230 | - id: namelen 231 | type: u1 232 | - id: first 233 | type: extent_ptr 234 | - id: name 235 | type: strz 236 | encoding: UTF-8 237 | - id: name_pad 238 | size: '((namelen + 3) & 0b11111100) - namelen' 239 | doc: Name with padding is 4-byte aligned. 240 | - id: stat 241 | type: stat 242 | -------------------------------------------------------------------------------- /qnxmount/efs/parser.py: -------------------------------------------------------------------------------- 1 | # This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild 2 | 3 | import kaitaistruct 4 | from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO 5 | 6 | 7 | if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 9): 8 | raise Exception("Incompatible Kaitai Struct Python API: 0.9 or later is required, but you have %s" % (kaitaistruct.__version__)) 9 | 10 | class Parser(KaitaiStruct): 11 | def __init__(self, _io, _parent=None, _root=None): 12 | self._io = _io 13 | self._parent = _parent 14 | self._root = _root if _root else self 15 | self._read() 16 | 17 | def _read(self): 18 | pass 19 | 20 | class Extent(KaitaiStruct): 21 | def __init__(self, i, _io, _parent=None, _root=None): 22 | self._io = _io 23 | self._parent = _parent 24 | self._root = _root if _root else self 25 | self.i = i 26 | self._read() 27 | 28 | def _read(self): 29 | pass 30 | 31 | @property 32 | def text_offset(self): 33 | if hasattr(self, '_m_text_offset'): 34 | return self._m_text_offset 35 | 36 | self._m_text_offset = (((self.header.text_offset_hi << 16) + self.header.text_offset_lo) << self._parent._parent.align_pow2) 37 | return getattr(self, '_m_text_offset', None) 38 | 39 | @property 40 | def text(self): 41 | if hasattr(self, '_m_text'): 42 | return self._m_text 43 | 44 | _pos = self._io.pos() 45 | self._io.seek(self.text_offset) 46 | self._m_text = self._io.read_bytes(self.header.text_size) 47 | self._io.seek(_pos) 48 | return getattr(self, '_m_text', None) 49 | 50 | @property 51 | def as_dir_entry(self): 52 | if hasattr(self, '_m_as_dir_entry'): 53 | return self._m_as_dir_entry 54 | 55 | _pos = self._io.pos() 56 | self._io.seek(self.text_offset) 57 | self._raw__m_as_dir_entry = self._io.read_bytes(self.header.text_size) 58 | _io__raw__m_as_dir_entry = KaitaiStream(BytesIO(self._raw__m_as_dir_entry)) 59 | self._m_as_dir_entry = Parser.DirEntry(_io__raw__m_as_dir_entry, self, self._root) 60 | self._io.seek(_pos) 61 | return getattr(self, '_m_as_dir_entry', None) 62 | 63 | @property 64 | def header(self): 65 | if hasattr(self, '_m_header'): 66 | return self._m_header 67 | 68 | _pos = self._io.pos() 69 | self._io.seek((self._root.unit_size - (32 * (self.i + 1)))) 70 | self._raw__m_header = self._io.read_bytes(32) 71 | _io__raw__m_header = KaitaiStream(BytesIO(self._raw__m_header)) 72 | self._m_header = Parser.ExtentHeader(_io__raw__m_header, self, self._root) 73 | self._io.seek(_pos) 74 | return getattr(self, '_m_header', None) 75 | 76 | @property 77 | def special_text(self): 78 | if hasattr(self, '_m_special_text'): 79 | return self._m_special_text 80 | 81 | _pos = self._io.pos() 82 | self._io.seek(self.text_offset) 83 | self._raw__m_special_text = self._io.read_bytes(self.header.text_size) 84 | _io__raw__m_special_text = KaitaiStream(BytesIO(self._raw__m_special_text)) 85 | self._m_special_text = Parser.SpecialExtentP(self.i, _io__raw__m_special_text, self, self._root) 86 | self._io.seek(_pos) 87 | return getattr(self, '_m_special_text', None) 88 | 89 | 90 | class UnitInfo(KaitaiStruct): 91 | def __init__(self, _io, _parent=None, _root=None): 92 | self._io = _io 93 | self._parent = _parent 94 | self._root = _root if _root else self 95 | self._read() 96 | 97 | def _read(self): 98 | self.struct_size = self._io.read_u2le() 99 | self.endian = self._io.read_bytes(1) 100 | self.pad = self._io.read_bytes(1) 101 | if not self.pad == b"\xFF": 102 | raise kaitaistruct.ValidationNotEqualError(b"\xFF", self.pad, self._io, u"/types/unit_info/seq/2") 103 | self.unit_pow2 = self._io.read_u2le() 104 | self.reserve = self._io.read_bytes(2) 105 | if not self.reserve == b"\xFF\xFF": 106 | raise kaitaistruct.ValidationNotEqualError(b"\xFF\xFF", self.reserve, self._io, u"/types/unit_info/seq/4") 107 | self.erase_count = self._io.read_u4le() 108 | self.boot = Parser.ExtentPtr(self._io, self, self._root) 109 | 110 | @property 111 | def unit_size(self): 112 | if hasattr(self, '_m_unit_size'): 113 | return self._m_unit_size 114 | 115 | self._m_unit_size = (1 << self.unit_pow2) 116 | return getattr(self, '_m_unit_size', None) 117 | 118 | 119 | class ExtentPtr(KaitaiStruct): 120 | def __init__(self, _io, _parent=None, _root=None): 121 | self._io = _io 122 | self._parent = _parent 123 | self._root = _root if _root else self 124 | self._read() 125 | 126 | def _read(self): 127 | self.logi_unit = self._io.read_u2le() 128 | self.index = self._io.read_u2le() 129 | 130 | 131 | class SpecialExtentP(KaitaiStruct): 132 | def __init__(self, idx, _io, _parent=None, _root=None): 133 | self._io = _io 134 | self._parent = _parent 135 | self._root = _root if _root else self 136 | self.idx = idx 137 | self._read() 138 | 139 | def _read(self): 140 | _on = self.idx 141 | if _on == 0: 142 | self.body = Parser.UnitInfo(self._io, self, self._root) 143 | elif _on == 4: 144 | self.body = Parser.DirEntry(self._io, self, self._root) 145 | elif _on == 1: 146 | self.body = Parser.UnitLogi(self._io, self, self._root) 147 | elif _on == 3: 148 | self.body = Parser.DirEntry(self._io, self, self._root) 149 | elif _on == 2: 150 | self.body = Parser.BootInfo(self._io, self, self._root) 151 | 152 | 153 | class Stat(KaitaiStruct): 154 | def __init__(self, _io, _parent=None, _root=None): 155 | self._io = _io 156 | self._parent = _parent 157 | self._root = _root if _root else self 158 | self._read() 159 | 160 | def _read(self): 161 | self.struct_size = self._io.read_u2le() 162 | self.mode = self._io.read_u2le() 163 | self.uid = self._io.read_u4le() 164 | self.gid = self._io.read_u4le() 165 | self.mtime = self._io.read_u4le() 166 | self.ctime = self._io.read_u4le() 167 | 168 | 169 | class UnitLogi(KaitaiStruct): 170 | def __init__(self, _io, _parent=None, _root=None): 171 | self._io = _io 172 | self._parent = _parent 173 | self._root = _root if _root else self 174 | self._read() 175 | 176 | def _read(self): 177 | self.struct_size = self._io.read_u2le() 178 | self.logi = self._io.read_u2le() 179 | self.age = self._io.read_u4le() 180 | self.pad = self._io.read_bytes((self.struct_size - 24)) 181 | self.md5 = self._io.read_bytes(16) 182 | 183 | 184 | class DirEntry(KaitaiStruct): 185 | def __init__(self, _io, _parent=None, _root=None): 186 | self._io = _io 187 | self._parent = _parent 188 | self._root = _root if _root else self 189 | self._read() 190 | 191 | def _read(self): 192 | self.struct_size = self._io.read_u2le() 193 | self.moves = self._io.read_u1() 194 | self.namelen = self._io.read_u1() 195 | self.first = Parser.ExtentPtr(self._io, self, self._root) 196 | self.name = (self._io.read_bytes_term(0, False, True, True)).decode(u"UTF-8") 197 | self.name_pad = self._io.read_bytes((((self.namelen + 3) & 252) - self.namelen)) 198 | self.stat = Parser.Stat(self._io, self, self._root) 199 | 200 | 201 | class ExtentHeader(KaitaiStruct): 202 | def __init__(self, _io, _parent=None, _root=None): 203 | self._io = _io 204 | self._parent = _parent 205 | self._root = _root if _root else self 206 | self._read() 207 | 208 | def _read(self): 209 | self.status0 = Parser.HeaderStatus(self._io, self, self._root) 210 | self.status1 = self._io.read_u4le() 211 | self.status2 = self._io.read_u4le() 212 | self.ecc = self._io.read_bytes(6) 213 | self.reserve = self._io.read_bytes(1) 214 | if not self.reserve == b"\xFF": 215 | raise kaitaistruct.ValidationNotEqualError(b"\xFF", self.reserve, self._io, u"/types/extent_header/seq/4") 216 | self.text_offset_hi = self._io.read_u1() 217 | self.text_offset_lo = self._io.read_u2le() 218 | self.text_size = self._io.read_u2le() 219 | self.next = Parser.ExtentPtr(self._io, self, self._root) 220 | self.super = Parser.ExtentPtr(self._io, self, self._root) 221 | 222 | 223 | class HeaderStatus(KaitaiStruct): 224 | def __init__(self, _io, _parent=None, _root=None): 225 | self._io = _io 226 | self._parent = _parent 227 | self._root = _root if _root else self 228 | self._read() 229 | 230 | def _read(self): 231 | self.no_write = self._io.read_bits_int_le(1) != 0 232 | self.no_next = self._io.read_bits_int_le(1) != 0 233 | self.no_super = self._io.read_bits_int_le(1) != 0 234 | self.no_split = self._io.read_bits_int_le(1) != 0 235 | self.condition = self._io.read_bits_int_le(3) 236 | self.ext_last = self._io.read_bits_int_le(1) != 0 237 | self.type = self._io.read_bits_int_le(2) 238 | self.basic = self._io.read_bits_int_le(1) != 0 239 | self.pad = self._io.read_bits_int_le(21) 240 | 241 | @property 242 | def is_file(self): 243 | if hasattr(self, '_m_is_file'): 244 | return self._m_is_file 245 | 246 | self._m_is_file = self.type == 3 247 | return getattr(self, '_m_is_file', None) 248 | 249 | @property 250 | def is_dir(self): 251 | if hasattr(self, '_m_is_dir'): 252 | return self._m_is_dir 253 | 254 | self._m_is_dir = self.type == 2 255 | return getattr(self, '_m_is_dir', None) 256 | 257 | @property 258 | def is_sys(self): 259 | if hasattr(self, '_m_is_sys'): 260 | return self._m_is_sys 261 | 262 | self._m_is_sys = self.type == 1 263 | return getattr(self, '_m_is_sys', None) 264 | 265 | 266 | class BootInfo(KaitaiStruct): 267 | def __init__(self, _io, _parent=None, _root=None): 268 | self._io = _io 269 | self._parent = _parent 270 | self._root = _root if _root else self 271 | self._read() 272 | 273 | def _read(self): 274 | self.struct_size = self._io.read_u2le() 275 | self.rev_major = self._io.read_u1() 276 | self.rev_minor = self._io.read_u1() 277 | self.sig = self._io.read_bytes(8) 278 | if not self.sig == b"\x51\x53\x53\x4C\x5F\x46\x33\x53": 279 | raise kaitaistruct.ValidationNotEqualError(b"\x51\x53\x53\x4C\x5F\x46\x33\x53", self.sig, self._io, u"/types/boot_info/seq/3") 280 | self.unit_index = self._io.read_u2le() 281 | self.unit_total = self._io.read_u2le() 282 | self.unit_spare = self._io.read_u2le() 283 | self.align_pow2 = self._io.read_u2le() 284 | self.root = Parser.ExtentPtr(self._io, self, self._root) 285 | 286 | @property 287 | def is_valid(self): 288 | if hasattr(self, '_m_is_valid'): 289 | return self._m_is_valid 290 | 291 | self._m_is_valid = ((self.struct_size == 24) and (self.rev_major == 3) and (self.rev_minor == 0)) 292 | return getattr(self, '_m_is_valid', None) 293 | 294 | 295 | class Units(KaitaiStruct): 296 | def __init__(self, align_pow2, num_units, _io, _parent=None, _root=None): 297 | self._io = _io 298 | self._parent = _parent 299 | self._root = _root if _root else self 300 | self.align_pow2 = align_pow2 301 | self.num_units = num_units 302 | self._read() 303 | 304 | def _read(self): 305 | self._raw_unit_list = [] 306 | self.unit_list = [] 307 | for i in range(self.num_units): 308 | self._raw_unit_list.append(self._io.read_bytes(self._root.unit_size)) 309 | _io__raw_unit_list = KaitaiStream(BytesIO(self._raw_unit_list[i])) 310 | self.unit_list.append(Parser.Unit(_io__raw_unit_list, self, self._root)) 311 | 312 | 313 | 314 | class Unit(KaitaiStruct): 315 | def __init__(self, _io, _parent=None, _root=None): 316 | self._io = _io 317 | self._parent = _parent 318 | self._root = _root if _root else self 319 | self._read() 320 | 321 | def _read(self): 322 | pass 323 | 324 | @property 325 | def raw(self): 326 | if hasattr(self, '_m_raw'): 327 | return self._m_raw 328 | 329 | _pos = self._io.pos() 330 | self._io.seek(0) 331 | self._m_raw = self._io.read_bytes(self._root.unit_size) 332 | self._io.seek(_pos) 333 | return getattr(self, '_m_raw', None) 334 | 335 | @property 336 | def extents(self): 337 | if hasattr(self, '_m_extents'): 338 | return self._m_extents 339 | 340 | self._m_extents = [] 341 | i = 0 342 | while True: 343 | _ = Parser.Extent(i, self._io, self, self._root) 344 | self._m_extents.append(_) 345 | if _.header.status0.ext_last: 346 | break 347 | i += 1 348 | return getattr(self, '_m_extents', None) 349 | 350 | @property 351 | def info(self): 352 | if hasattr(self, '_m_info'): 353 | return self._m_info 354 | 355 | self._m_info = self.extents[0].special_text.body 356 | return getattr(self, '_m_info', None) 357 | 358 | @property 359 | def logi(self): 360 | if hasattr(self, '_m_logi'): 361 | return self._m_logi 362 | 363 | self._m_logi = self.extents[1].special_text.body 364 | return getattr(self, '_m_logi', None) 365 | 366 | 367 | @property 368 | def unit_size(self): 369 | if hasattr(self, '_m_unit_size'): 370 | return self._m_unit_size 371 | 372 | self._m_unit_size = self.unit_shortcut.unit_size 373 | return getattr(self, '_m_unit_size', None) 374 | 375 | @property 376 | def unit_shortcut(self): 377 | """Needed for bootstrapping the unit size value.""" 378 | if hasattr(self, '_m_unit_shortcut'): 379 | return self._m_unit_shortcut 380 | 381 | _pos = self._io.pos() 382 | self._io.seek(0) 383 | self._m_unit_shortcut = Parser.UnitInfo(self._io, self, self._root) 384 | self._io.seek(_pos) 385 | return getattr(self, '_m_unit_shortcut', None) 386 | 387 | @property 388 | def units(self): 389 | """Dummy values to satisfy the compiler, we will manually instantiate it anyway.""" 390 | if hasattr(self, '_m_units'): 391 | return self._m_units 392 | 393 | self._m_units = Parser.Units(1, 1, self._io, self, self._root) 394 | return getattr(self, '_m_units', None) 395 | 396 | 397 | -------------------------------------------------------------------------------- /qnxmount/etfs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/qnxmount/etfs/__init__.py -------------------------------------------------------------------------------- /qnxmount/etfs/interface.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from qnxmount.etfs.parser import Parser 3 | from functools import lru_cache 4 | from collections import defaultdict 5 | import logging 6 | 7 | LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class ETFS: 11 | """This class implements the basic functionality to create a Kaitai io stream. 12 | 13 | Args: 14 | stream: Kaitaistream containing the qnx6 file system. 15 | pagesize (int): page size. 16 | 17 | Attributes: 18 | stream: Kaitaistream containing the qnx6 file system. 19 | parser (Parser): Parser class automatically generated from .ksy file. 20 | page_size (int): page size. 21 | current_pages: 22 | ftable: File table of the file system. 23 | dir_tree: Directory tree of the file system. 24 | path_to_fid: Dictionary to map a file path to a fid. 25 | """ 26 | def __init__(self, stream, pagesize=2048): 27 | self.stream = stream 28 | self.parser = Parser(self.stream) 29 | 30 | transaction_size = 16 31 | self.page_size = pagesize 32 | assert self.stream._io.size() % (pagesize + transaction_size) == 0, 'size of memory map invalid' 33 | num_pages = self.stream._io.size() // (pagesize + transaction_size) 34 | self.parser._m_pages = self.parser.Pages(pagesize, num_pages, self.parser._io, _root=self.parser) 35 | self.current_pages = self.get_current_file_pages() 36 | 37 | self.ftable = self.build_ftable() 38 | self.set_full_file_names() 39 | self.dir_tree = self.build_dir_tree() 40 | 41 | self.path_to_fid = {self.get_path(fid): fid for fid, entry in enumerate(self.ftable) if ((entry is not None) and (entry.full_name is not None))} 42 | 43 | def read_dir(self, path): 44 | return self.dir_tree[path] 45 | 46 | def build_dir_tree(self): 47 | tree = defaultdict(set) 48 | for fid, entry in enumerate(self.ftable): 49 | if entry is None or entry.full_name is None: 50 | continue 51 | path = self.get_path(fid) 52 | if fid != 0: 53 | tree[path.parent].add(fid) 54 | return tree 55 | 56 | @lru_cache(maxsize=128) 57 | def get_path(self, fid: int): 58 | if fid == 0: 59 | return Path('/') 60 | 61 | entry = self.ftable[fid] 62 | if self.ftable[entry.pfid] is None: 63 | parent_path = Path('/recovered_files') 64 | else: 65 | parent_path = self.get_path(entry.pfid) 66 | return parent_path / Path(entry.full_name) 67 | 68 | @lru_cache(maxsize=256) 69 | def read_file(self, fid): 70 | current_pages = self.current_pages[fid] 71 | data = b'' 72 | for cluster in range(0 if not current_pages else max(current_pages) + 1): 73 | if cluster not in current_pages: 74 | LOGGER.warning(f"cluster {cluster} not found for {self.get_path(fid)} (fid {fid}). Replaced with 0xff.") 75 | data += b'\xff' * self.page_size 76 | else: 77 | data += current_pages[cluster].data.raw 78 | return data 79 | 80 | def set_full_file_names(self): 81 | for fid, entry in enumerate(self.ftable): 82 | if entry is None: 83 | continue 84 | if entry.is_extension or not entry.is_valid: 85 | entry._m_full_name = None 86 | 87 | elif entry.is_solo: 88 | entry._m_full_name = entry.body.name 89 | 90 | else: 91 | if self.ftable[entry.efid] is not None: 92 | extension = self.ftable[entry.efid] 93 | assert extension.is_extension 94 | assert extension.pfid == fid 95 | entry._m_full_name = entry.body.name + extension.body.name 96 | else: 97 | entry._m_full_name = entry.body.name 98 | 99 | def get_current_file_pages(self): 100 | pages = defaultdict(lambda: defaultdict(lambda: defaultdict(None))) 101 | for page in self.parser.pages.pages: 102 | transaction = page.transaction 103 | if transaction.fid == 0xffff: 104 | continue 105 | pages[transaction.fid][transaction.cluster][transaction.sequence] = page 106 | 107 | for fid, clusters in pages.items(): 108 | for cluster, sequences in clusters.items(): 109 | # assume that for equal sequences the one with the highest offset is the most recent one 110 | # not sure if correct 111 | pages[fid][cluster] = sequences[max(sequences)] 112 | 113 | return pages 114 | 115 | def build_ftable(self): 116 | ftable = [] 117 | ftable_pages = self.current_pages[self.parser.fid_ftable] 118 | for cluster in range(max(ftable_pages) + 1): 119 | if cluster not in ftable_pages: 120 | for _ in range(self.page_size // 64): 121 | ftable.append(None) 122 | continue 123 | page = ftable_pages[cluster] 124 | for entry in page.data.as_ftable.entries: 125 | ftable.append(entry) 126 | return ftable 127 | 128 | if __name__ == '__main__': 129 | from argparse import ArgumentParser 130 | 131 | parser = ArgumentParser() 132 | parser.add_argument('image', type=Path, help='Path to EFS binary') 133 | parser.add_argument('-s', '--page_size', type=lambda x: int(x, 0), help='Size of pages (clusters)', default=2048) 134 | args = parser.parse_args() 135 | 136 | etfs = ETFS(args.image, 0, args.page_size) 137 | 138 | exit(0) 139 | 140 | 141 | -------------------------------------------------------------------------------- /qnxmount/etfs/mount.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import logging 3 | from pathlib import Path 4 | 5 | from fuse import FUSE, FuseOSError, Operations 6 | 7 | from qnxmount.etfs.interface import ETFS 8 | from qnxmount.stream import Stream 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class FuseETFS(Operations): 14 | """Fuse implementation of the ETFS file system. 15 | 16 | Args: 17 | stream: Kaitaistream containing the qnx6 file system. 18 | """ 19 | 20 | def __init__(self, stream, pagesize): 21 | self.etfs = ETFS(stream, pagesize=pagesize) 22 | 23 | def getattr(self, path, fh=None): 24 | """Get directory with stat information. 25 | 26 | Args: 27 | path (Path): Path. 28 | fh: file handle. 29 | 30 | Returns: 31 | dict: dictionary with keys identical to the stat C structure of stat(2). 32 | """ 33 | LOGGER.debug(f"getattr({path})") 34 | path = Path(path) 35 | 36 | if path not in self.etfs.path_to_fid: 37 | raise FuseOSError(errno.ENOENT) 38 | 39 | fid = self.etfs.path_to_fid[path] 40 | entry = self.etfs.ftable[fid] 41 | return dict( 42 | st_size=entry.body.size, 43 | st_nlink=1, 44 | st_mode=entry.body.mode, 45 | st_ctime=entry.body.ctime, 46 | st_mtime=entry.body.mtime, 47 | st_uid=entry.body.uid, 48 | st_gid=entry.body.gid, 49 | ) 50 | 51 | def readdir(self, path, fh): 52 | """Read content from directory. 53 | 54 | Args: 55 | path (Path): Path to directory. 56 | fh: file handle. 57 | 58 | Returns: 59 | dict: dictionary with keys identical to the stat C structure of stat(2). 60 | """ 61 | LOGGER.debug(f"readdir({path})") 62 | path = Path(path) 63 | 64 | if path in self.etfs.dir_tree: 65 | for fid in self.etfs.dir_tree[path]: 66 | entry = self.etfs.ftable[fid] 67 | yield entry.full_name 68 | 69 | def read(self, path, size, offset, fh): 70 | """Read content from an object. 71 | 72 | Args: 73 | path (Path): Path to object. 74 | size (int): size of content to be read. 75 | offset (int): starting offset in the file. 76 | fh: file handle. 77 | 78 | Returns: 79 | bytes: Raw file content. 80 | """ 81 | LOGGER.debug(f"read({path}, {size}, {offset})") 82 | path = Path(path) 83 | 84 | if not path in self.etfs.path_to_fid: 85 | raise FuseOSError(errno.ENOENT) 86 | 87 | fid = self.etfs.path_to_fid[path] 88 | file = self.etfs.read_file(fid) 89 | return file[offset : offset + size] 90 | 91 | def readlink(self, path): 92 | """Read content from a link given the path. 93 | 94 | Args: 95 | path (Path): Path to symlink. 96 | 97 | Returns: 98 | str: symlink target 99 | """ 100 | LOGGER.debug(f"readlink({path})") 101 | path = Path(path) 102 | 103 | if not path in self.etfs.path_to_fid: 104 | raise FuseOSError(errno.ENOENT) 105 | 106 | fid = self.etfs.path_to_fid[path] 107 | return self.etfs.read_file(fid).decode("utf-8") 108 | 109 | 110 | def mount(image, mount_point, offset, pagesize): 111 | with Stream(image, offset) as stream: 112 | etfs = FuseETFS(stream, pagesize) 113 | FUSE(etfs, str(mount_point), nothreads=True, foreground=True) 114 | -------------------------------------------------------------------------------- /qnxmount/etfs/parser.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: parser 3 | file-extension: .bin 4 | title: etfs_parser 5 | endian: le 6 | xref: 7 | header: https://github.com/RunZeJustin/qnx660/blob/47c4158e3993d7536170b649e6c1e09552318fb4/target/qnx6/usr/include/fs/etfs.h 8 | 9 | instances: 10 | pages: 11 | pos: 0 12 | type: pages(2048,9) # Dummy values to satisfy compiler, initialize later 13 | fid_root: 14 | value: 0 15 | fid_ftable: 16 | value: 1 17 | fid_badblks: 18 | value: 2 19 | fid_counts: 20 | value: 3 21 | fid_lostfound: 22 | value: 4 23 | fid_reserved: 24 | value: 5 25 | fid_firstfile: 26 | value: 6 27 | 28 | types: 29 | pages: 30 | params: 31 | - id: pagesize 32 | type: u4 33 | - id: num_pages 34 | type: u4 35 | seq: 36 | - id: pages 37 | type: page(pagesize) 38 | repeat: expr 39 | repeat-expr: num_pages 40 | 41 | page: 42 | params: 43 | - id: pagesize 44 | type: u4 45 | seq: 46 | - id: data 47 | size: pagesize 48 | type: userdata 49 | - id: transaction 50 | type: transaction 51 | 52 | userdata: 53 | instances: 54 | as_ftable: 55 | pos: 0 56 | type: ftable 57 | raw: 58 | pos: 0 59 | size-eos: true 60 | 61 | ftable: 62 | seq: 63 | - id: entries 64 | type: ftable_entry 65 | repeat: eos 66 | 67 | transaction: 68 | seq: 69 | - id: fid 70 | type: u4 71 | doc: File id 72 | - id: cluster 73 | type: u4 74 | doc: Cluster offset in file 75 | - id: nclusters 76 | type: u2 77 | doc: Number of contiguous clusters for this transaction 78 | - id: tacode 79 | type: trans_code 80 | doc: Code for transaction area 81 | - id: dacode 82 | type: trans_code 83 | doc: Code for data area 84 | - id: sequence 85 | type: u4 86 | doc: Sequence for this transaction 87 | 88 | trans_code: 89 | seq: 90 | - id: code 91 | type: u1 92 | instances: 93 | ok: 94 | value: code & 0x0f == 0 95 | ecc: 96 | value: code & 0x0f == 1 97 | erased: 98 | value: code & 0x0f == 2 99 | foxes: 100 | value: code & 0x0f == 3 101 | dataerr: 102 | value: code & 0x0f == 5 103 | deverr: 104 | value: code & 0x0f == 6 105 | badblk: 106 | value: code & 0x0f == 7 107 | 108 | ftable_entry: 109 | seq: 110 | - id: efid 111 | type: u2 112 | doc: File id of extra info attached to this file 113 | - id: pfid 114 | type: u2 115 | doc: File id of parent to this file 116 | - id: body 117 | type: 118 | switch-on: entry_type 119 | cases: 120 | 0: file_entry 121 | 1: extname_entry 122 | _: no_entry 123 | instances: 124 | is_extension: 125 | value: 'efid == 0x8000' 126 | test: 127 | value: '(efid & 0x8000) == 0x8000' 128 | has_no_parent: 129 | value: 'pfid == 0xffff' 130 | doc: These are deleted files or not-initialised entries. 131 | entry_type: 132 | value: 'is_extension.to_i + has_no_parent.to_i * 2' 133 | doc: Dummy variable to switch on. 134 | full_name: 135 | value: 0 # will be overwritten in code 136 | is_solo: 137 | value: 'efid == 0x0000' 138 | is_valid: 139 | value: 'efid != 0xffff' # check if true 140 | 141 | file_entry: 142 | seq: 143 | - id: mode 144 | type: u4 145 | doc: File mode 146 | - id: uid 147 | type: u4 148 | doc: User ID of owner 149 | - id: gid 150 | type: u4 151 | doc: Group ID of owner 152 | - id: atime 153 | type: u4 154 | doc: Time of last access 155 | - id: mtime 156 | type: u4 157 | doc: Time of last modification 158 | - id: ctime 159 | type: u4 160 | doc: Time of last change 161 | - id: size 162 | type: u4 163 | doc: File size (always 0 for directories) 164 | - id: name 165 | size: 32 # ETFS_FNAME_SHORT_LEN 166 | type: strz 167 | encoding: utf-8 168 | 169 | extname_entry: 170 | seq: 171 | - id: name 172 | size: 59 173 | type: strz 174 | encoding: utf-8 175 | - id: type 176 | type: u1 177 | instances: 178 | is_valid: 179 | value: type == 0 180 | 181 | no_entry: {} 182 | -------------------------------------------------------------------------------- /qnxmount/etfs/parser.py: -------------------------------------------------------------------------------- 1 | # This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild 2 | 3 | import kaitaistruct 4 | from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO 5 | 6 | 7 | if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 9): 8 | raise Exception("Incompatible Kaitai Struct Python API: 0.9 or later is required, but you have %s" % (kaitaistruct.__version__)) 9 | 10 | class Parser(KaitaiStruct): 11 | def __init__(self, _io, _parent=None, _root=None): 12 | self._io = _io 13 | self._parent = _parent 14 | self._root = _root if _root else self 15 | self._read() 16 | 17 | def _read(self): 18 | pass 19 | 20 | class Userdata(KaitaiStruct): 21 | def __init__(self, _io, _parent=None, _root=None): 22 | self._io = _io 23 | self._parent = _parent 24 | self._root = _root if _root else self 25 | self._read() 26 | 27 | def _read(self): 28 | pass 29 | 30 | @property 31 | def as_ftable(self): 32 | if hasattr(self, '_m_as_ftable'): 33 | return self._m_as_ftable 34 | 35 | _pos = self._io.pos() 36 | self._io.seek(0) 37 | self._m_as_ftable = Parser.Ftable(self._io, self, self._root) 38 | self._io.seek(_pos) 39 | return getattr(self, '_m_as_ftable', None) 40 | 41 | @property 42 | def raw(self): 43 | if hasattr(self, '_m_raw'): 44 | return self._m_raw 45 | 46 | _pos = self._io.pos() 47 | self._io.seek(0) 48 | self._m_raw = self._io.read_bytes_full() 49 | self._io.seek(_pos) 50 | return getattr(self, '_m_raw', None) 51 | 52 | 53 | class NoEntry(KaitaiStruct): 54 | def __init__(self, _io, _parent=None, _root=None): 55 | self._io = _io 56 | self._parent = _parent 57 | self._root = _root if _root else self 58 | self._read() 59 | 60 | def _read(self): 61 | pass 62 | 63 | 64 | class FtableEntry(KaitaiStruct): 65 | def __init__(self, _io, _parent=None, _root=None): 66 | self._io = _io 67 | self._parent = _parent 68 | self._root = _root if _root else self 69 | self._read() 70 | 71 | def _read(self): 72 | self.efid = self._io.read_u2le() 73 | self.pfid = self._io.read_u2le() 74 | _on = self.entry_type 75 | if _on == 0: 76 | self.body = Parser.FileEntry(self._io, self, self._root) 77 | elif _on == 1: 78 | self.body = Parser.ExtnameEntry(self._io, self, self._root) 79 | else: 80 | self.body = Parser.NoEntry(self._io, self, self._root) 81 | 82 | @property 83 | def has_no_parent(self): 84 | """These are deleted files or not-initialised entries.""" 85 | if hasattr(self, '_m_has_no_parent'): 86 | return self._m_has_no_parent 87 | 88 | self._m_has_no_parent = self.pfid == 65535 89 | return getattr(self, '_m_has_no_parent', None) 90 | 91 | @property 92 | def is_extension(self): 93 | if hasattr(self, '_m_is_extension'): 94 | return self._m_is_extension 95 | 96 | self._m_is_extension = self.efid == 32768 97 | return getattr(self, '_m_is_extension', None) 98 | 99 | @property 100 | def full_name(self): 101 | if hasattr(self, '_m_full_name'): 102 | return self._m_full_name 103 | 104 | self._m_full_name = 0 105 | return getattr(self, '_m_full_name', None) 106 | 107 | @property 108 | def test(self): 109 | if hasattr(self, '_m_test'): 110 | return self._m_test 111 | 112 | self._m_test = (self.efid & 32768) == 32768 113 | return getattr(self, '_m_test', None) 114 | 115 | @property 116 | def is_solo(self): 117 | if hasattr(self, '_m_is_solo'): 118 | return self._m_is_solo 119 | 120 | self._m_is_solo = self.efid == 0 121 | return getattr(self, '_m_is_solo', None) 122 | 123 | @property 124 | def is_valid(self): 125 | if hasattr(self, '_m_is_valid'): 126 | return self._m_is_valid 127 | 128 | self._m_is_valid = self.efid != 65535 129 | return getattr(self, '_m_is_valid', None) 130 | 131 | @property 132 | def entry_type(self): 133 | """Dummy variable to switch on.""" 134 | if hasattr(self, '_m_entry_type'): 135 | return self._m_entry_type 136 | 137 | self._m_entry_type = (int(self.is_extension) + (int(self.has_no_parent) * 2)) 138 | return getattr(self, '_m_entry_type', None) 139 | 140 | 141 | class TransCode(KaitaiStruct): 142 | def __init__(self, _io, _parent=None, _root=None): 143 | self._io = _io 144 | self._parent = _parent 145 | self._root = _root if _root else self 146 | self._read() 147 | 148 | def _read(self): 149 | self.code = self._io.read_u1() 150 | 151 | @property 152 | def deverr(self): 153 | if hasattr(self, '_m_deverr'): 154 | return self._m_deverr 155 | 156 | self._m_deverr = (self.code & 15) == 6 157 | return getattr(self, '_m_deverr', None) 158 | 159 | @property 160 | def dataerr(self): 161 | if hasattr(self, '_m_dataerr'): 162 | return self._m_dataerr 163 | 164 | self._m_dataerr = (self.code & 15) == 5 165 | return getattr(self, '_m_dataerr', None) 166 | 167 | @property 168 | def erased(self): 169 | if hasattr(self, '_m_erased'): 170 | return self._m_erased 171 | 172 | self._m_erased = (self.code & 15) == 2 173 | return getattr(self, '_m_erased', None) 174 | 175 | @property 176 | def ok(self): 177 | if hasattr(self, '_m_ok'): 178 | return self._m_ok 179 | 180 | self._m_ok = (self.code & 15) == 0 181 | return getattr(self, '_m_ok', None) 182 | 183 | @property 184 | def ecc(self): 185 | if hasattr(self, '_m_ecc'): 186 | return self._m_ecc 187 | 188 | self._m_ecc = (self.code & 15) == 1 189 | return getattr(self, '_m_ecc', None) 190 | 191 | @property 192 | def foxes(self): 193 | if hasattr(self, '_m_foxes'): 194 | return self._m_foxes 195 | 196 | self._m_foxes = (self.code & 15) == 3 197 | return getattr(self, '_m_foxes', None) 198 | 199 | @property 200 | def badblk(self): 201 | if hasattr(self, '_m_badblk'): 202 | return self._m_badblk 203 | 204 | self._m_badblk = (self.code & 15) == 7 205 | return getattr(self, '_m_badblk', None) 206 | 207 | 208 | class Page(KaitaiStruct): 209 | def __init__(self, pagesize, _io, _parent=None, _root=None): 210 | self._io = _io 211 | self._parent = _parent 212 | self._root = _root if _root else self 213 | self.pagesize = pagesize 214 | self._read() 215 | 216 | def _read(self): 217 | self._raw_data = self._io.read_bytes(self.pagesize) 218 | _io__raw_data = KaitaiStream(BytesIO(self._raw_data)) 219 | self.data = Parser.Userdata(_io__raw_data, self, self._root) 220 | self.transaction = Parser.Transaction(self._io, self, self._root) 221 | 222 | 223 | class Pages(KaitaiStruct): 224 | def __init__(self, pagesize, num_pages, _io, _parent=None, _root=None): 225 | self._io = _io 226 | self._parent = _parent 227 | self._root = _root if _root else self 228 | self.pagesize = pagesize 229 | self.num_pages = num_pages 230 | self._read() 231 | 232 | def _read(self): 233 | self.pages = [] 234 | for i in range(self.num_pages): 235 | self.pages.append(Parser.Page(self.pagesize, self._io, self, self._root)) 236 | 237 | 238 | 239 | class Ftable(KaitaiStruct): 240 | def __init__(self, _io, _parent=None, _root=None): 241 | self._io = _io 242 | self._parent = _parent 243 | self._root = _root if _root else self 244 | self._read() 245 | 246 | def _read(self): 247 | self.entries = [] 248 | i = 0 249 | while not self._io.is_eof(): 250 | self.entries.append(Parser.FtableEntry(self._io, self, self._root)) 251 | i += 1 252 | 253 | 254 | 255 | class FileEntry(KaitaiStruct): 256 | def __init__(self, _io, _parent=None, _root=None): 257 | self._io = _io 258 | self._parent = _parent 259 | self._root = _root if _root else self 260 | self._read() 261 | 262 | def _read(self): 263 | self.mode = self._io.read_u4le() 264 | self.uid = self._io.read_u4le() 265 | self.gid = self._io.read_u4le() 266 | self.atime = self._io.read_u4le() 267 | self.mtime = self._io.read_u4le() 268 | self.ctime = self._io.read_u4le() 269 | self.size = self._io.read_u4le() 270 | self.name = (KaitaiStream.bytes_terminate(self._io.read_bytes(32), 0, False)).decode(u"utf-8") 271 | 272 | 273 | class Transaction(KaitaiStruct): 274 | def __init__(self, _io, _parent=None, _root=None): 275 | self._io = _io 276 | self._parent = _parent 277 | self._root = _root if _root else self 278 | self._read() 279 | 280 | def _read(self): 281 | self.fid = self._io.read_u4le() 282 | self.cluster = self._io.read_u4le() 283 | self.nclusters = self._io.read_u2le() 284 | self.tacode = Parser.TransCode(self._io, self, self._root) 285 | self.dacode = Parser.TransCode(self._io, self, self._root) 286 | self.sequence = self._io.read_u4le() 287 | 288 | 289 | class ExtnameEntry(KaitaiStruct): 290 | def __init__(self, _io, _parent=None, _root=None): 291 | self._io = _io 292 | self._parent = _parent 293 | self._root = _root if _root else self 294 | self._read() 295 | 296 | def _read(self): 297 | self.name = (KaitaiStream.bytes_terminate(self._io.read_bytes(59), 0, False)).decode(u"utf-8") 298 | self.type = self._io.read_u1() 299 | 300 | @property 301 | def is_valid(self): 302 | if hasattr(self, '_m_is_valid'): 303 | return self._m_is_valid 304 | 305 | self._m_is_valid = self.type == 0 306 | return getattr(self, '_m_is_valid', None) 307 | 308 | 309 | @property 310 | def fid_firstfile(self): 311 | if hasattr(self, '_m_fid_firstfile'): 312 | return self._m_fid_firstfile 313 | 314 | self._m_fid_firstfile = 6 315 | return getattr(self, '_m_fid_firstfile', None) 316 | 317 | @property 318 | def pages(self): 319 | if hasattr(self, '_m_pages'): 320 | return self._m_pages 321 | 322 | _pos = self._io.pos() 323 | self._io.seek(0) 324 | self._m_pages = Parser.Pages(2048, 9, self._io, self, self._root) 325 | self._io.seek(_pos) 326 | return getattr(self, '_m_pages', None) 327 | 328 | @property 329 | def fid_badblks(self): 330 | if hasattr(self, '_m_fid_badblks'): 331 | return self._m_fid_badblks 332 | 333 | self._m_fid_badblks = 2 334 | return getattr(self, '_m_fid_badblks', None) 335 | 336 | @property 337 | def fid_ftable(self): 338 | if hasattr(self, '_m_fid_ftable'): 339 | return self._m_fid_ftable 340 | 341 | self._m_fid_ftable = 1 342 | return getattr(self, '_m_fid_ftable', None) 343 | 344 | @property 345 | def fid_root(self): 346 | if hasattr(self, '_m_fid_root'): 347 | return self._m_fid_root 348 | 349 | self._m_fid_root = 0 350 | return getattr(self, '_m_fid_root', None) 351 | 352 | @property 353 | def fid_counts(self): 354 | if hasattr(self, '_m_fid_counts'): 355 | return self._m_fid_counts 356 | 357 | self._m_fid_counts = 3 358 | return getattr(self, '_m_fid_counts', None) 359 | 360 | @property 361 | def fid_lostfound(self): 362 | if hasattr(self, '_m_fid_lostfound'): 363 | return self._m_fid_lostfound 364 | 365 | self._m_fid_lostfound = 4 366 | return getattr(self, '_m_fid_lostfound', None) 367 | 368 | @property 369 | def fid_reserved(self): 370 | if hasattr(self, '_m_fid_reserved'): 371 | return self._m_fid_reserved 372 | 373 | self._m_fid_reserved = 5 374 | return getattr(self, '_m_fid_reserved', None) 375 | 376 | 377 | -------------------------------------------------------------------------------- /qnxmount/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | import sys 4 | from getpass import getuser 5 | from pathlib import Path 6 | from socket import gethostname 7 | 8 | if sys.version_info >= (3, 8): 9 | from importlib import metadata 10 | else: 11 | import importlib_metadata as metadata # https://packaging.python.org/en/latest/guides/single-sourcing-package-version/ 12 | FORMATTER = logging.Formatter("%(asctime)s — %(name)s — %(levelname)s — %(message)s") 13 | 14 | 15 | def setup_logging(logger, loglevel=logging.INFO): 16 | logger.setLevel(loglevel) 17 | logger.addHandler(get_console_handler()) 18 | # logger.propagate = False 19 | 20 | logger.info("Logging started") 21 | logger.info("Python version: %s", sys.version) 22 | logger.info("Script started by %s on %s", getuser(), gethostname()) 23 | logger.info("Called with sys.argv:\n %s", " ".join(sys.argv)) 24 | version = get_version() 25 | if version is not None: 26 | logger.info("Current qnxmount version: %s", version) 27 | return logger 28 | 29 | rev_hash = get_git_revision_hash(Path(sys.argv[0]).parent) 30 | if rev_hash: 31 | logger.info("Current commit hash: %s", rev_hash) 32 | else: 33 | logger.warning("This script is not in a git repository!") 34 | 35 | return logger 36 | 37 | 38 | def get_console_handler(): 39 | console_handler = logging.StreamHandler(sys.stdout) 40 | console_handler.setFormatter(FORMATTER) 41 | return console_handler 42 | 43 | 44 | def get_version(): 45 | try: 46 | return metadata.version("qnxmount") 47 | except (ModuleNotFoundError, metadata.PackageNotFoundError) as e: 48 | return None 49 | 50 | 51 | def get_git_revision_hash(git_dir): 52 | try: 53 | output = subprocess.check_output(["git", "-C", str(git_dir), "rev-parse", "HEAD"], stderr=subprocess.DEVNULL) 54 | return output.decode("utf8").rstrip() 55 | except (subprocess.CalledProcessError, FileNotFoundError): 56 | pass 57 | return None 58 | -------------------------------------------------------------------------------- /qnxmount/qnx6/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/qnxmount/qnx6/__init__.py -------------------------------------------------------------------------------- /qnxmount/qnx6/interface.py: -------------------------------------------------------------------------------- 1 | import stat 2 | from functools import lru_cache 3 | from pathlib import Path 4 | 5 | import crcmod 6 | from kaitaistruct import BytesIO, KaitaiStream 7 | 8 | from qnxmount.qnx6.parser import Parser 9 | 10 | 11 | class QNX6FS: 12 | """This class implements the logic of the qnx6 file system and uses the 13 | structures in the class :class:`Parser`. 14 | 15 | Args: 16 | stream: Kaitaistream containing the qnx6 file system. 17 | 18 | Attributes: 19 | stream (KaitaiStream): Kaitai data stream of qnx6 file system. 20 | parser (Parser): Parser class automatically generated from .ksy file. 21 | blocksize (int): Block size. 22 | active_superblock (Parser.Superblock): Active superblock. 23 | """ 24 | 25 | def __init__(self, stream): 26 | self.cache = dict() 27 | self.stream = stream 28 | self.parser = Parser(self.stream) 29 | self.check_superblock_crc() 30 | 31 | self.blocksize = self.parser.blocksize 32 | if self.parser.qnx6_bootblock.superblock0.serial >= self.parser.qnx6_bootblock.superblock1.serial: 33 | self.active_superblock = self.parser.qnx6_bootblock.superblock0 34 | else: 35 | self.active_superblock = self.parser.qnx6_bootblock.superblock1 36 | 37 | def add_to_cache(self, path: Path, inode_number: int) -> None: 38 | self.cache[path] = inode_number 39 | 40 | def get_dir_from_path(self, path): 41 | """Get directory content from its path. 42 | 43 | Args: 44 | path (Union[Path, PurePath]): Path to directory. 45 | 46 | Returns: 47 | Parser.Directory: Parsed directory. 48 | """ 49 | inode_number = self.get_inode_number_from_path(path) 50 | return self.get_dir(inode_number) 51 | 52 | @lru_cache(maxsize=4096) 53 | def get_inode_number_from_path(self, path): 54 | """Get the inode number from a path. 55 | 56 | Args: 57 | path (Union[Path, PurePath]): Path to object. 58 | 59 | Returns: 60 | int: Inode number. 61 | """ 62 | if not path.name: 63 | return 1 64 | if path in self.cache: 65 | return self.cache[path] 66 | 67 | inode_number = self.get_inode_number_from_path(path.parent) 68 | directory = self.get_dir(inode_number) 69 | for entry in directory.entries: 70 | if entry.content.name == path.name: 71 | self.add_to_cache(path, entry.inode_number) 72 | return entry.inode_number 73 | 74 | return None 75 | 76 | def get_longname(self, index): 77 | """Get longname from its index. 78 | 79 | Args: 80 | index (int): index in the longfile inode. 81 | 82 | Returns: 83 | Parser.Longname: Longname object. 84 | """ 85 | longname_raw = self.read_file( 86 | self.active_superblock.longfile, offset=index * self.parser.blocksize, size=self.parser.blocksize 87 | ) 88 | longname = self.parser.Longname(KaitaiStream(BytesIO(longname_raw)), _root=self.parser._root) 89 | return longname 90 | 91 | def get_dir(self, inode_number): 92 | """Get directory content from its inode number. 93 | 94 | Args: 95 | inode_number (int): Inode number of the directory. 96 | 97 | Returns: 98 | Parser.Directory: Parsed directory. 99 | """ 100 | assert inode_number >= 1, "Inode number smaller than 1 not allowed!" 101 | dir_raw = self.read_dir(inode_number) 102 | directory = self.parser.Directory(KaitaiStream(BytesIO(dir_raw)), _root=self.parser._root) 103 | 104 | valid_entries = [] 105 | for entry in filter(lambda x: x.inode_number != 0, directory.entries): 106 | if not entry.content.name: 107 | longname = self.get_longname(entry.content.index) 108 | entry.content._m_name = longname.name 109 | entry.length = longname.length 110 | valid_entries.append(entry) 111 | 112 | directory.entries = valid_entries 113 | return directory 114 | 115 | def read_dir(self, inode_number): 116 | """Read raw directory content from its inode number. 117 | 118 | Args: 119 | inode_number (int): Inode number of the directory. 120 | 121 | Returns: 122 | bytes: Raw directory content. 123 | """ 124 | assert inode_number >= 1, "Inode number smaller than 1 not allowed!" 125 | dir_inode = self.get_inode(inode_number) 126 | assert stat.S_ISDIR(dir_inode.mode), "Inode is not a directory!" 127 | dir_raw = self.read_file(dir_inode) 128 | 129 | return dir_raw 130 | 131 | def get_inode(self, inode_number): 132 | """Retrieve inode from its inode number. 133 | 134 | Args: 135 | inode_number (int): Inode number of the directory. 136 | 137 | Returns: 138 | Parser.Inode: Parsed inode. 139 | """ 140 | # inode_number 1 is the root ("/") directory 141 | assert inode_number >= 1, "Inode number smaller than 1 not allowed!" 142 | 143 | inode_raw = self.read_file( 144 | self.active_superblock.inodes, 145 | offset=(inode_number - 1) * self.parser.sizeof_inode, 146 | size=self.parser.sizeof_inode, 147 | ) 148 | 149 | return self.parser.Inode(KaitaiStream(BytesIO(inode_raw)), _root=self.parser._root) 150 | 151 | def read_file(self, inode, offset=0, size=None): 152 | """Read raw content from an inode. 153 | 154 | Args: 155 | inode (Parser.Inode): Inode number of the directory. 156 | offset (int): starting offset in file. 157 | size (int): size of content to be read. 158 | 159 | Returns: 160 | bytes: Raw inode content. 161 | """ 162 | if size is None: 163 | size = inode.size - offset 164 | elif offset + size > inode.size: 165 | size = inode.size - offset 166 | # assert (offset <= inode.size and size <= inode.size - offset), 'Attempting to read beyond file size!' 167 | 168 | file_content = b"" 169 | o = offset 170 | while (len(file_content) - offset % self.blocksize) < size: 171 | file_content += self.parse_block_pointer(inode, o).raw_body 172 | o += self.blocksize 173 | 174 | return file_content[offset % self.blocksize : offset % self.blocksize + size] 175 | 176 | def parse_block_pointer(self, inode, offset): 177 | """Get pointer to the data of the inode on the given offset. 178 | 179 | The inodes have space for 16 pointers to data blocks. When these file size is larger than 180 | 16 blocks, the file system uses a layered method. The initial 16 pointers point to a block 181 | of pointers that point to data blocks (level 1). Level 0 is when the 16 pointers directly 182 | point to data blocks. The maximum level for an inode is 2. 183 | 184 | Args: 185 | inode (Parser.Inode): Inode object. 186 | offset: offset in the content of the inode. 187 | 188 | Returns: 189 | Parser.BlockPointer: Pointer to the data block at the given offset. 190 | """ 191 | 192 | def bytes_in_unit(level): 193 | return self.blocksize * (self.blocksize // 4) ** (inode.level - level) 194 | 195 | idx = offset // bytes_in_unit(0) 196 | pointer = inode.pointers[idx] 197 | 198 | for lvl in range(inode.level): 199 | offset = offset % bytes_in_unit(lvl) 200 | idx = offset // bytes_in_unit(lvl + 1) 201 | pointer = pointer.body_as_pointer_list[idx] 202 | 203 | return pointer 204 | 205 | def check_superblock_crc(self): 206 | """Assert superblock integrity.""" 207 | superblock0 = { 208 | "raw": self.parser.qnx6_bootblock.superblock0_raw, 209 | "object": self.parser.qnx6_bootblock.superblock0, 210 | } 211 | superblock1 = { 212 | "raw": self.parser.qnx6_bootblock.superblock1_raw, 213 | "object": self.parser.qnx6_bootblock.superblock1, 214 | } 215 | 216 | crc32_func = crcmod.mkCrcFun(0x104C11DB7, initCrc=0, rev=False) 217 | 218 | for superblock in [superblock0, superblock1]: 219 | assert len(superblock["raw"]) == 512, "Incorrect superblock size!" 220 | assert superblock["object"].crc == crc32_func(superblock["raw"][8:]), "Incorrect superblock CRC!" 221 | -------------------------------------------------------------------------------- /qnxmount/qnx6/mount.py: -------------------------------------------------------------------------------- 1 | import os 2 | import errno 3 | import logging 4 | import stat 5 | from pathlib import PurePath 6 | 7 | from fuse import FUSE, FuseOSError, Operations 8 | 9 | from qnxmount.qnx6.interface import QNX6FS 10 | from qnxmount.stream import Stream 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class FuseQNX6(Operations): 16 | """Fuse implementation of the qnx6 file system. 17 | 18 | Args: 19 | stream: Kaitaistream containing the qnx6 file system. 20 | """ 21 | 22 | def __init__(self, stream): 23 | self.qnx6fs = QNX6FS(stream) 24 | 25 | def open(self, path, flags): 26 | """Get file handle to path 27 | 28 | Args: 29 | path (PurePath): Path to inode. 30 | flags: open flags. 31 | 32 | Returns: 33 | fh: file handle. 34 | """ 35 | if flags & os.O_RDWR: 36 | raise FuseOSError(errno.EROFS) 37 | return super().open(path, flags) 38 | 39 | def getattr(self, path, fh=None): 40 | """Get directory with inode information. 41 | 42 | Args: 43 | path (PurePath): Path to inode. 44 | fh: file handle. 45 | 46 | Returns: 47 | dict: dictionary with keys identical to the stat C structure of stat(2). 48 | """ 49 | LOGGER.debug(f"getattr({path=})") 50 | path = PurePath(path) 51 | inode_number = self.qnx6fs.get_inode_number_from_path(path) 52 | if inode_number is None: 53 | raise FuseOSError(errno.ENOENT) 54 | 55 | inode = self.qnx6fs.get_inode(inode_number) 56 | return dict( 57 | st_size=inode.size, 58 | st_nlink=1, 59 | st_mode=inode.mode, 60 | st_ctime=inode.ctime, 61 | st_mtime=inode.mtime, 62 | st_uid=inode.uid, 63 | st_gid=inode.gid, 64 | ) 65 | 66 | def readdir(self, path, fh): 67 | """Read content from directory inode. 68 | 69 | Args: 70 | path (PurePath): Path to inode. 71 | fh: file handle. 72 | 73 | Returns: 74 | dict: dictionary with keys identical to the stat C structure of stat(2). 75 | """ 76 | LOGGER.debug(f"readdir({path=})") 77 | path = PurePath(path) 78 | directory = self.qnx6fs.get_dir_from_path(path) 79 | for entry in directory.entries: 80 | # Here we already have access to inode numbers, we should put them in the cache so subsequent getattrs will be MUCH faster 81 | name = entry.content.name 82 | self.qnx6fs.add_to_cache(path / name, entry.inode_number) 83 | yield name 84 | 85 | def read(self, path, size, offset, fh): 86 | """Read content from an inode. 87 | 88 | Args: 89 | path (PurePath): Path to inode. 90 | size (int): size of content to be read. 91 | offset (int): starting offset in the file. 92 | fh: file handle. 93 | 94 | Returns: 95 | bytes: Raw inode content. 96 | """ 97 | LOGGER.debug(f"read({path=}, {size=}, {offset=})") 98 | path = PurePath(path) 99 | inode_number = self.qnx6fs.get_inode_number_from_path(path) 100 | if not inode_number: 101 | raise FuseOSError(errno.ENOENT) 102 | 103 | inode = self.qnx6fs.get_inode(inode_number) 104 | if inode and stat.S_ISREG(inode.mode): 105 | return self.qnx6fs.read_file(inode, offset, size) 106 | raise FuseOSError(errno.EIO) 107 | 108 | def readlink(self, path): 109 | """Read content from a link given the path. 110 | 111 | Args: 112 | path (PurePath): Path to inode. 113 | 114 | Returns: 115 | str: symlink target 116 | """ 117 | LOGGER.debug(f"readlink({path=})") 118 | path = PurePath(path) 119 | inode_number = self.qnx6fs.get_inode_number_from_path(path) 120 | if not inode_number: 121 | raise FuseOSError(errno.ENOENT) 122 | 123 | inode = self.qnx6fs.get_inode(inode_number) 124 | if inode and stat.S_ISLNK(inode.mode): 125 | link_raw = self.qnx6fs.read_file(inode) 126 | return link_raw.decode("utf8") 127 | raise FuseOSError(errno.ENOENT) 128 | 129 | 130 | def mount(image, mount_point, offset): 131 | with Stream(image, offset) as stream: 132 | qnx6 = FuseQNX6(stream) 133 | FUSE(qnx6, str(mount_point), nothreads=True, foreground=True) 134 | -------------------------------------------------------------------------------- /qnxmount/qnx6/parser.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: parser 3 | file-extension: .bin 4 | title: qnx6_parser 5 | endian: le 6 | 7 | seq: 8 | - id: qnx6_bootblock 9 | type: bootblock 10 | 11 | types: 12 | bootblock: 13 | seq: 14 | - id: magic 15 | contents: [0xeb, 0x10, 0x90 ,0x00] 16 | - id: offset_qnx6fs 17 | type: u4 18 | - id: sblk0 19 | type: u4 20 | - id: sblk1 21 | type: u4 22 | 23 | instances: 24 | superblock0: 25 | io: _root._io 26 | pos: sblk0*512 27 | type: superblock 28 | superblock1: 29 | io: _root._io 30 | pos: sblk1*512 31 | type: superblock 32 | superblock0_raw: 33 | io: _root._io 34 | pos: sblk0*512 35 | size: 512 36 | superblock1_raw: 37 | io: _root._io 38 | pos: sblk1*512 39 | size: 512 40 | 41 | superblock: 42 | seq: 43 | - id: magic 44 | contents: [0x22, 0x11, 0x19, 0x68] 45 | - id: crc 46 | type: u4 47 | - id: serial 48 | type: u8 49 | - id: ctime 50 | type: u4 51 | - id: atime 52 | type: u4 53 | - id: flag 54 | type: u4 55 | - id: version1 56 | type: u2 57 | - id: version2 58 | type: u2 59 | - id: volumeid 60 | size: 16 #check 61 | - id: blocksize 62 | type: u4 63 | - id: num_inodes 64 | type: u4 65 | - id: free_inodes 66 | type: u4 67 | - id: num_blocks 68 | type: u4 69 | - id: free_blocks 70 | type: u4 71 | - id: allocgroup 72 | type: u4 73 | - id: inodes 74 | type: rootnode 75 | - id: bitmap 76 | type: rootnode 77 | - id: longfile 78 | type: rootnode 79 | - id: iclaim 80 | type: rootnode 81 | - id: iextra 82 | type: rootnode 83 | - id: migrate_blocks 84 | type: u4 85 | - id: scrub_block 86 | type: u4 87 | - id: spare 88 | size: 32 89 | 90 | rootnode: 91 | seq: 92 | - id: size 93 | type: u8 94 | - id: pointers 95 | type: block_pointer 96 | repeat: expr 97 | repeat-expr: 16 98 | - id: level 99 | type: u1 100 | - id: mode 101 | size: 1 102 | - id: spare 103 | size: 6 104 | 105 | inode: 106 | seq: 107 | - id: size 108 | type: u8 109 | - id: uid 110 | type: u4 111 | - id: gid 112 | type: u4 113 | - id: ftime 114 | type: u4 115 | - id: mtime 116 | type: u4 117 | - id: atime 118 | type: u4 119 | - id: ctime 120 | type: u4 121 | - id: mode 122 | type: u2 123 | - id: ext_mode 124 | type: u2 125 | - id: pointers 126 | type: block_pointer 127 | repeat: expr 128 | repeat-expr: 16 129 | - id: level 130 | type: u1 131 | - id: status 132 | size: 1 133 | - id: unknown 134 | size: 2 135 | - id: zeros 136 | type: u4 137 | repeat: expr 138 | repeat-expr: 6 139 | 140 | block_pointer: 141 | seq: 142 | - id: ptr 143 | type: u4 144 | instances: 145 | raw_body: 146 | io: _root._io 147 | pos: _root.data_start + ptr * _root.blocksize 148 | size: _root.blocksize 149 | body_as_pointer_list: 150 | io: _root._io 151 | pos: _root.data_start + ptr * _root.blocksize 152 | type: block_pointer 153 | repeat: expr 154 | repeat-expr: _root.blocksize / 4 155 | 156 | directory: 157 | seq: 158 | - id: entries 159 | type: dir_entry 160 | repeat: eos 161 | 162 | dir_entry: 163 | seq: 164 | - id: inode_number 165 | type: u4 166 | - id: length 167 | type: u1 168 | - id: content 169 | size: 27 170 | type: 171 | switch-on: length 172 | cases: 173 | 0xff: dir_entry_longname 174 | _: shortname 175 | 176 | dir_entry_longname: 177 | seq: 178 | - id: zeros 179 | size: 3 180 | - id: index 181 | type: u4 182 | - id: checksum 183 | type: u4 184 | - id: more_zeros 185 | size: 16 186 | instances: 187 | name: 188 | value: 0 189 | 190 | shortname: 191 | seq: 192 | - id: name 193 | type: strz 194 | eos-error: false 195 | encoding: UTF-8 196 | 197 | longname: 198 | seq: 199 | - id: length 200 | type: u2 201 | - id: name 202 | type: str 203 | size: length 204 | encoding: UTF-8 205 | 206 | 207 | instances: 208 | sizeof_inode: 209 | value: sizeof 210 | blocksize: 211 | value: qnx6_bootblock.superblock0.blocksize 212 | abs_data_start_padding: 213 | value: '(blocksize > 0x3000) ? blocksize - 0x3000 : 0x3000 - blocksize' 214 | doc: Kaitai does not support abs() 215 | data_start: 216 | value: '0x3000 + ((blocksize <= 0x1000) ? 0 : abs_data_start_padding)' 217 | doc: 'https://github.com/RunZeJustin/qnx660/blob/47c4158e3993d7536170b649e6c1e09552318fb4/target/qnx6/usr/include/sys/fs_qnx6.h' 218 | 219 | 220 | 221 | -------------------------------------------------------------------------------- /qnxmount/qnx6/parser.py: -------------------------------------------------------------------------------- 1 | # This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild 2 | 3 | import kaitaistruct 4 | from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO 5 | 6 | 7 | if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 9): 8 | raise Exception("Incompatible Kaitai Struct Python API: 0.9 or later is required, but you have %s" % (kaitaistruct.__version__)) 9 | 10 | class Parser(KaitaiStruct): 11 | def __init__(self, _io, _parent=None, _root=None): 12 | self._io = _io 13 | self._parent = _parent 14 | self._root = _root if _root else self 15 | self._read() 16 | 17 | def _read(self): 18 | self.qnx6_bootblock = Parser.Bootblock(self._io, self, self._root) 19 | 20 | class DirEntryLongname(KaitaiStruct): 21 | def __init__(self, _io, _parent=None, _root=None): 22 | self._io = _io 23 | self._parent = _parent 24 | self._root = _root if _root else self 25 | self._read() 26 | 27 | def _read(self): 28 | self.zeros = self._io.read_bytes(3) 29 | self.index = self._io.read_u4le() 30 | self.checksum = self._io.read_u4le() 31 | self.more_zeros = self._io.read_bytes(16) 32 | 33 | @property 34 | def name(self): 35 | if hasattr(self, '_m_name'): 36 | return self._m_name 37 | 38 | self._m_name = 0 39 | return getattr(self, '_m_name', None) 40 | 41 | 42 | class Shortname(KaitaiStruct): 43 | def __init__(self, _io, _parent=None, _root=None): 44 | self._io = _io 45 | self._parent = _parent 46 | self._root = _root if _root else self 47 | self._read() 48 | 49 | def _read(self): 50 | self.name = (self._io.read_bytes_term(0, False, True, False)).decode(u"UTF-8") 51 | 52 | 53 | class DirEntry(KaitaiStruct): 54 | def __init__(self, _io, _parent=None, _root=None): 55 | self._io = _io 56 | self._parent = _parent 57 | self._root = _root if _root else self 58 | self._read() 59 | 60 | def _read(self): 61 | self.inode_number = self._io.read_u4le() 62 | self.length = self._io.read_u1() 63 | _on = self.length 64 | if _on == 255: 65 | self._raw_content = self._io.read_bytes(27) 66 | _io__raw_content = KaitaiStream(BytesIO(self._raw_content)) 67 | self.content = Parser.DirEntryLongname(_io__raw_content, self, self._root) 68 | else: 69 | self._raw_content = self._io.read_bytes(27) 70 | _io__raw_content = KaitaiStream(BytesIO(self._raw_content)) 71 | self.content = Parser.Shortname(_io__raw_content, self, self._root) 72 | 73 | 74 | class Superblock(KaitaiStruct): 75 | def __init__(self, _io, _parent=None, _root=None): 76 | self._io = _io 77 | self._parent = _parent 78 | self._root = _root if _root else self 79 | self._read() 80 | 81 | def _read(self): 82 | self.magic = self._io.read_bytes(4) 83 | if not self.magic == b"\x22\x11\x19\x68": 84 | raise kaitaistruct.ValidationNotEqualError(b"\x22\x11\x19\x68", self.magic, self._io, u"/types/superblock/seq/0") 85 | self.crc = self._io.read_u4le() 86 | self.serial = self._io.read_u8le() 87 | self.ctime = self._io.read_u4le() 88 | self.atime = self._io.read_u4le() 89 | self.flag = self._io.read_u4le() 90 | self.version1 = self._io.read_u2le() 91 | self.version2 = self._io.read_u2le() 92 | self.volumeid = self._io.read_bytes(16) 93 | self.blocksize = self._io.read_u4le() 94 | self.num_inodes = self._io.read_u4le() 95 | self.free_inodes = self._io.read_u4le() 96 | self.num_blocks = self._io.read_u4le() 97 | self.free_blocks = self._io.read_u4le() 98 | self.allocgroup = self._io.read_u4le() 99 | self.inodes = Parser.Rootnode(self._io, self, self._root) 100 | self.bitmap = Parser.Rootnode(self._io, self, self._root) 101 | self.longfile = Parser.Rootnode(self._io, self, self._root) 102 | self.iclaim = Parser.Rootnode(self._io, self, self._root) 103 | self.iextra = Parser.Rootnode(self._io, self, self._root) 104 | self.migrate_blocks = self._io.read_u4le() 105 | self.scrub_block = self._io.read_u4le() 106 | self.spare = self._io.read_bytes(32) 107 | 108 | 109 | class Inode(KaitaiStruct): 110 | def __init__(self, _io, _parent=None, _root=None): 111 | self._io = _io 112 | self._parent = _parent 113 | self._root = _root if _root else self 114 | self._read() 115 | 116 | def _read(self): 117 | self.size = self._io.read_u8le() 118 | self.uid = self._io.read_u4le() 119 | self.gid = self._io.read_u4le() 120 | self.ftime = self._io.read_u4le() 121 | self.mtime = self._io.read_u4le() 122 | self.atime = self._io.read_u4le() 123 | self.ctime = self._io.read_u4le() 124 | self.mode = self._io.read_u2le() 125 | self.ext_mode = self._io.read_u2le() 126 | self.pointers = [] 127 | for i in range(16): 128 | self.pointers.append(Parser.BlockPointer(self._io, self, self._root)) 129 | 130 | self.level = self._io.read_u1() 131 | self.status = self._io.read_bytes(1) 132 | self.unknown = self._io.read_bytes(2) 133 | self.zeros = [] 134 | for i in range(6): 135 | self.zeros.append(self._io.read_u4le()) 136 | 137 | 138 | 139 | class Rootnode(KaitaiStruct): 140 | def __init__(self, _io, _parent=None, _root=None): 141 | self._io = _io 142 | self._parent = _parent 143 | self._root = _root if _root else self 144 | self._read() 145 | 146 | def _read(self): 147 | self.size = self._io.read_u8le() 148 | self.pointers = [] 149 | for i in range(16): 150 | self.pointers.append(Parser.BlockPointer(self._io, self, self._root)) 151 | 152 | self.level = self._io.read_u1() 153 | self.mode = self._io.read_bytes(1) 154 | self.spare = self._io.read_bytes(6) 155 | 156 | 157 | class BlockPointer(KaitaiStruct): 158 | def __init__(self, _io, _parent=None, _root=None): 159 | self._io = _io 160 | self._parent = _parent 161 | self._root = _root if _root else self 162 | self._read() 163 | 164 | def _read(self): 165 | self.ptr = self._io.read_u4le() 166 | 167 | @property 168 | def raw_body(self): 169 | if hasattr(self, '_m_raw_body'): 170 | return self._m_raw_body 171 | 172 | io = self._root._io 173 | _pos = io.pos() 174 | io.seek((self._root.data_start + (self.ptr * self._root.blocksize))) 175 | self._m_raw_body = io.read_bytes(self._root.blocksize) 176 | io.seek(_pos) 177 | return getattr(self, '_m_raw_body', None) 178 | 179 | @property 180 | def body_as_pointer_list(self): 181 | if hasattr(self, '_m_body_as_pointer_list'): 182 | return self._m_body_as_pointer_list 183 | 184 | io = self._root._io 185 | _pos = io.pos() 186 | io.seek((self._root.data_start + (self.ptr * self._root.blocksize))) 187 | self._m_body_as_pointer_list = [] 188 | for i in range(self._root.blocksize // 4): 189 | self._m_body_as_pointer_list.append(Parser.BlockPointer(io, self, self._root)) 190 | 191 | io.seek(_pos) 192 | return getattr(self, '_m_body_as_pointer_list', None) 193 | 194 | 195 | class Bootblock(KaitaiStruct): 196 | def __init__(self, _io, _parent=None, _root=None): 197 | self._io = _io 198 | self._parent = _parent 199 | self._root = _root if _root else self 200 | self._read() 201 | 202 | def _read(self): 203 | self.magic = self._io.read_bytes(4) 204 | if not self.magic == b"\xEB\x10\x90\x00": 205 | raise kaitaistruct.ValidationNotEqualError(b"\xEB\x10\x90\x00", self.magic, self._io, u"/types/bootblock/seq/0") 206 | self.offset_qnx6fs = self._io.read_u4le() 207 | self.sblk0 = self._io.read_u4le() 208 | self.sblk1 = self._io.read_u4le() 209 | 210 | @property 211 | def superblock0(self): 212 | if hasattr(self, '_m_superblock0'): 213 | return self._m_superblock0 214 | 215 | io = self._root._io 216 | _pos = io.pos() 217 | io.seek((self.sblk0 * 512)) 218 | self._m_superblock0 = Parser.Superblock(io, self, self._root) 219 | io.seek(_pos) 220 | return getattr(self, '_m_superblock0', None) 221 | 222 | @property 223 | def superblock1(self): 224 | if hasattr(self, '_m_superblock1'): 225 | return self._m_superblock1 226 | 227 | io = self._root._io 228 | _pos = io.pos() 229 | io.seek((self.sblk1 * 512)) 230 | self._m_superblock1 = Parser.Superblock(io, self, self._root) 231 | io.seek(_pos) 232 | return getattr(self, '_m_superblock1', None) 233 | 234 | @property 235 | def superblock0_raw(self): 236 | if hasattr(self, '_m_superblock0_raw'): 237 | return self._m_superblock0_raw 238 | 239 | io = self._root._io 240 | _pos = io.pos() 241 | io.seek((self.sblk0 * 512)) 242 | self._m_superblock0_raw = io.read_bytes(512) 243 | io.seek(_pos) 244 | return getattr(self, '_m_superblock0_raw', None) 245 | 246 | @property 247 | def superblock1_raw(self): 248 | if hasattr(self, '_m_superblock1_raw'): 249 | return self._m_superblock1_raw 250 | 251 | io = self._root._io 252 | _pos = io.pos() 253 | io.seek((self.sblk1 * 512)) 254 | self._m_superblock1_raw = io.read_bytes(512) 255 | io.seek(_pos) 256 | return getattr(self, '_m_superblock1_raw', None) 257 | 258 | 259 | class Longname(KaitaiStruct): 260 | def __init__(self, _io, _parent=None, _root=None): 261 | self._io = _io 262 | self._parent = _parent 263 | self._root = _root if _root else self 264 | self._read() 265 | 266 | def _read(self): 267 | self.length = self._io.read_u2le() 268 | self.name = (self._io.read_bytes(self.length)).decode(u"UTF-8") 269 | 270 | 271 | class Directory(KaitaiStruct): 272 | def __init__(self, _io, _parent=None, _root=None): 273 | self._io = _io 274 | self._parent = _parent 275 | self._root = _root if _root else self 276 | self._read() 277 | 278 | def _read(self): 279 | self.entries = [] 280 | i = 0 281 | while not self._io.is_eof(): 282 | self.entries.append(Parser.DirEntry(self._io, self, self._root)) 283 | i += 1 284 | 285 | 286 | 287 | @property 288 | def sizeof_inode(self): 289 | if hasattr(self, '_m_sizeof_inode'): 290 | return self._m_sizeof_inode 291 | 292 | self._m_sizeof_inode = 128 293 | return getattr(self, '_m_sizeof_inode', None) 294 | 295 | @property 296 | def blocksize(self): 297 | if hasattr(self, '_m_blocksize'): 298 | return self._m_blocksize 299 | 300 | self._m_blocksize = self.qnx6_bootblock.superblock0.blocksize 301 | return getattr(self, '_m_blocksize', None) 302 | 303 | @property 304 | def abs_data_start_padding(self): 305 | """Kaitai does not support abs().""" 306 | if hasattr(self, '_m_abs_data_start_padding'): 307 | return self._m_abs_data_start_padding 308 | 309 | self._m_abs_data_start_padding = ((self.blocksize - 12288) if self.blocksize > 12288 else (12288 - self.blocksize)) 310 | return getattr(self, '_m_abs_data_start_padding', None) 311 | 312 | @property 313 | def data_start(self): 314 | """https://github.com/RunZeJustin/qnx660/blob/47c4158e3993d7536170b649e6c1e09552318fb4/target/qnx6/usr/include/sys/fs_qnx6.h.""" 315 | if hasattr(self, '_m_data_start'): 316 | return self._m_data_start 317 | 318 | self._m_data_start = (12288 + (0 if self.blocksize <= 4096 else self.abs_data_start_padding)) 319 | return getattr(self, '_m_data_start', None) 320 | 321 | 322 | -------------------------------------------------------------------------------- /qnxmount/stream.py: -------------------------------------------------------------------------------- 1 | from kaitaistruct import KaitaiStream 2 | import mmap 3 | 4 | class Stream: 5 | def __init__(self, path, offset=0): 6 | self.path = path 7 | self.offset = offset 8 | 9 | def __enter__(self): 10 | self.f = open(self.path, 'rb') 11 | self.mm = mmap.mmap(self.f.fileno(), length=0, access=mmap.ACCESS_READ, offset=self.offset) 12 | self.stream = KaitaiStream(self.mm) 13 | return self.stream 14 | 15 | def __exit__(self, type, value, traceback): 16 | self.stream.close() 17 | self.mm.close() 18 | self.f.close() -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/__init__.py -------------------------------------------------------------------------------- /tests/qnx6/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx6/__init__.py -------------------------------------------------------------------------------- /tests/qnx6/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tarfile 3 | from pathlib import Path 4 | 5 | @pytest.fixture(scope="session") 6 | def image_path(): 7 | image_path = Path(__file__).parent / "test_data/test_image.bin" 8 | if image_path is None or not image_path.exists(): 9 | pytest.skip() 10 | return image_path 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def tar_path(): 15 | tar_path = Path(__file__).parent / "test_data/test_image.tar.gz" 16 | if tar_path is None or not tarfile.is_tarfile(tar_path): 17 | pytest.skip() 18 | return tar_path 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/qnx6/test_data/make_test_fs.sh: -------------------------------------------------------------------------------- 1 | if [ -z "$1" ] 2 | then 3 | echo "Supply destination directory!" 4 | exit 1 5 | else 6 | if [ ! -d "$1" ] 7 | then 8 | echo "Directory not found!" 9 | exit 1 10 | fi 11 | fi 12 | 13 | 14 | dd if=/dev/zero bs=4k count=100 of="$1"/test_image.bin 15 | mkqnx6fs "$1"/test_image.bin 16 | mkdir "$1"/mnt 17 | mount -t qnx6 -o sync=optional "$1"/test_image.bin "$1"/mnt 18 | 19 | cd "$1"/mnt 20 | dd bs=1 count=16 < /dev/urandom > this_file_is_small 21 | dd bs=4k count=16 < /dev/urandom > this_file_is_large 22 | dd bs=1k if=/dev/urandom of=this_file_is_large seek=5 count=10 conv=notrunc 23 | dd bs=1 count=16 < /dev/urandom > this_file_is_removed 24 | cp this_file_is_removed this_file_is_a_copy 25 | rm this_file_is_removed 26 | dd bs=1 count=100 < /dev/urandom > this_file_is_not_renamed 27 | mv this_file_is_not_renamed this_file_is_renamed 28 | mkfifo this_is_a_fifo 29 | touch this_file_has_27_characters 30 | 31 | mkdir directory_1 32 | mkdir directory_2 33 | mkdir directory_1/directory_1_1 34 | cd directory_1/directory_1_1 35 | echo "This is a test file system!" > message.txt 36 | cd .. 37 | ln -s directory_1_1/message.txt link_message.txt 38 | dd bs=1 count=100 < /dev/urandom > this_file_is_moved 39 | mv this_file_is_moved ../this_file_is_moved 40 | cd .. 41 | ln -s directory_1/link_message.txt link_link_message.txt 42 | ln -s ../directory_2 link_directory_2 43 | 44 | cd directory_2 45 | touch read_only 46 | chmod 444 read_only 47 | touch full_permission 48 | chmod 777 full_permission 49 | touch standard_file 50 | touch root_only 51 | chmod 700 root_only 52 | touch executable 53 | chmod 111 executable 54 | touch descending 55 | chmod 764 descending 56 | touch the_weird_one 57 | chmod 002 the_weird_one 58 | touch another_owner 59 | chown nobody another_owner 60 | 61 | cd .. 62 | mkdir copy_of_directory_2 63 | cp -r directory_2 copy_of_directory_2 64 | 65 | cd "$1"/mnt 66 | tar -czvf "$1"/test_image.tar.gz * 67 | umount "$1"/mnt 68 | rm -r "$1"/mnt 69 | -------------------------------------------------------------------------------- /tests/qnx6/test_data/test_image.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx6/test_data/test_image.bin -------------------------------------------------------------------------------- /tests/qnx6/test_data/test_image.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx6/test_data/test_image.tar.gz -------------------------------------------------------------------------------- /tests/qnx6/test_qnx6fs.py: -------------------------------------------------------------------------------- 1 | import tarfile 2 | from pathlib import Path 3 | from stat import S_ISBLK, S_ISCHR, S_ISDIR, S_ISFIFO, S_ISLNK, S_ISREG 4 | 5 | from qnxmount.qnx6.interface import QNX6FS 6 | from qnxmount.stream import Stream 7 | 8 | 9 | def test_compare_parsing_of_image_with_tar(image_path, tar_path): 10 | with Stream(image_path) as stream, tarfile.open(tar_path) as qnx6_tar: 11 | qnx6_image = QNX6FS(stream) 12 | for member in qnx6_tar.getmembers(): 13 | inode_number = qnx6_image.get_inode_number_from_path(Path(member.name)) 14 | inode = qnx6_image.get_inode(inode_number) 15 | 16 | assert_inode_contents_equal(member, inode) 17 | if member.isreg(): # for regular files check if contents equal 18 | assert qnx6_tar.extractfile(member).read() == qnx6_image.read_file(inode) 19 | 20 | 21 | def assert_inode_contents_equal(tar_inode, image_inode): 22 | assert tar_inode.uid == image_inode.uid 23 | assert tar_inode.gid == image_inode.gid 24 | assert tar_inode.mtime == image_inode.mtime 25 | assert tar_inode.isdir() == S_ISDIR(image_inode.mode) 26 | assert tar_inode.issym() == S_ISLNK(image_inode.mode) 27 | assert tar_inode.isreg() == S_ISREG(image_inode.mode) 28 | assert tar_inode.isfifo() == S_ISFIFO(image_inode.mode) 29 | assert tar_inode.isblk() == S_ISBLK(image_inode.mode) 30 | assert tar_inode.ischr() == S_ISCHR(image_inode.mode) 31 | """ 32 | Mode bits are saved differently in tar files than in regular inodes. 33 | The permission, setuid, setgid and reserved bits are masked when the mode is saved to tar. 34 | The other bits (such as whether the file is a directory) are saved elsewhere. 35 | Directly comparing the modes would therefore result in an assertion error. 36 | To account for this the mode bits are masked with 0o7777. 37 | For more info on how tar saves mode bits see https://www.gnu.org/software/tar/manual/html_node/Standard.html 38 | and for qnx6 https://www.qnx.com/developers/docs/7.1/#com.qnx.doc.neutrino.lib_ref/topic/s/stat_struct.html. 39 | """ 40 | assert tar_inode.mode & 0o7777 == image_inode.mode & 0o7777 # permissions, setuid, setgid, reserved bit 41 | 42 | 43 | def test_read_file_on_large_regular_file(image_path, tar_path): 44 | with Stream(image_path) as stream, tarfile.open(tar_path) as qnx6_tar: 45 | qnx6_image = QNX6FS(stream) 46 | 47 | for member in qnx6_tar.getmembers(): 48 | if member.isreg() and member.size > qnx6_image.blocksize: 49 | inode_number = qnx6_image.get_inode_number_from_path(Path(member.name)) 50 | inode = qnx6_image.get_inode(inode_number) 51 | 52 | offset = max(17, member.size % qnx6_image.blocksize) 53 | 54 | def tar_raw_bytes(o, s): 55 | return qnx6_tar.extractfile(member).read()[o : o + s] 56 | 57 | def image_raw_bytes(o, s): 58 | return qnx6_image.read_file(inode, offset=o, size=s) 59 | 60 | assert tar_raw_bytes(offset, qnx6_image.blocksize) == image_raw_bytes(offset, qnx6_image.blocksize) 61 | assert tar_raw_bytes(0, offset) == image_raw_bytes(0, offset) 62 | assert tar_raw_bytes(offset, qnx6_image.blocksize - offset) == image_raw_bytes(offset, qnx6_image.blocksize - offset) 63 | assert tar_raw_bytes(offset, offset) == image_raw_bytes(offset, offset) 64 | -------------------------------------------------------------------------------- /tests/qnx_efs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_efs/__init__.py -------------------------------------------------------------------------------- /tests/qnx_efs/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tarfile 3 | from qnxmount.efs.interface import scan_partitions 4 | from pathlib import Path 5 | 6 | @pytest.fixture(scope="session") 7 | def efs_image(): 8 | image_path = Path(__file__).parent / "test_data/test_image.bin" 9 | if image_path is None or not image_path.exists(): 10 | pytest.skip() 11 | return next(scan_partitions(image_path)) 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def efs_tar(): 16 | tar_path = Path(__file__).parent / "test_data/test_image.tar.gz" 17 | if tar_path is None or not tarfile.is_tarfile(tar_path): 18 | pytest.skip() 19 | return tarfile.open(tar_path) 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/qnx_efs/test_data/efs.bld: -------------------------------------------------------------------------------- 1 | [block_size=128k spare_blocks=1 min_size=512k mount=/mnt/test123] 2 | 3 | -------------------------------------------------------------------------------- /tests/qnx_efs/test_data/make_test_fs.sh: -------------------------------------------------------------------------------- 1 | if [ ! -e /dev/hd0t77 ] 2 | then 3 | devb -ram ram capacity=819200 & 4 | dinit -q -h /dev/hd0t77 5 | mkdir /ram 6 | mount /dev/hd0t77 /ram 7 | fi 8 | 9 | mountpoint=/mnt/test123 10 | 11 | # kill already running devf-ram processes 12 | for p in $(ps -A | grep devf-ram | awk '//{print $1}') 13 | do 14 | kill $p 15 | done 16 | 17 | # Create empty EFS image 18 | mkefs /tmp/efs.bld /ram/image.efs 19 | 20 | # Start flash driver emulated in RAM, 2M size, 128k blocksize 21 | devf-ram -s0,2M,,,128k 22 | # Erase flash device 23 | flashctl -p /dev/fs0 -ev 24 | # Copy image into flash 25 | cp -V /ram/image.efs /dev/fs0 26 | 27 | # Start creating files 28 | cd $mountpoint 29 | dd bs=1 count=16 < /dev/urandom > this_file_is_small 30 | dd bs=4k count=16 < /dev/urandom > this_file_is_large 31 | dd bs=1k if=/dev/urandom of=this_file_is_large seek=5 count=10 conv=notrunc 32 | dd bs=1 count=16 < /dev/urandom > this_file_is_removed 33 | cp this_file_is_removed this_file_is_a_copy 34 | rm this_file_is_removed 35 | dd bs=1 count=100 < /dev/urandom > this_file_is_not_renamed 36 | mv this_file_is_not_renamed this_file_is_renamed 37 | mkfifo this_is_a_fifo 38 | 39 | mkdir directory_1 40 | mkdir directory_2 41 | mkdir directory_1/directory_1_1 42 | cd directory_1/directory_1_1 43 | echo "This is a test file system!" > message.txt 44 | cd .. 45 | ln -s directory_1_1/message.txt link_message.txt 46 | dd bs=1 count=100 < /dev/urandom > this_file_is_moved 47 | mv this_file_is_moved ../this_file_is_moved 48 | cd .. 49 | ln -s directory_1/link_message.txt link_link_message.txt 50 | ln -s ../directory_2 link_directory_2 51 | 52 | cd directory_2 53 | touch read_only 54 | chmod 444 read_only 55 | touch full_permission 56 | chmod 777 full_permission 57 | touch standard_file 58 | touch root_only 59 | chmod 700 root_only 60 | touch executable 61 | chmod 111 executable 62 | touch descending 63 | chmod 764 descending 64 | touch the_weird_one 65 | chmod 002 the_weird_one 66 | touch another_owner 67 | chown nobody another_owner 68 | 69 | cd .. 70 | mkdir copy_of_directory_2 71 | cp -r directory_2 copy_of_directory_2 72 | 73 | cd $mountpoint 74 | 75 | # For some reason tar does not play nice with efs/devf-ram when handling directories 76 | # Manually appending everything to the tar does work 77 | rm /ram/test_image.tar 78 | for f in $(find | tail +1 | sed -r s:^\.\/::) 79 | do 80 | tar rvf /ram/test_image.tar --no-recursion $f; 81 | done 82 | gzip /ram/test_image.tar 83 | 84 | sync 85 | 86 | dd if=/dev/fs0 of=/ram/test_image.bin 87 | 88 | -------------------------------------------------------------------------------- /tests/qnx_efs/test_data/test_image.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_efs/test_data/test_image.bin -------------------------------------------------------------------------------- /tests/qnx_efs/test_data/test_image.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_efs/test_data/test_image.tar.gz -------------------------------------------------------------------------------- /tests/qnx_efs/test_qnx_efs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISFIFO, S_ISCHR, S_ISBLK 3 | 4 | 5 | def test_compare_parsing_of_image_with_tar(efs_image, efs_tar): 6 | for member in efs_tar.getmembers(): 7 | dir_entry = efs_image.get_dir_entry_from_path(Path(member.name)) 8 | 9 | assert_attributes_equal(member, dir_entry) 10 | if member.isreg(): # for regular files check if contents equal 11 | assert efs_tar.extractfile(member).read() == efs_image.read_file(dir_entry) 12 | 13 | 14 | def assert_attributes_equal(tar_entry, image_entry): 15 | assert tar_entry.mode & 0o7777 == image_entry.stat.mode & 0o7777 16 | assert tar_entry.mtime == image_entry.stat.mtime 17 | assert tar_entry.uid == image_entry.stat.uid 18 | assert tar_entry.gid == image_entry.stat.gid 19 | assert tar_entry.isdir() == S_ISDIR(image_entry.stat.mode) 20 | assert tar_entry.issym() == S_ISLNK(image_entry.stat.mode) 21 | assert tar_entry.isreg() == S_ISREG(image_entry.stat.mode) 22 | assert tar_entry.isfifo() == S_ISFIFO(image_entry.stat.mode) 23 | assert tar_entry.isblk() == S_ISBLK(image_entry.stat.mode) 24 | assert tar_entry.ischr() == S_ISCHR(image_entry.stat.mode) 25 | 26 | -------------------------------------------------------------------------------- /tests/qnx_etfs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_etfs/__init__.py -------------------------------------------------------------------------------- /tests/qnx_etfs/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tarfile 3 | from pathlib import Path 4 | 5 | @pytest.fixture(scope="session") 6 | def image_path(): 7 | image_path = Path(__file__).parent / "test_data/test_image.bin" 8 | if image_path is None or not image_path.exists(): 9 | pytest.skip() 10 | return image_path 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def tar_path(): 15 | tar_path = Path(__file__).parent / "test_data/test_image.tar.gz" 16 | if tar_path is None or not tarfile.is_tarfile(tar_path): 17 | pytest.skip() 18 | return tar_path 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/etfs.bld: -------------------------------------------------------------------------------- 1 | [cluster_size=1k block_size=64k num_blocks=10,0] 2 | 3 | -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/make_test_fs.sh: -------------------------------------------------------------------------------- 1 | if [ ! -e /dev/hd0t77 ] 2 | then 3 | devb-ram ram capacity=819200 & 4 | dinit -q -h /dev/hd0t77 5 | mkdir /ram 6 | mount /dev/hd0t77 /ram 7 | fi 8 | 9 | mountpoint=/etfs 10 | # kill already running devf-ram processes 11 | for p in $(ps -A | grep fs-etfs-ram | awk '//{print $1}') 12 | do 13 | kill $p 14 | done 15 | 16 | # Create empty ETFS image 17 | mketfs /tmp/etfs.bld /ram/start_image.etfs 18 | 19 | # Start flash driver emulated in RAM 20 | mkdir $mountpoint 21 | fs-etfs-ram -C 1 -e -c 0 -m $mountpoint -D size=1M 22 | etfsctl -i 23 | etfsctl -d /dev/etfs2 -S -e -w /ram/start_image.etfs -c 24 | 25 | # Start creating files 26 | cd $mountpoint 27 | dd bs=1 count=16 < /dev/urandom > this_file_is_small 28 | dd bs=4k count=16 < /dev/urandom > this_file_is_large 29 | dd bs=1k if=/dev/urandom of=this_file_is_large seek=5 count=10 conv=notrunc 30 | dd bs=1 count=16 < /dev/urandom > this_file_is_removed 31 | cp this_file_is_removed this_file_is_a_copy 32 | rm this_file_is_removed 33 | dd bs=1 count=100 < /dev/urandom > this_file_is_not_renamed 34 | mv this_file_is_not_renamed this_file_is_renamed 35 | mkfifo this_is_a_fifo 36 | touch this_file_is_32_characters_long_ 37 | 38 | mkdir directory_1 39 | mkdir directory_2 40 | mkdir directory_1/directory_1_1 41 | cd directory_1/directory_1_1 42 | echo "This is a test file system!" > message.txt 43 | cd .. 44 | ln -s directory_1_1/message.txt link_message.txt 45 | dd bs=1 count=100 < /dev/urandom > this_file_is_moved 46 | mv this_file_is_moved ../this_file_is_moved 47 | cd .. 48 | ln -s directory_1/link_message.txt link_link_message.txt 49 | ln -s ../directory_2 link_directory_2 50 | 51 | cd directory_2 52 | touch read_only 53 | chmod 444 read_only 54 | touch full_permission 55 | chmod 777 full_permission 56 | touch standard_file 57 | touch root_only 58 | chmod 700 root_only 59 | touch executable 60 | chmod 111 executable 61 | touch descending 62 | chmod 764 descending 63 | touch the_weird_one 64 | chmod 002 the_weird_one 65 | touch another_owner 66 | chown nobody another_owner 67 | 68 | cd .. 69 | mkdir copy_of_directory_2 70 | cp -r directory_2 copy_of_directory_2 71 | 72 | cd $mountpoint 73 | rm /ram/test_image.tar 74 | tar -czvf /ram/test_image.tar.gz * 75 | 76 | etfsctl -d /dev/etfs2 -S -R /ram/test_image.bin -c 77 | -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/old_test_image.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_etfs/test_data/old_test_image.bin -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/old_test_image.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_etfs/test_data/old_test_image.tar.gz -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/test_image.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_etfs/test_data/test_image.bin -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/test_image.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_etfs/test_data/test_image.tar.gz -------------------------------------------------------------------------------- /tests/qnx_etfs/test_data/test_transactions.etfs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetherlandsForensicInstitute/qnxmount/0379c064975d5bbe3595ac7e3d149ea789406794/tests/qnx_etfs/test_data/test_transactions.etfs -------------------------------------------------------------------------------- /tests/qnx_etfs/test_qnx_etfs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISFIFO, S_ISCHR, S_ISBLK 3 | from qnxmount.stream import Stream 4 | from qnxmount.etfs.interface import ETFS 5 | import tarfile 6 | 7 | 8 | def test_compare_parsing_of_image_with_tar(image_path, tar_path): 9 | with Stream(image_path) as stream, tarfile.open(tar_path) as etfs_tar: 10 | etfs_image = ETFS(stream, 1024) 11 | for member in etfs_tar.getmembers(): 12 | path = Path(member.name) 13 | if not path.is_absolute(): 14 | path = Path('/') / path 15 | fid = etfs_image.path_to_fid[path] 16 | dir_entry = etfs_image.ftable[fid] 17 | 18 | assert_attributes_equal(member, dir_entry) 19 | if member.isreg(): # for regular files check if contents equal 20 | assert etfs_tar.extractfile(member).read() == etfs_image.read_file(fid)[:dir_entry.body.size] 21 | 22 | 23 | def assert_attributes_equal(tar_entry, image_entry): 24 | assert tar_entry.mode & 0o7777 == image_entry.body.mode & 0o7777 25 | assert tar_entry.mtime == image_entry.body.mtime 26 | assert tar_entry.uid == image_entry.body.uid 27 | assert tar_entry.gid == image_entry.body.gid 28 | assert tar_entry.isdir() == S_ISDIR(image_entry.body.mode) 29 | assert tar_entry.issym() == S_ISLNK(image_entry.body.mode) 30 | assert tar_entry.isreg() == S_ISREG(image_entry.body.mode) 31 | assert tar_entry.isfifo() == S_ISFIFO(image_entry.body.mode) 32 | assert tar_entry.isblk() == S_ISBLK(image_entry.body.mode) 33 | assert tar_entry.ischr() == S_ISCHR(image_entry.body.mode) 34 | 35 | --------------------------------------------------------------------------------