From de282a39e77c90b1bba9114c5eb51efa89ff617a Mon Sep 17 00:00:00 2001 From: botlabsDev <54632107+botlabsDev@users.noreply.github.com> Date: Sun, 7 Jun 2020 18:12:16 +0200 Subject: [PATCH] add deployment workflow to pypi.org --- .github/workflows/CICD.yml | 65 +++++++++ .gitignore | 6 + README.md | 62 ++++++++- bootstrap.sh | 26 ++++ npkpy/__init__.py | 0 npkpy/analyseNpk.py | 31 +++++ npkpy/common.py | 66 +++++++++ npkpy/main.py | 43 ++++++ npkpy/npk/XCntMultiContainerList.py | 9 ++ npkpy/npk/__init__.py | 0 npkpy/npk/cntArchitectureTag.py | 9 ++ npkpy/npk/cntBasic.py | 89 +++++++++++++ npkpy/npk/cntNullBlock.py | 9 ++ npkpy/npk/cntSquasFsImage.py | 19 +++ npkpy/npk/cntSquashFsHashSignature.py | 14 ++ npkpy/npk/cntZlibCompressedData.py | 9 ++ npkpy/npk/npk.py | 101 ++++++++++++++ npkpy/npk/npkConstants.py | 54 ++++++++ npkpy/npk/npkFileBasic.py | 47 +++++++ npkpy/npk/pckDescription.py | 7 + npkpy/npk/pckEckcdsaHash.py | 9 ++ npkpy/npk/pckHeader.py | 77 +++++++++++ npkpy/npk/pckReleaseTyp.py | 9 ++ npkpy/npk/pckRequirementsHeader.py | 93 +++++++++++++ npkpy/npk/xCntFlagA.py | 14 ++ npkpy/npk/xCntFlagB.py | 14 ++ npkpy/npk/xCntFlagC.py | 14 ++ npkpy/npk/xCntMpls.py | 9 ++ npkpy/npk/xCntMultiContainerHeader.py | 15 +++ pytest.sh | 11 ++ requirements.txt | 8 ++ setup.py | 32 +++++ tests/XCntMultiContainerList_test.py | 16 +++ tests/__init__.py | 0 tests/cntBasic_test.py | 118 +++++++++++++++++ tests/cntNullBlock_test.py | 16 +++ tests/cntSquasFsImage_test.py | 27 ++++ tests/cntSquashFsHashSignature_test.py | 22 +++ tests/cntZlibCompressedData_test.py | 17 +++ tests/common_test.py | 102 ++++++++++++++ tests/constants.py | 89 +++++++++++++ tests/npkConstants_test.py | 14 ++ tests/npkFileBasic_test.py | 21 +++ tests/npkParsing_gpsFile_test.py | 162 +++++++++++++++++++++++ tests/npk_test.py | 70 ++++++++++ tests/pckDescription_test.py | 16 +++ tests/pckEckcdsaHash_test.py | 16 +++ tests/pckReleaseTyp_test.py | 16 +++ tests/testData/gps-6.45.6.npk | Bin 0 -> 53329 bytes tests/testData/gps-6.45.6.result | 86 ++++++++++++ tests/xCntFlagA_test.py | 17 +++ tests/xCntFlagB_test.py | 18 +++ tests/xCntFlagC_test.py | 17 +++ tests/xCntMpls_test.py | 16 +++ tests_acceptance_test/__init__.py | 0 tests_acceptance_test/acceptance_test.py | 66 +++++++++ tools/__init__.py | 0 tools/download_all_packages.py | 128 ++++++++++++++++++ tools/download_all_packages_test.py | 105 +++++++++++++++ tools/npkModify_test.py | 97 ++++++++++++++ tools/sections.py | 51 +++++++ tools/sections_test.py | 43 ++++++ 62 files changed, 2335 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/CICD.yml create mode 100755 bootstrap.sh create mode 100644 npkpy/__init__.py create mode 100644 npkpy/analyseNpk.py create mode 100644 npkpy/common.py create mode 100644 npkpy/main.py create mode 100644 npkpy/npk/XCntMultiContainerList.py create mode 100644 npkpy/npk/__init__.py create mode 100644 npkpy/npk/cntArchitectureTag.py create mode 100644 npkpy/npk/cntBasic.py create mode 100644 npkpy/npk/cntNullBlock.py create mode 100644 npkpy/npk/cntSquasFsImage.py create mode 100644 npkpy/npk/cntSquashFsHashSignature.py create mode 100644 npkpy/npk/cntZlibCompressedData.py create mode 100644 npkpy/npk/npk.py create mode 100644 npkpy/npk/npkConstants.py create mode 100644 npkpy/npk/npkFileBasic.py create mode 100644 npkpy/npk/pckDescription.py create mode 100644 npkpy/npk/pckEckcdsaHash.py create mode 100644 npkpy/npk/pckHeader.py create mode 100644 npkpy/npk/pckReleaseTyp.py create mode 100644 npkpy/npk/pckRequirementsHeader.py create mode 100644 npkpy/npk/xCntFlagA.py create mode 100644 npkpy/npk/xCntFlagB.py create mode 100644 npkpy/npk/xCntFlagC.py create mode 100644 npkpy/npk/xCntMpls.py create mode 100644 npkpy/npk/xCntMultiContainerHeader.py create mode 100755 pytest.sh create mode 100644 requirements.txt create mode 100755 setup.py create mode 100644 tests/XCntMultiContainerList_test.py create mode 100644 tests/__init__.py create mode 100644 tests/cntBasic_test.py create mode 100644 tests/cntNullBlock_test.py create mode 100644 tests/cntSquasFsImage_test.py create mode 100644 tests/cntSquashFsHashSignature_test.py create mode 100644 tests/cntZlibCompressedData_test.py create mode 100644 tests/common_test.py create mode 100644 tests/constants.py create mode 100644 tests/npkConstants_test.py create mode 100644 tests/npkFileBasic_test.py create mode 100644 tests/npkParsing_gpsFile_test.py create mode 100644 tests/npk_test.py create mode 100644 tests/pckDescription_test.py create mode 100644 tests/pckEckcdsaHash_test.py create mode 100644 tests/pckReleaseTyp_test.py create mode 100644 tests/testData/gps-6.45.6.npk create mode 100644 tests/testData/gps-6.45.6.result create mode 100644 tests/xCntFlagA_test.py create mode 100644 tests/xCntFlagB_test.py create mode 100644 tests/xCntFlagC_test.py create mode 100644 tests/xCntMpls_test.py create mode 100644 tests_acceptance_test/__init__.py create mode 100644 tests_acceptance_test/acceptance_test.py create mode 100644 tools/__init__.py create mode 100755 tools/download_all_packages.py create mode 100644 tools/download_all_packages_test.py create mode 100644 tools/npkModify_test.py create mode 100644 tools/sections.py create mode 100644 tools/sections_test.py diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml new file mode 100644 index 0000000..5da45d1 --- /dev/null +++ b/.github/workflows/CICD.yml @@ -0,0 +1,65 @@ +name: npkpy +on: [push] + +jobs: + run-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.6 + uses: actions/setup-python@v1 + with: + python-version: 3.6 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Lint with flake8 + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Test with pytest + run: | + pip install pytest pytest-cov codecov + pytest --cov=./ + codecov --token=${{ secrets.CODECOV_TOKEN }} + + publish-to-pypi: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@master + - name: Set up Python 3.6 + uses: actions/setup-python@v1 + with: + python-version: 3.6 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Build a binary wheel and a source tarball + run: | + python3 setup.py sdist bdist_wheel + + - name: Publish distribution + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.TEST_PYPI_PASSWORD }} + repository_url: https://test.pypi.org/legacy/ + + - name: Publish distribution to PyPI + if: startsWith(github.event.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_PASSWORD }} diff --git a/.gitignore b/.gitignore index b6e4761..66397ab 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,9 @@ dmypy.json # Pyre type checker .pyre/ + + +## custom +.idea +virtualenv/ + diff --git a/README.md b/README.md index f51ffb6..c0a90ad 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,60 @@ -# npkpy -MikroTik npk-format unpacker. +[![Actions Status](https://github.com/botlabsDev/npkpy/workflows/Pytest/badge.svg)](https://github.com/botlabsDev/npkpy/actions) +[![codecov](https://codecov.io/gh/botlabsDev/npkpy/branch/master/graph/badge.svg?token=4ns6uIqoln)](https://codecov.io/gh/botlabsDev/npkpy) + + + +# npkPy +The npkPy package module is an unpacking tool for MikroTiks custom NPK container format. The tool is capable +to display to the content of any NPK package and to export all container. + +["NPK stands for MikroTik RouterOS upgrade package"](https://whatis.techtarget.com/fileformat/NPK-MikroTik-RouterOS-upgrade-package) +and since there is no solid unpacking tool for the format available, I want to share my approach of it. +The format, in general, is used by MikroTik to install and update the software on MikroTiks routerOs systems. + +NPK packages can be found here: [MikroTik Archive](https://mikrotik.com/download/archive) + +The code covers the ability to modify the container payload. Yet, this ability won't be available for cli. +Please be aware, that you can't create or modify __valid__ packages [since they are signed](https://forum.mikrotik.com/viewtopic.php?t=87126). + +``` +All recent packages are signed with EC-KCDSA signature, +and there's no way to create a valid npk file unless you know a secret key. +``` + +## Usage + +``` +$ npkPy is an unpacking tool for MikroTiks custom NPK container format + +optional arguments: + -h, --help show this help message and exit + +input: + --files FILES Select one or more files to process + --srcFolder SRCFOLDER + Process all NPK files found recursively in given source folder. + --glob GLOB Simple glob. Filter files from --srcFolder which match the given string. + +output: + --dstFolder DSTFOLDER + Extract container into given folder + +actions: + --showContainer List all container from selected NPK files + --exportAll Export all container from selected NPK files + --exportSquashFs Export all SquashFs container from selected NPK files + --exportZlib Export all Zlib compressed container from selected NPK files + +``` + +Common understanding: A file represents an NPK package with multiple containers. +Each container 'contains' payloads like descriptions, SquashFs images or Zlib compressed data. + +## Other unpacking tools +If npkPy does not work for you, check out older approaches of NPK unpacking tools: +* [mikrotik-npk](https://github.com/kost/mikrotik-npk) +* [npk-tools](https://github.com/rsa9000/npk-tools) + + + + diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..223f13c --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +venv=${1:-virtualenv} + +## setup virtualenv if not already exist +if [[ ! -e ${venv} ]]; then + virtualenv --python=python ${venv} + ${venv}/bin/pip install pip --upgrade + ${venv}/bin/pip install pip -r requirements.txt + ${venv}/bin/pip install pip -e . +fi + +git config user.name "botlabsDev" +git config user.email "git@botlabs.dev" +echo "--git config--" +echo -n "git user:"; git config user.name +echo -n "git email:"; git config user.email +echo "--------------" + +## start virtualenv +source ${venv}/bin/activate + + + + + diff --git a/npkpy/__init__.py b/npkpy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/npkpy/analyseNpk.py b/npkpy/analyseNpk.py new file mode 100644 index 0000000..ff69097 --- /dev/null +++ b/npkpy/analyseNpk.py @@ -0,0 +1,31 @@ +from npkpy.common import getFullPktInfo, extractContainer +from npkpy.npk.cntSquasFsImage import NPK_SQUASH_FS_IMAGE +from npkpy.npk.cntZlibCompressedData import NPK_ZLIB_COMPRESSED_DATA +from npkpy.npk.npkConstants import CNT_HANDLER + +EXPORT_FOLDER_PREFIX = "npkPyExport_" + + +def analyseNpk(opts, npkFiles): + filterContainer = [] + + if opts.showContainer: + for file in npkFiles: + print("\n".join(getFullPktInfo(file))) + + if opts.exportAll: + filterContainer = CNT_HANDLER.keys() + if opts.exportSquashFs: + filterContainer = [NPK_SQUASH_FS_IMAGE] + if opts.exportZlib: + filterContainer = [NPK_ZLIB_COMPRESSED_DATA] + + if filterContainer: + for npkFile in npkFiles: + exportFolder = opts.dstFolder / f"{EXPORT_FOLDER_PREFIX}{npkFile.file.stem}" + exportFolder.mkdir(parents=True, exist_ok=True) + + extractContainer(npkFile, exportFolder, filterContainer) + + if not next(exportFolder.iterdir(), None): + exportFolder.rmdir() diff --git a/npkpy/common.py b/npkpy/common.py new file mode 100644 index 0000000..9b38c4b --- /dev/null +++ b/npkpy/common.py @@ -0,0 +1,66 @@ +import hashlib +from pathlib import Path +from typing import List + + +def getAllNkpFiles(path, containStr=None): + return path.glob(f"**/*{containStr}*.npk" if containStr else "**/*.npk") + + +def extractContainer(npkFile, exportFolder, filterContainer): + for position, cnt in enumerate(npkFile.pck_cntList): + if cnt.cnt_id in filterContainer: + filePath = exportFolder / f"{position:03}_cnt_{cnt.cnt_idName}.raw" + writeToFile(filePath, cnt.cnt_payload) + + +def writeToFile(file, payloads): + written = 0 + if not isinstance(payloads, list): + payloads = [payloads] + + with open(file, "wb") as f: + for payload in payloads: + f.write(payload) + written += len(payload) + + +def getPktInfo(file): + return [str(file.file.name)] + + +def getCntInfo(file) -> List: + return [f"Cnt:{pos:3}:{c.cnt_idName}" for pos, c in file.pck_enumerateCnt] + + +def getFullPktInfo(file) -> List: + output = getPktInfo(file) + output += getCntInfo(file) + for cnt in file.pck_cntList: + output += getFullCntInfo(cnt) + return output + + +def getFullCntInfo(cnt) -> List: + info = [] + idName, options = cnt.output_cnt + info.append(f"{idName}") + for option in options: + info.append(f" {option}") + return info + + +def sha1sumFromFile(file: Path): + with file.open('rb') as f: + return sha1sumFromBinary(f.read()) + + +def sha1sumFromBinary(payloads): + if len(payloads) == 0: + return "" + + sha1 = hashlib.sha1() + for payload in [payloads] if not isinstance(payloads, list) else payloads: + sha1.update(payload) + + return sha1.digest() diff --git a/npkpy/main.py b/npkpy/main.py new file mode 100644 index 0000000..0a73fb1 --- /dev/null +++ b/npkpy/main.py @@ -0,0 +1,43 @@ +import argparse +from pathlib import Path + +from npkpy.analyseNpk import analyseNpk +from npkpy.common import getAllNkpFiles +from npkpy.npk.npk import Npk + + +def parseArgs(): + parser = argparse.ArgumentParser(description='npkPy is an unpacking tool for MikroTiks custom NPK container format') + + inputGroup = parser.add_argument_group("input") + inputGroup.add_argument("--files", action='append', type=Path, + help="Select one or more files to process") + inputGroup.add_argument("--srcFolder", type=Path, default=Path("."), + help="Process all NPK files found recursively in given source folder.") + + inputFilterGroup = inputGroup.add_mutually_exclusive_group() + inputFilterGroup.add_argument("--glob", type=str, default=None, + help="Simple glob. Filter files from --srcFolder which match the given string.") + + outputGroup = parser.add_argument_group("output") + outputGroup.add_argument("--dstFolder", type=Path, default=Path(".") / "exportNpk", + help="Extract container into given folder") + + actionGroup = parser.add_argument_group("actions") + exclusiveAction = actionGroup.add_mutually_exclusive_group(required=True) + exclusiveAction.add_argument("--showContainer", action="store_true", + help="List all container from selected NPK files") + exclusiveAction.add_argument("--exportAll", action="store_true", + help="Export all container from selected NPK files") + exclusiveAction.add_argument("--exportSquashFs", action="store_true", + help="Export all SquashFs container from selected NPK files") + exclusiveAction.add_argument("--exportZlib", action="store_true", + help="Export all Zlib compressed container from selected NPK files") + + return parser.parse_args() + + +def main(): + opts = parseArgs() + files = (Npk(f) for f in (opts.files if opts.files else getAllNkpFiles(opts.srcFolder, opts.glob))) + analyseNpk(opts, files) diff --git a/npkpy/npk/XCntMultiContainerList.py b/npkpy/npk/XCntMultiContainerList.py new file mode 100644 index 0000000..bdd478e --- /dev/null +++ b/npkpy/npk/XCntMultiContainerList.py @@ -0,0 +1,9 @@ +from npkpy.npk.pckRequirementsHeader import PckRequirementsHeader + +NPK_MULTICONTAINER_LIST = 20 + + +class XCnt_MultiContainerList(PckRequirementsHeader): + @property + def _regularCntId(self): + return NPK_MULTICONTAINER_LIST diff --git a/npkpy/npk/__init__.py b/npkpy/npk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/npkpy/npk/cntArchitectureTag.py b/npkpy/npk/cntArchitectureTag.py new file mode 100644 index 0000000..b73de0f --- /dev/null +++ b/npkpy/npk/cntArchitectureTag.py @@ -0,0 +1,9 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_ARCHITECTURE_TAG = 16 + + +class CntArchitectureTag(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_ARCHITECTURE_TAG diff --git a/npkpy/npk/cntBasic.py b/npkpy/npk/cntBasic.py new file mode 100644 index 0000000..764221c --- /dev/null +++ b/npkpy/npk/cntBasic.py @@ -0,0 +1,89 @@ +import logging +import struct + +BYTES_LEN_CNT_ID = 2 +BYTES_LEN_CNT_PAYLOAD_LEN = 4 + +NPK_CNT_BASIC = -1 + + +class NpkContainerBasic: + """ + 0____4____8____b____f + | | | | | + x0_|AABB|BBCC|C ..... C| + x1_|....|....|....|....| + + A = Container Identifier + B = Payload length + C = Payload + """ + + def __init__(self, data, offsetInPck): + self._data = bytearray(data) + self._offsetInPck = offsetInPck + self.modified = False + + @property + def _regularCntId(self): + return NPK_CNT_BASIC + + @property + def cnt_id(self): + cntId = struct.unpack_from(b"h", self._data, 0)[0] + if cntId != self._regularCntId: + raise RuntimeError(f"Cnt object does not represent given container typ {self._regularCntId}/{cntId}") + return cntId + + @property + def cnt_idName(self): + return str(self.__class__.__name__) + + @property + def cnt_payloadLen(self): + return (struct.unpack_from(b"I", self._data, 2))[0] + + @cnt_payloadLen.setter + def cnt_payloadLen(self, payloadLen): + logging.warning("[MODIFICATION] Please be aware that modifications can break the npk structure") + self.modified = True + struct.pack_into(b"I", self._data, 2, payloadLen) + + @property + def cnt_payload(self): + return struct.unpack_from(f"{self.cnt_payloadLen}s", self._data, 6)[0] + + @cnt_payload.setter + def cnt_payload(self, payload): + tmpLen = len(payload) + tmpHead = self._data[:2 + 4] + tmpHead += struct.pack(f"{tmpLen}s", payload) + self._data = tmpHead + self.cnt_payloadLen = tmpLen + + @property + def cnt_fullLength(self): + return BYTES_LEN_CNT_ID + BYTES_LEN_CNT_PAYLOAD_LEN + self.cnt_payloadLen + # return len(self._data) + + @property + def output_cnt(self): + viewLen = min(10, self.cnt_payloadLen) + + return (f"{self.cnt_idName}", [f"Cnt id: {self.cnt_id}", + f"Cnt offset: {self._offsetInPck}", + f"Cnt len: {self.cnt_fullLength}", + f"Payload len: {self.cnt_payloadLen}", + f"Payload[0:{viewLen}]: {self.cnt_payload[0:viewLen]} [...] " + ]) + + @property + def cnt_fullBinary(self): + cntId = self.cnt_id + payloadLen = self.cnt_payloadLen + + payload = struct.unpack_from(f"{self.cnt_payloadLen}s", + buffer=self._data, + offset=BYTES_LEN_CNT_ID + BYTES_LEN_CNT_PAYLOAD_LEN)[0] + + return struct.pack(b"=hI", cntId, payloadLen) + payload diff --git a/npkpy/npk/cntNullBlock.py b/npkpy/npk/cntNullBlock.py new file mode 100644 index 0000000..d0a3277 --- /dev/null +++ b/npkpy/npk/cntNullBlock.py @@ -0,0 +1,9 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_NULL_BLOCK = 22 + + +class CntNullBlock(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_NULL_BLOCK diff --git a/npkpy/npk/cntSquasFsImage.py b/npkpy/npk/cntSquasFsImage.py new file mode 100644 index 0000000..362a5dc --- /dev/null +++ b/npkpy/npk/cntSquasFsImage.py @@ -0,0 +1,19 @@ +from npkpy.common import sha1sumFromBinary +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_SQUASH_FS_IMAGE = 21 + + +class CntSquashFsImage(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_SQUASH_FS_IMAGE + + @property + def cnt_payload_hash(self): + return sha1sumFromBinary(self.cnt_payload) + + @property + def output_cnt(self): + idName, options = super().output_cnt + return idName, options + [f"calc Sha1Hash: {self.cnt_payload_hash}"] diff --git a/npkpy/npk/cntSquashFsHashSignature.py b/npkpy/npk/cntSquashFsHashSignature.py new file mode 100644 index 0000000..c4ed6fe --- /dev/null +++ b/npkpy/npk/cntSquashFsHashSignature.py @@ -0,0 +1,14 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_SQUASHFS_HASH_SIGNATURE = 9 + + +class CntSquashFsHashSignature(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_SQUASHFS_HASH_SIGNATURE + + @property + def output_cnt(self): + idName, options = super().output_cnt + return idName, options + [f"Payload[-10:]: {self.cnt_payload[-10:]}"] diff --git a/npkpy/npk/cntZlibCompressedData.py b/npkpy/npk/cntZlibCompressedData.py new file mode 100644 index 0000000..954be1e --- /dev/null +++ b/npkpy/npk/cntZlibCompressedData.py @@ -0,0 +1,9 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_ZLIB_COMPRESSED_DATA = 4 + + +class CntZlibCompressedData(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_ZLIB_COMPRESSED_DATA diff --git a/npkpy/npk/npk.py b/npkpy/npk/npk.py new file mode 100644 index 0000000..c9abb59 --- /dev/null +++ b/npkpy/npk/npk.py @@ -0,0 +1,101 @@ +import struct +from pathlib import Path + +from npkpy.npk.npkConstants import CNT_HANDLER +from npkpy.npk.cntBasic import BYTES_LEN_CNT_ID, BYTES_LEN_CNT_PAYLOAD_LEN +from npkpy.npk.npkFileBasic import FileBasic + +MAGICBYTES = b"\x1e\xf1\xd0\xba" +BYTES_LEN_MAGIC_HEADER = 4 +BYTES_LEN_PCK_SIZE_LEN = 4 + + +class Npk(FileBasic): + """ + 0____4____8____b____f + | | | | | + 0_|AAAA|BBBB| C ..... | + 1_|....|....|....|....| + + + A = MAGIC BYTES (4) + B = PCK SIZE (4) + C = Begin of Container area + + """ + __cntList = None + + def __init__(self, filePath: Path): + super().__init__(filePath) + self.cntOffset = 8 + self._data = self.readDataFromFile(0, self.cntOffset) + self._checkMagicBytes(errorMsg="MagicBytes not found in Npk file") + self.pck_header = self.pck_cntList[0] + + @property + def pck_magicBytes(self): + return struct.unpack_from(b"4s", self._data, 0)[0] + + @property + def pck_payloadLen(self): + self.__pck_payloadUpdate() + payloadLen = struct.unpack_from(b"I", self._data, 4)[0] + return payloadLen + + def __pck_payloadUpdate(self): + if any(cnt.modified for cnt in self.pck_cntList): + currentSize = 0 + for cnt in self.pck_cntList: + currentSize += cnt.cnt_fullLength + cnt.modified = False + struct.pack_into(b"I", self._data, 4, currentSize) + + @property + def pck_fullSize(self): + return BYTES_LEN_MAGIC_HEADER + BYTES_LEN_PCK_SIZE_LEN + self.pck_payloadLen + + @property + def pck_fullBinary(self): + binary = MAGICBYTES + struct.pack("I", self.pck_payloadLen) + for c in self.pck_cntList: + binary += c.cnt_fullBinary + return binary + + @property + def pck_enumerateCnt(self): + for pos, c in enumerate(self.pck_cntList): + yield pos, c + + @property + def pck_cntList(self): + if not self.__cntList: + self.__cntList = self.__parseAllCnt() + return self.__cntList + + def __parseAllCnt(self): + lst = [] + offset = self.cntOffset + while offset < self.file.stat().st_size - 1: + lst.append(self.__getCnt(offset)) + offset += BYTES_LEN_CNT_ID + BYTES_LEN_CNT_PAYLOAD_LEN + lst[-1].cnt_payloadLen + return lst + + def __getCnt(self, offset): + cntId = struct.unpack_from("H", self.readDataFromFile(offset, 2))[0] + payloadLen = struct.unpack_from("I", self.readDataFromFile(offset + BYTES_LEN_CNT_ID, 4))[0] + pktLen = BYTES_LEN_CNT_ID + BYTES_LEN_CNT_PAYLOAD_LEN + payloadLen + + data = self.readDataFromFile(offset, pktLen) + if len(data) != pktLen: + raise RuntimeError(f"File maybe corrupted. Please download again. File: {self.file.absolute()}") + try: + return CNT_HANDLER[cntId](data, offset) + except KeyError: + raise RuntimeError(f"failed with id: {cntId}\n" + f"New cnt id discovered in file: {self.file.absolute()}") + # except TypeError: + # raise RuntimeError(f"failed with id: {cntId}\n{self.file.absolute()}") + + def _checkMagicBytes(self, errorMsg): + if not self.pck_magicBytes == MAGICBYTES: + raise RuntimeError(errorMsg) diff --git a/npkpy/npk/npkConstants.py b/npkpy/npk/npkConstants.py new file mode 100644 index 0000000..2a833e6 --- /dev/null +++ b/npkpy/npk/npkConstants.py @@ -0,0 +1,54 @@ +from npkpy.npk.XCntMultiContainerList import NPK_MULTICONTAINER_LIST, XCnt_MultiContainerList +from npkpy.npk.cntArchitectureTag import NPK_ARCHITECTURE_TAG, CntArchitectureTag +from npkpy.npk.cntNullBlock import NPK_NULL_BLOCK, CntNullBlock +from npkpy.npk.pckReleaseTyp import NPK_RELEASE_TYP, PckReleaseTyp +from npkpy.npk.cntSquasFsImage import NPK_SQUASH_FS_IMAGE, CntSquashFsImage +from npkpy.npk.cntSquashFsHashSignature import NPK_SQUASHFS_HASH_SIGNATURE, CntSquashFsHashSignature +from npkpy.npk.cntZlibCompressedData import NPK_ZLIB_COMPRESSED_DATA, CntZlibCompressedData +from npkpy.npk.cntBasic import NPK_CNT_BASIC, NpkContainerBasic +from npkpy.npk.pckDescription import NPK_PCK_DESCRIPTION, PckDescription +from npkpy.npk.pckEckcdsaHash import NPK_ECKCDSA_HASH, PckEckcdsaHash +from npkpy.npk.pckHeader import NPK_PCK_HEADER, PckHeader +from npkpy.npk.pckRequirementsHeader import NPK_REQUIREMENTS_HEADER, PckRequirementsHeader +from npkpy.npk.xCntFlagB import NPK_FLAG_B, XCnt_flagB +from npkpy.npk.xCntFlagC import NPK_FLAG_C, XCnt_flagC +from npkpy.npk.xCntFlagA import NPK_FLAG_A, XCnt_flagA +from npkpy.npk.xCntMultiContainerHeader import NPK_MULTICONTAINER_HEADER, XCnt_multiContainerHeader +from npkpy.npk.xCntMpls import NPK_MPLS, XCntMpls + + + +CNT_HANDLER = { + NPK_CNT_BASIC: NpkContainerBasic, + 0: "?", + NPK_PCK_HEADER: PckHeader, + NPK_PCK_DESCRIPTION: PckDescription, + NPK_REQUIREMENTS_HEADER: PckRequirementsHeader, + NPK_ZLIB_COMPRESSED_DATA: CntZlibCompressedData, + 5: "?", + 6: "?", + NPK_FLAG_A: XCnt_flagA, + NPK_FLAG_B: XCnt_flagB, + NPK_SQUASHFS_HASH_SIGNATURE: CntSquashFsHashSignature, + 10: "?", + 11: "?", + 12: "?", + 13: "?", + 14: "?", + 15: "?", + NPK_ARCHITECTURE_TAG: CntArchitectureTag, + NPK_FLAG_C: XCnt_flagC, + NPK_MULTICONTAINER_HEADER: XCnt_multiContainerHeader, + NPK_MPLS: XCntMpls, + NPK_MULTICONTAINER_LIST: XCnt_MultiContainerList, + NPK_SQUASH_FS_IMAGE: CntSquashFsImage, + NPK_NULL_BLOCK: CntNullBlock, + NPK_ECKCDSA_HASH: PckEckcdsaHash, + NPK_RELEASE_TYP: PckReleaseTyp, + 25: "?", + 26: "?", + 27: "?", + 28: "?", + 29: "?", + 30: "?", +} diff --git a/npkpy/npk/npkFileBasic.py b/npkpy/npk/npkFileBasic.py new file mode 100644 index 0000000..1626399 --- /dev/null +++ b/npkpy/npk/npkFileBasic.py @@ -0,0 +1,47 @@ +import re +from pathlib import Path + +from npkpy.common import sha1sumFromFile + +ARCHITECTURES = ['arm', 'mipsbe', 'mipsle', 'mmips', 'ppc', 'smips', 'tile', 'x86'] + +RE_SUFFIX = '\.(npk$)' +RE_VERSION = '-(\d\.\d\d\.\d)' +RE_PROGRAM_NAME = '(^[\w-]*)-' + + +class FileBasic: + __data = None + + def __init__(self, filePath: Path): + self.file = filePath + + @property + def filename_suffix(self): + return re.search(RE_SUFFIX, self.file.name).group(1) + + @property + def filename_version(self): + return re.search(RE_VERSION, self.file.name).group(1) + + @property + def filename_architecture(self): + for a in ARCHITECTURES: + if f"_{a}_" in self.file.name: + return a + return "x86" + + @property + def filename_program(self): + name = re.search(RE_PROGRAM_NAME, self.file.name).group(1) + name.replace(f"_{self.filename_architecture}_", "") + return name + + @property + def file_hash(self): + return sha1sumFromFile(self.file) + + def readDataFromFile(self, offset, size): + with self.file.open("rb") as f: + f.seek(offset) + return bytearray(f.read(size)) diff --git a/npkpy/npk/pckDescription.py b/npkpy/npk/pckDescription.py new file mode 100644 index 0000000..80c27da --- /dev/null +++ b/npkpy/npk/pckDescription.py @@ -0,0 +1,7 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_PCK_DESCRIPTION = 2 +class PckDescription(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_PCK_DESCRIPTION diff --git a/npkpy/npk/pckEckcdsaHash.py b/npkpy/npk/pckEckcdsaHash.py new file mode 100644 index 0000000..1e2b52c --- /dev/null +++ b/npkpy/npk/pckEckcdsaHash.py @@ -0,0 +1,9 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_ECKCDSA_HASH = 23 +class PckEckcdsaHash(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_ECKCDSA_HASH + + diff --git a/npkpy/npk/pckHeader.py b/npkpy/npk/pckHeader.py new file mode 100644 index 0000000..07dc567 --- /dev/null +++ b/npkpy/npk/pckHeader.py @@ -0,0 +1,77 @@ +import datetime +import struct + +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_PCK_HEADER = 1 + + +class PckHeader(NpkContainerBasic): + """ + 0____4____8____b____f + | | | | | + x0_|AABB|BBCC|CCCC|CCCC| + x1_|CCCC|CCDE|FGHH|HH..| + x2_|....|....|....|....| + + + A = Container Identifier (2) + B = Payload length (4) + C = Program Name (16) + D = Program version: revision + E = Program version: rc + F = Program version: minor + G = Program version: major + H = Build time + I = NULL BLock / Flags + + """ + + + def __init__(self, data, offsetInPck): + super().__init__(data, offsetInPck) + self._offset = offsetInPck + self.flagOffset = 0 + + @property + def _regularCntId(self): + return NPK_PCK_HEADER + + @property + def cnt_programName(self): + # TODO: b"" - b needs to be removed! + return bytes(struct.unpack_from(b"16B", self._data, 6)).decode().rstrip('\x00') + + @property + def cnt_osVersion(self): + revision = (struct.unpack_from(b"B", self._data, 22))[0] + rc = (struct.unpack_from(b"B", self._data, 23))[0] + minor = (struct.unpack_from(b"B", self._data, 24))[0] + major = (struct.unpack_from(b"B", self._data, 25))[0] + return f"{major}.{minor}.{revision} - rc(?): {rc}" + + @property + def cnt_built_time(self): + return datetime.datetime.utcfromtimestamp(struct.unpack_from(b"I", self._data, 26)[0]) + + @property + def cnt_nullBlock(self): + return struct.unpack_from(b"4B", self._data, 30) + + @property + def cnt_flags(self): + try: + return struct.unpack_from(b"7B", self._data, 34) + except struct.error: + ## pkt with version 5.23 seems to have only four flags. + return struct.unpack_from(b"4B", self._data, 34) + + @property + def output_cnt(self): + idName, options = super().output_cnt + return (idName, options + [f"Program name: {self.cnt_programName}", + f"Os version: {self.cnt_osVersion}", + f"Created at: {self.cnt_built_time}", + f"NullBlock: {self.cnt_nullBlock}", + f"Flags: {self.cnt_flags}" + ]) diff --git a/npkpy/npk/pckReleaseTyp.py b/npkpy/npk/pckReleaseTyp.py new file mode 100644 index 0000000..453ed40 --- /dev/null +++ b/npkpy/npk/pckReleaseTyp.py @@ -0,0 +1,9 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_RELEASE_TYP = 24 + + +class PckReleaseTyp(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_RELEASE_TYP \ No newline at end of file diff --git a/npkpy/npk/pckRequirementsHeader.py b/npkpy/npk/pckRequirementsHeader.py new file mode 100644 index 0000000..f5c6929 --- /dev/null +++ b/npkpy/npk/pckRequirementsHeader.py @@ -0,0 +1,93 @@ +import struct +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_REQUIREMENTS_HEADER = 3 + + +class PckRequirementsHeader(NpkContainerBasic): + def _versionOneTwo(foo): + def check(self): + if self.cnt_structure_id > 0: + return foo(self) + return "" + + return check + + def _versionTwo(foo): + def check(self): + if self.cnt_structure_id > 1: + return foo(self) + return "" + + return check + + def __init__(self, data, offsetInPck): + super().__init__(data, offsetInPck) + self._offset = offsetInPck + + @property + def _regularCntId(self): + return NPK_REQUIREMENTS_HEADER + + @property + def cnt_structure_id(self): + return struct.unpack_from(b"H", self._data, 6)[0] + + @property + @_versionOneTwo + def cnt_programName(self): + return bytes(struct.unpack_from(b"16B", self._data, 8)).decode().rstrip('\x00') + + @property + @_versionOneTwo + def cnt_osVersionFrom(self): + revision = (struct.unpack_from(b"B", self._data, 24))[0] + rc = (struct.unpack_from(b"B", self._data, 25))[0] + minor = (struct.unpack_from(b"B", self._data, 26))[0] + major = (struct.unpack_from(b"B", self._data, 27))[0] + return f"{major}.{minor}.{revision} - rc(?): {rc}" + + @property + @_versionOneTwo + def cnt_nullBlock(self): + return struct.unpack_from(b"BBBB", self._data, 28) + + @property + @_versionOneTwo + def cnt_osVersionTo(self): + revision = (struct.unpack_from(b"B", self._data, 32))[0] + rc = (struct.unpack_from(b"B", self._data, 33))[0] + minor = (struct.unpack_from(b"B", self._data, 34))[0] + major = (struct.unpack_from(b"B", self._data, 35))[0] + return f"{major}.{minor}.{revision} - rc(?): {rc}" + + @property + @_versionTwo + def cnt_flags(self): + return struct.unpack_from(b"4B", self._data, 36) + + @property + def cnt_fullBinary(self): + id = self.cnt_id + payload_len = self.cnt_payloadLen + payload = struct.unpack_from(f"{self.cnt_payloadLen}s", self._data, 2 + 4)[0] + + # if self.cnt_structure_id > 2: + # print(self.cnt_flags) + # return struct.pack(f"HH", id, payload_len) + se + payload + return struct.pack(f"=HI", id, payload_len) + payload + + @property + def output_cnt(self): + idName, opt = super().output_cnt + options = [f"Cnt id: {self.cnt_id}", + f"StructID: {self.cnt_structure_id}", + f"Offset: {self._offset}", + f"Program name: {self.cnt_programName}", + f"Null block: {self.cnt_nullBlock}", + f"Os versionFrom: {self.cnt_osVersionFrom}", + f"Os versionTo: {self.cnt_osVersionTo}", + f"Flags: {self.cnt_flags}" + ] + + return (f"{self.cnt_idName}", opt + options) diff --git a/npkpy/npk/xCntFlagA.py b/npkpy/npk/xCntFlagA.py new file mode 100644 index 0000000..a3c5d4e --- /dev/null +++ b/npkpy/npk/xCntFlagA.py @@ -0,0 +1,14 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_FLAG_A = 7 + + +class XCnt_flagA(NpkContainerBasic): + @property + def _regularCntId(self): + return NPK_FLAG_A + + # @property + # def cnt_payload(self): + # # TODO pkt gps-5.23-mipsbe.npk contains b'\n update-console\n ' + diff --git a/npkpy/npk/xCntFlagB.py b/npkpy/npk/xCntFlagB.py new file mode 100644 index 0000000..26cd442 --- /dev/null +++ b/npkpy/npk/xCntFlagB.py @@ -0,0 +1,14 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_FLAG_B = 8 + + +class XCnt_flagB(NpkContainerBasic): + # TODO: found in gps-5.23-mipsbe.npk + @property + def _regularCntId(self): + return NPK_FLAG_B + + # @property + # def cnt_payload(self): + # # TODO pkt gps-5.23-mipsbe.npk contains b'\n update-console\n ' diff --git a/npkpy/npk/xCntFlagC.py b/npkpy/npk/xCntFlagC.py new file mode 100644 index 0000000..7559dde --- /dev/null +++ b/npkpy/npk/xCntFlagC.py @@ -0,0 +1,14 @@ +from npkpy.npk.cntBasic import NpkContainerBasic + +NPK_FLAG_C = 17 + + +class XCnt_flagC(NpkContainerBasic): + ##TODO: found in multicast-3.30-mipsbe.npk + @property + def _regularCntId(self): + return NPK_FLAG_C + + # @property + # def cnt_payload(self): + # # TODO pkt gps-5.23-mipsbe.npk contains b'\n update-console\n ' diff --git a/npkpy/npk/xCntMpls.py b/npkpy/npk/xCntMpls.py new file mode 100644 index 0000000..9bcd86d --- /dev/null +++ b/npkpy/npk/xCntMpls.py @@ -0,0 +1,9 @@ +from npkpy.npk.pckRequirementsHeader import PckRequirementsHeader + +NPK_MPLS = 19 + + +class XCntMpls(PckRequirementsHeader): + @property + def _regularCntId(self): + return NPK_MPLS diff --git a/npkpy/npk/xCntMultiContainerHeader.py b/npkpy/npk/xCntMultiContainerHeader.py new file mode 100644 index 0000000..7bdd44c --- /dev/null +++ b/npkpy/npk/xCntMultiContainerHeader.py @@ -0,0 +1,15 @@ +import struct + +from npkpy.npk.pckHeader import PckHeader + +NPK_MULTICONTAINER_HEADER: int = 18 + + +class XCnt_multiContainerHeader(PckHeader): + @property + def _regularCntId(self): + return NPK_MULTICONTAINER_HEADER + + @property + def cnt_flags(self): + return struct.unpack_from(b"4B", self._data, 34) diff --git a/pytest.sh b/pytest.sh new file mode 100755 index 0000000..aa7946f --- /dev/null +++ b/pytest.sh @@ -0,0 +1,11 @@ +!/bin/bash + +if [ ! -z "$1" ] +then + clear; echo "test specific file: $1" + pytest --cov=./ $1 -v +else + clear; echo "test all project files" + pytest --cov=npkpy --cov=acceptance_test -v + +fi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..addff5d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +pytest_httpserver +urlpath +setuptools +wheel +twine +pytest +pytest-cov + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..d6b0a75 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3.8 +import setuptools +from datetime import datetime + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + name="npkPy", + version=f"{(datetime.now()).strftime('%Y.%m.%d.%H.%M')}", + description="npkPy is an unpacker tool for MikroTiks custom npk container format", + author='botlabsDev', + author_email='npkPy@botlabs.dev', + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/botlabsDev/npkpy", + packages=setuptools.find_packages(), + python_requires='>=3.6', + classifiers=[ + "Programming Language :: Python :: 3.6", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Operating System :: OS Independent", + ], + entry_points={ + 'console_scripts': [ + "npkPy=npkpy.main:main", + # "npkDownloader=npkpy.download:main", + ], + }, + install_requires=[ + ], +) diff --git a/tests/XCntMultiContainerList_test.py b/tests/XCntMultiContainerList_test.py new file mode 100644 index 0000000..2bd9878 --- /dev/null +++ b/tests/XCntMultiContainerList_test.py @@ -0,0 +1,16 @@ +import struct +import unittest + +from npkpy.npk.XCntMultiContainerList import XCnt_MultiContainerList +from tests.constants import DummyBasicCnt + + +class Test_xCnt_MultiContainerList(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 20) + + self.cnt = XCnt_MultiContainerList(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(20, self.cnt.cnt_id) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cntBasic_test.py b/tests/cntBasic_test.py new file mode 100644 index 0000000..48af161 --- /dev/null +++ b/tests/cntBasic_test.py @@ -0,0 +1,118 @@ +import struct +import unittest + +from npkpy.npk.cntBasic import NpkContainerBasic +from tests.constants import DummyBasicCnt + + +class Test_npkContainerBasic(unittest.TestCase): + + def setUp(self) -> None: + self.cnt = NpkContainerBasic(DummyBasicCnt().binCnt, offsetInPck=0) + + def test_extractCntId(self): + self.assertEqual(-1, self.cnt.cnt_id) + + def test_failForWrongCntId(self): + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 999) + cnt = NpkContainerBasic(dummyCnt.binCnt, offsetInPck=0) + with self.assertRaises(RuntimeError) as e: + _ = cnt.cnt_id + self.assertEqual("Cnt object does not represent given container typ -1/999", e.exception.args[0]) + + def test_getNameOfContainerType(self): + self.assertEqual("NpkContainerBasic", self.cnt.cnt_idName) + + def test_extractPayloadLen(self): + self.assertEqual(7, self.cnt.cnt_payloadLen) + + def test_extractPayload(self): + self.assertEqual(b"Payload", self.cnt.cnt_payload) + + def test_extractCntFromGivenOffset(self): + self.assertEqual(len(DummyBasicCnt().binCnt), self.cnt.cnt_fullLength) + + def test_giveOverviewOfCnt(self): + expectedResult = ('NpkContainerBasic', [f'Cnt id: -1', + 'Cnt offset: 0', + 'Cnt len: 13', + 'Payload len: 7', + "Payload[0:7]: b'Payload' [...] "]) + self.assertEqual(expectedResult, self.cnt.output_cnt) + + def test_getFullBinaryOfContainer(self): + self.assertEqual(DummyBasicCnt().binCnt, self.cnt.cnt_fullBinary) + + +class Test_modifyNpkContainerBasic(unittest.TestCase): + def setUp(self) -> None: + self.cnt = NpkContainerBasic(DummyBasicCnt().binCnt, offsetInPck=0) + + def test_increaseCntSize(self): + origCntFullLength = self.cnt.cnt_fullLength + origPayloadLen = self.cnt.cnt_payloadLen + + self.cnt.cnt_payloadLen += 3 + + self.assertEqual(7, origPayloadLen) + self.assertEqual(10, self.cnt.cnt_payloadLen) + self.assertEqual(13, origCntFullLength) + self.assertEqual(16, self.cnt.cnt_fullLength) + + def test_decreaseCntSize(self): + origCntFullLength = self.cnt.cnt_fullLength + origPayloadLen = self.cnt.cnt_payloadLen + + self.cnt.cnt_payloadLen -= 4 + + self.assertEqual(7, origPayloadLen) + self.assertEqual(13, origCntFullLength) + self.assertEqual(3, self.cnt.cnt_payloadLen) + self.assertEqual(9, self.cnt.cnt_fullLength) + + def test_failAccessPayload_afterIncreasingPayloadLenField(self): + origPayloadLen = self.cnt.cnt_payloadLen + self.cnt.cnt_payloadLen += 3 + + self.assertEqual(origPayloadLen + 3, self.cnt.cnt_payloadLen) + with self.assertRaises(struct.error): + _ = self.cnt.cnt_payload + + def test_decreasingPayloadLenField_decreaseFullCntLenAndPayload(self): + origCntFullLength = self.cnt.cnt_fullLength + origPayloadLen = self.cnt.cnt_payloadLen + + self.cnt.cnt_payloadLen -= 4 + + self.assertEqual(origPayloadLen - 4, self.cnt.cnt_payloadLen) + self.assertEqual(origCntFullLength - 4, self.cnt.cnt_fullLength) + self.assertEqual(b"Pay", self.cnt.cnt_payload) + + def test_failDecreasingPayloadLenFieldBelowZero(self): + with self.assertRaises(struct.error) as e: + self.cnt.cnt_payloadLen -= 8 + self.assertEqual("argument out of range", e.exception.args[0]) + + def test_increasePayload_updatePayloadLen(self): + replacingPayload = b"NewTestPayload" + + self.cnt.cnt_payload = replacingPayload + + self.assertEqual(replacingPayload, self.cnt.cnt_payload) + self.assertEqual(len(replacingPayload), self.cnt.cnt_payloadLen) + + def test_decreasePayload_updatePayloadLen(self): + replacingPayload = b"New" + + self.cnt.cnt_payload = replacingPayload + self.assertEqual(replacingPayload, self.cnt.cnt_payload) + self.assertEqual(len(replacingPayload), self.cnt.cnt_payloadLen) + + def test_NullPayload_updatePayloadLenToZero(self): + replacingPayload = b"" + + self.cnt.cnt_payload = replacingPayload + self.assertEqual(replacingPayload, self.cnt.cnt_payload) + self.assertEqual(len(replacingPayload), self.cnt.cnt_payloadLen) + diff --git a/tests/cntNullBlock_test.py b/tests/cntNullBlock_test.py new file mode 100644 index 0000000..d536a69 --- /dev/null +++ b/tests/cntNullBlock_test.py @@ -0,0 +1,16 @@ +import struct +import unittest + +from npkpy.npk.cntNullBlock import CntNullBlock +from tests.constants import DummyBasicCnt + + +class Test_cntNullBlock(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 22) + + self.cnt = CntNullBlock(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(22, self.cnt.cnt_id) diff --git a/tests/cntSquasFsImage_test.py b/tests/cntSquasFsImage_test.py new file mode 100644 index 0000000..58f20cd --- /dev/null +++ b/tests/cntSquasFsImage_test.py @@ -0,0 +1,27 @@ +import struct +import unittest + +from npkpy.npk.cntSquasFsImage import CntSquashFsImage +from tests.constants import DummyBasicCnt + + +class Test_cntSquashFsImage(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 21) + self.cnt = CntSquashFsImage(dummyCnt.binCnt, offsetInPck=0) + + self.expectedHash = b'\xc3\x04\x15\xea\xccjYDit\xb7\x16\xef\xf5l\xf2\x82\x19\x81]' + + def test_validateCntId(self): + self.assertEqual(21, self.cnt.cnt_id) + + def test_payload_hash(self): + self.assertEqual(self.expectedHash, self.cnt.cnt_payload_hash) + + def test_giveOverviewOfCnt(self): + expected = f"calc Sha1Hash: {self.expectedHash}" + + _, cntData = self.cnt.output_cnt + + self.assertEqual(expected, cntData[-1]) diff --git a/tests/cntSquashFsHashSignature_test.py b/tests/cntSquashFsHashSignature_test.py new file mode 100644 index 0000000..5fe0f60 --- /dev/null +++ b/tests/cntSquashFsHashSignature_test.py @@ -0,0 +1,22 @@ +import struct +import unittest + +from npkpy.npk.cntSquashFsHashSignature import CntSquashFsHashSignature +from tests.constants import DummyBasicCnt + + +class Test_cntSquashFsHashSignature(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 9) + self.cnt = CntSquashFsHashSignature(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(9, self.cnt.cnt_id) + + def test_giveOverviewOfCnt(self): + expected = "Payload[-10:]: b'Payload'" + + _, cntData = self.cnt.output_cnt + + self.assertEqual(expected, cntData[-1]) diff --git a/tests/cntZlibCompressedData_test.py b/tests/cntZlibCompressedData_test.py new file mode 100644 index 0000000..072b357 --- /dev/null +++ b/tests/cntZlibCompressedData_test.py @@ -0,0 +1,17 @@ +import struct +import unittest + +from npkpy.npk.cntSquashFsHashSignature import CntSquashFsHashSignature +from tests.constants import DummyBasicCnt + + +class Test_cntSquashFsHashSignature(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 9) + + self.cnt = CntSquashFsHashSignature(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(9, self.cnt.cnt_id) + diff --git a/tests/common_test.py b/tests/common_test.py new file mode 100644 index 0000000..7a2d278 --- /dev/null +++ b/tests/common_test.py @@ -0,0 +1,102 @@ +import tempfile +import unittest +from pathlib import Path + +from npkpy.common import getPktInfo, getCntInfo, getAllNkpFiles +from npkpy.npk.npk import Npk + + +class Common_Test(unittest.TestCase): + + def setUp(self) -> None: + self.npkFile = Path(tempfile.NamedTemporaryFile(suffix=".npk").name) + self.npkFile.write_bytes(Path("tests/testData/gps-6.45.6.npk").read_bytes()) + self.npk = Npk(self.npkFile) + + def tearDown(self) -> None: + if self.npkFile.exists(): + self.npkFile.unlink() + + def test_getBasicPktInfo(self): + self.assertEqual([(str(self.npkFile.name))], getPktInfo(self.npk)) + + def test_getBasicCntInfo(self): + self.assertEqual(['Cnt: 0:PckHeader', + 'Cnt: 1:PckReleaseTyp', + 'Cnt: 2:CntArchitectureTag', + 'Cnt: 3:PckDescription', + 'Cnt: 4:PckEckcdsaHash', + 'Cnt: 5:PckRequirementsHeader', + 'Cnt: 6:CntNullBlock', + 'Cnt: 7:CntSquashFsImage', + 'Cnt: 8:CntSquashFsHashSignature', + 'Cnt: 9:CntArchitectureTag'], getCntInfo(self.npk), ) + + def test_getFullPktInfo(self): + pass + + # result = getFullPktInfo(self.npk) + # + # self.assertEqual(['Cnt: 0:PckHeader', + # 'Cnt: 1:PckReleaseTyp', + # 'Cnt: 2:PckArchitectureTag', + # 'Cnt: 3:PckDescription', + # 'Cnt: 4:PckSha1Hash', + # 'Cnt: 5:PckRequirementsHeader', + # 'Cnt: 6:PckNullBlock', + # 'Cnt: 7:PckSquashFsImage', + # 'Cnt: 8:PckSquashFsHashSignature', + # 'Cnt: 9:PckArchitectureTag'], result) + + def test_getFullCntInfo(self): + pass + + +class Test_findNpkFiles(unittest.TestCase): + + def setUp(self) -> None: + self.tmpPath = Path(tempfile.TemporaryDirectory().name) + self.tmpPath.mkdir(parents=True) + self.expectedFiles = [] + + def tearDown(self) -> None: + for file in self.tmpPath.rglob("*"): + if file.is_file(): + file.unlink() + for folder in sorted(self.tmpPath.rglob("*"), key=lambda _path: str(_path.absolute()).count("/"), reverse=True): + folder.rmdir() + self.tmpPath.rmdir() + + def test_findMultipleNpkFiles_inFolder(self): + self.addExistingFiles(["fileA.npk", "fileB.npk", "fileC.npk"]) + + self.assertExistingFiles(sorted(getAllNkpFiles(self.tmpPath))) + + def test_findMultipleNpkFilesRecursive(self): + self.addExistingFiles(["fileA.npk", "subB/fileB.npk", "subB/subC/fileC.npk"]) + + self.assertExistingFiles(sorted(getAllNkpFiles(self.tmpPath))) + + def test_selectOnlyNpkFiles(self): + self.addExistingFiles(["fileA.npk", "fileB.exe", "fileC.txt"]) + + self.expectedFiles = [self.tmpPath / "fileA.npk"] + + self.assertExistingFiles(sorted(getAllNkpFiles(self.tmpPath))) + + def test_globOnlyNpkFilesFittingPattern(self): + self.addExistingFiles(["fi__pattern__leA.npk", "fileB.npk", "fi__pattern__leC.exe", "fileD.exe"]) + + self.expectedFiles = [self.tmpPath / "fi__pattern__leA.npk"] + + self.assertExistingFiles(sorted(getAllNkpFiles(self.tmpPath, containStr="__pattern__"))) + + def assertExistingFiles(self, result): + self.assertEqual(self.expectedFiles, result) + + def addExistingFiles(self, files): + for file in files: + f = self.tmpPath / file + f.parent.mkdir(parents=True, exist_ok=True) + f.touch() + self.expectedFiles.append(f) diff --git a/tests/constants.py b/tests/constants.py new file mode 100644 index 0000000..94fcd06 --- /dev/null +++ b/tests/constants.py @@ -0,0 +1,89 @@ +# HEADER +import struct + +from npkpy.npk.pckHeader import NPK_PCK_HEADER + +MAGICBYTES = b"\x1e\xf1\xd0\xba" + +PCKSIZE = struct.pack("I", 28) +MAGIC_AND_SIZE = MAGICBYTES + PCKSIZE + +# OPENING ARCHITECTURE_TAG +SET_HEADER_TAG_ID = struct.pack("H", NPK_PCK_HEADER) # b"\x01\x00" +SET_HEADER_TAG_PAYLOAD = bytes("NAME OF PROGRAM".encode()) +SET_HEADER_TAG_SIZE = struct.pack("I", len(SET_HEADER_TAG_PAYLOAD)) + +CNT_SET_ARCHITECTURE_TAG = SET_HEADER_TAG_ID + \ + SET_HEADER_TAG_SIZE + \ + SET_HEADER_TAG_PAYLOAD + +# CLOSING ARCHITECTURE_TAG +CLOSING_ARCHITECTURE_TAG_ID = struct.pack("H", 1) # b"\x01\x00" +CLOSING_ARCHITECTURE_TAG_PAYLOAD = struct.pack("s", b"I") +CLOSING_ARCHITECTURE_TAG_SIZE = struct.pack("I", len(CLOSING_ARCHITECTURE_TAG_PAYLOAD)) + +CNT_CLOSING_ARCHITECTURE_TAG = CLOSING_ARCHITECTURE_TAG_ID + \ + CLOSING_ARCHITECTURE_TAG_SIZE + \ + CLOSING_ARCHITECTURE_TAG_PAYLOAD + +# MINIMAL_NPK_PAKAGE + + +MINIMAL_NPK_PACKAGE = MAGIC_AND_SIZE + \ + CNT_SET_ARCHITECTURE_TAG + \ + CNT_CLOSING_ARCHITECTURE_TAG + + +def getDummyNpkBinary(payload=None): + if not payload: + payload = DummyHeaderCnt().binHeaderCntA + pckPayload = payload + pckLen = struct.pack("I", len(pckPayload)) + npkBinary = MAGICBYTES + pckLen + pckPayload + return npkBinary + + +class DummyBasicCnt: + _00_cnt_id = struct.pack("h", -1) + _01_cnt_payload_len = struct.pack("I", 7) + _02_cnt_payload = struct.pack("7s", b"Payload") + + @property + def binCnt(self): + return self._00_cnt_id + \ + self._01_cnt_payload_len + \ + self._02_cnt_payload + + +class DummyHeaderCnt: + _00_cnt_id = struct.pack("H", 1) + _01_cnt_payload_len = struct.pack("I", 35) + _02_cnt_programName = struct.pack("16s", b"01234567890abcdef") + _03_cnt_versionRevision = struct.pack("B", 3) + _04_cnt_versionRc = struct.pack("B", 4) + _05_cnt_versionMinor = struct.pack("B", 2) + _06_cnt_versionMajor = struct.pack("B", 1) + _07_cnt_buildTime = struct.pack("I", 1) + _08_cnt_nullBock = struct.pack("I", 0) + _09a_cnt_flagsA = struct.pack("7B", 0, 0, 0, 0, 0, 0, 0) + # _09b_cnt_flagsB = struct.pack("4B", 0, 0, 0, 0) + + @property + def binHeaderCntA(self): + return self._binBasicHeaderCnt + self._09a_cnt_flagsA + + # @property + # def binHeaderCntB(self): + # return self._binBasicHeaderCnt + self._09b_cnt_flagsB + + @property + def _binBasicHeaderCnt(self): + return self._00_cnt_id + \ + self._01_cnt_payload_len + \ + self._02_cnt_programName + \ + self._03_cnt_versionRevision + \ + self._04_cnt_versionRc + \ + self._05_cnt_versionMinor + \ + self._06_cnt_versionMajor + \ + self._07_cnt_buildTime + \ + self._08_cnt_nullBock diff --git a/tests/npkConstants_test.py b/tests/npkConstants_test.py new file mode 100644 index 0000000..a819527 --- /dev/null +++ b/tests/npkConstants_test.py @@ -0,0 +1,14 @@ +import unittest + +from npkpy.npk.npkConstants import CNT_HANDLER +from tests.constants import DummyBasicCnt + + +class Test_npkConstants(unittest.TestCase): + + def test_validateAssignment_DictIdIsContainerId(self): + for cnt_id, cnt_class in CNT_HANDLER.items(): + if cnt_class != "?": + cnt = cnt_class(DummyBasicCnt().binCnt, 0) + self.assertEqual(cnt_id, cnt._regularCntId, + msg=f"{cnt_id}!={cnt._regularCntId}") diff --git a/tests/npkFileBasic_test.py b/tests/npkFileBasic_test.py new file mode 100644 index 0000000..e2be133 --- /dev/null +++ b/tests/npkFileBasic_test.py @@ -0,0 +1,21 @@ +import unittest +from pathlib import Path + +from npkpy.npk.npkFileBasic import FileBasic + + +class FileInfo_Test(unittest.TestCase): + def setUp(self) -> None: + self.file = FileBasic(Path("advanced-tools-6.41.3.npk")) + + def test_file(self): + self.assertEqual(Path("advanced-tools-6.41.3.npk"), self.file.file) + + def test_versionName(self): + self.assertEqual("6.41.3", self.file.filename_version) + + def test_programName(self): + self.assertEqual("advanced-tools", self.file.filename_program) + + def test_programSuffix(self): + self.assertEqual("npk", self.file.filename_suffix) diff --git a/tests/npkParsing_gpsFile_test.py b/tests/npkParsing_gpsFile_test.py new file mode 100644 index 0000000..bfe215d --- /dev/null +++ b/tests/npkParsing_gpsFile_test.py @@ -0,0 +1,162 @@ +import datetime +import unittest +from pathlib import Path + +from npkpy.npk.npk import Npk, MAGICBYTES + + +class GpsFile_Test(unittest.TestCase): + def setUp(self) -> None: + self.npkFile = Path("tests/testData/gps-6.45.6.npk") + self.npk = Npk(self.npkFile) + self.cnt = self.npk.pck_cntList + + +class ParseGpsNpkFile_Test(GpsFile_Test): + + def test_fileInfos(self): + self.assertEqual('gps', self.npk.filename_program) + self.assertEqual('6.45.6', self.npk.filename_version) + self.assertEqual('npk', self.npk.filename_suffix) + self.assertEqual('x86', self.npk.filename_architecture) + self.assertEqual(b'\xc6\x16\xf0\x9d~lS\xa7z\xba}.\xe5\xa6w=\xe9\xb4S\xe7', self.npk.file_hash) + + def test_NpkHeader(self): + self.assertEqual(MAGICBYTES, self.npk.pck_magicBytes) + self.assertEqual(53321, self.npk.pck_payloadLen) + self.assertEqual(53329, self.npk.pck_fullSize) + + def test_PckHeader(self): + self.assertEqual(1, self.npk.pck_header.cnt_id) + self.assertEqual("PckHeader", self.npk.pck_header.cnt_idName) + self.assertEqual(36, self.npk.pck_header.cnt_payloadLen) + self.assertEqual(b'gps\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06f-\x06\x97gw]' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00', self.npk.pck_header.cnt_payload) + + self.assertEqual("gps", self.npk.pck_header.cnt_programName) + self.assertEqual("6.45.6 - rc(?): 102", self.npk.pck_header.cnt_osVersion) + self.assertEqual(datetime.datetime(2019, 9, 10, 9, 6, 31), self.npk.pck_header.cnt_built_time) + self.assertEqual((0, 0, 0, 0), self.npk.pck_header.cnt_nullBlock) + self.assertEqual((0, 0, 0, 0, 2, 0, 0), self.npk.pck_header.cnt_flags) + + def test_ReleaseTyp(self): + cnt = self.cnt[1] + + self.assertEqual(24, cnt.cnt_id) + self.assertEqual(50, cnt._offsetInPck) + self.assertEqual("PckReleaseTyp", cnt.cnt_idName) + self.assertEqual(6, cnt.cnt_payloadLen) + self.assertEqual(b"stable", cnt.cnt_payload) + self.assertEqual(12, cnt.cnt_fullLength) + + def test_PckArchitectureTag(self): + cnt = self.cnt[2] + + self.assertEqual(16, cnt.cnt_id) + self.assertEqual(62, cnt._offsetInPck) + self.assertEqual("CntArchitectureTag", cnt.cnt_idName) + self.assertEqual(4, cnt.cnt_payloadLen) + self.assertEqual(b"i386", cnt.cnt_payload) + self.assertEqual(10, cnt.cnt_fullLength) + + def test_PckDescription(self): + cnt = self.cnt[3] + + self.assertEqual(2, cnt.cnt_id) + self.assertEqual(72, cnt._offsetInPck) + self.assertEqual("PckDescription", cnt.cnt_idName) + self.assertEqual(25, cnt.cnt_payloadLen) + self.assertEqual(b'Provides support for GPS.', cnt.cnt_payload) + self.assertEqual(31, cnt.cnt_fullLength) + + def test_PckHash(self): + cnt = self.cnt[4] + + self.assertEqual(23, cnt.cnt_id) + self.assertEqual(103, cnt._offsetInPck) + self.assertEqual("PckEckcdsaHash", cnt.cnt_idName) + self.assertEqual(40, cnt.cnt_payloadLen) + self.assertEqual(b'1a7d206bbfe626c55aa6d2d2caabb6a5a990f13d', cnt.cnt_payload) + self.assertEqual(46, cnt.cnt_fullLength) + + def test_PckRequirementsHeader(self): + cnt = self.cnt[5] + + self.assertEqual(3, cnt.cnt_id) + self.assertEqual(149, cnt._offsetInPck) + self.assertEqual("PckRequirementsHeader", cnt.cnt_idName) + self.assertEqual(1, cnt.cnt_structure_id) + self.assertEqual(34, cnt.cnt_payloadLen) + self.assertEqual('system', cnt.cnt_programName) + self.assertEqual((0, 0, 0, 0), cnt.cnt_nullBlock) + self.assertEqual("6.45.6 - rc(?): 102", cnt.cnt_osVersionFrom) + self.assertEqual("6.45.6 - rc(?): 102", cnt.cnt_osVersionTo) + self.assertEqual("", cnt.cnt_flags) + self.assertEqual(40, cnt.cnt_fullLength) + + def test_PckNullBlock(self): + cnt = self.cnt[6] + + self.assertEqual(22, cnt.cnt_id) + self.assertEqual(189, cnt._offsetInPck) + self.assertEqual("CntNullBlock", cnt.cnt_idName) + self.assertEqual(3895, cnt.cnt_payloadLen) + self.assertEqual(b'\x00' * 3895, cnt.cnt_payload) + self.assertEqual(3901, cnt.cnt_fullLength) + + def test_PckSquashFsImage(self): + cnt = self.cnt[7] + + self.assertEqual(21, cnt.cnt_id) + self.assertEqual(4090, cnt._offsetInPck) + self.assertEqual("CntSquashFsImage", cnt.cnt_idName) + self.assertEqual(49152, cnt.cnt_payloadLen) + self.assertEqual(b'hsqs', cnt.cnt_payload[0:4]) + self.assertEqual(49158, cnt.cnt_fullLength) + + def test_PckSquashFsHashSignature(self): + cnt = self.cnt[8] + + self.assertEqual(9, cnt.cnt_id) + self.assertEqual(53248, cnt._offsetInPck) + self.assertEqual("CntSquashFsHashSignature", cnt.cnt_idName) + self.assertEqual(68, cnt.cnt_payloadLen) + self.assertEqual(b'\x8e\xa2\xb1\x8e\xf7n\xef355', cnt.cnt_payload[0:10]) + self.assertEqual(74, cnt.cnt_fullLength) + + def test_parseGpsFilxe_PckArchitectureTag_Closing(self): + cnt = self.cnt[9] + + self.assertEqual(16, cnt.cnt_id) + self.assertEqual(53322, cnt._offsetInPck) + self.assertEqual("CntArchitectureTag", cnt.cnt_idName) + self.assertEqual(1, cnt.cnt_payloadLen) + self.assertEqual(b'I', cnt.cnt_payload[0:10]) + self.assertEqual(7, cnt.cnt_fullLength) + + def test_checkStructure(self): + self.assertEqual(10, len(self.npk.pck_cntList)) + self.assertEqual([1, 24, 16, 2, 23, 3, 22, 21, 9, 16], list(cnt.cnt_id for cnt in self.npk.pck_cntList)) + + +class WriteModifiedGpsFile_Test(GpsFile_Test): + def test_modify_PckRequirementsHeader(self): + x = b"\x03\x00\x22\x00\x00\x00\x01\x00\x73\x79\x73\x74\x65\x6d\x00\x00" + \ + b"\x00\x00\x00\x00\x00\x00\x00\x00\x06\x66\x2d\x06\x00\x00\x00\x00" + \ + b"\x06\x66\x2d\x06\x00\x00\x00\x00" + + cnt = None + for c in self.cnt: + if c.cnt_id == 3: + cnt = c + break + + self.assertEqual(x, cnt.cnt_fullBinary) + + def test_createFile_changePayloadTwice(self): + oldPayload = self.npk.pck_header.cnt_payload + + self.npk.pck_header.cnt_payload = b"A" + self.npk.pck_header.cnt_payload = oldPayload + + self.assertEqual(Npk(self.npkFile).file.read_bytes(), self.npk.pck_fullBinary) diff --git a/tests/npk_test.py b/tests/npk_test.py new file mode 100644 index 0000000..d275030 --- /dev/null +++ b/tests/npk_test.py @@ -0,0 +1,70 @@ +import struct +import tempfile +import unittest +from pathlib import Path + +from npkpy.npk.npk import Npk +from npkpy.npk.pckHeader import PckHeader +from tests.constants import DummyHeaderCnt, MAGICBYTES, getDummyNpkBinary + + +class Test_npkClass(unittest.TestCase): + + def setUp(self) -> None: + self.npkFile = Path(tempfile.NamedTemporaryFile(suffix=".npk").name) + self.npkFile.write_bytes(getDummyNpkBinary()) + + def test_fileIsNoNpkFile(self): + self.npkFile.write_bytes(b"NoMagicBytesAtHeadOfFile") + + with self.assertRaises(RuntimeError) as e: + _ = Npk(self.npkFile).pck_magicBytes + self.assertEqual(e.exception.args[0], "MagicBytes not found in Npk file") + + def test_npkFileIsCorrupt_fileCorruptException(self): + self.npkFile.write_bytes(MAGICBYTES + b"CorruptFile") + + with self.assertRaises(RuntimeError) as e: + _ = Npk(self.npkFile).pck_cntList + self.assertEqual(e.exception.args[0], + f"File maybe corrupted. Please download again. File: {self.npkFile.absolute()}") + + def test_extractMagicBytes(self): + self.assertEqual(MAGICBYTES, Npk(self.npkFile).pck_magicBytes) + + def test_extractLenOfNpkPayload_propagatedSizeIsValid(self): + self.assertEqual(len(DummyHeaderCnt().binHeaderCntA), Npk(self.npkFile).pck_payloadLen) + + def test_calculatePckFullSize_equalsFileSize(self): + self.assertEqual(self.npkFile.stat().st_size, Npk(self.npkFile).pck_fullSize) + + def test_getNpkBinary_equalsOriginalBinary(self): + npkBinary = self.npkFile.read_bytes() + + self.assertEqual(npkBinary, Npk(self.npkFile).pck_fullBinary) + + def test_getEnumeratedListOfCntInNpk(self): + cntList = list(Npk(self.npkFile).pck_enumerateCnt) + cntId, cnt = cntList[0] + + self.assertEqual(1, len(cntList)) + self.assertEqual(0, cntId) + self.assertTrue(isinstance(cnt, PckHeader)) + + def test_getAllCnt_returnAsList(self): + cntList = Npk(self.npkFile).pck_cntList + + self.assertEqual(1, len(cntList)) + self.assertTrue(isinstance(cntList[0], PckHeader)) + + def test_getAllCnt_exceptionWithUnknownCntInNpk(self): + unknownCnt = DummyHeaderCnt() + unknownCnt._00_cnt_id = struct.pack("H", 999) + self.npkFile.write_bytes(getDummyNpkBinary(payload=unknownCnt.binHeaderCntA)) + + with self.assertRaises(RuntimeError) as e: + _ = Npk(self.npkFile).pck_cntList + self.assertEqual(e.exception.args[0], f"failed with id: 999\n" + f"New cnt id discovered in file: {self.npkFile.absolute()}") + + diff --git a/tests/pckDescription_test.py b/tests/pckDescription_test.py new file mode 100644 index 0000000..6940367 --- /dev/null +++ b/tests/pckDescription_test.py @@ -0,0 +1,16 @@ +import struct +import unittest + +from npkpy.npk.pckDescription import PckDescription +from tests.constants import DummyBasicCnt + + +class Test_pckDescription(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 2) + + self.cnt = PckDescription(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(2, self.cnt.cnt_id) diff --git a/tests/pckEckcdsaHash_test.py b/tests/pckEckcdsaHash_test.py new file mode 100644 index 0000000..2c3b548 --- /dev/null +++ b/tests/pckEckcdsaHash_test.py @@ -0,0 +1,16 @@ +import struct +import unittest + +from npkpy.npk.pckEckcdsaHash import PckEckcdsaHash +from tests.constants import DummyBasicCnt + + +class Test_pckEckcdsaHash(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 23) + + self.cnt = PckEckcdsaHash(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(23, self.cnt.cnt_id) diff --git a/tests/pckReleaseTyp_test.py b/tests/pckReleaseTyp_test.py new file mode 100644 index 0000000..12c6cd1 --- /dev/null +++ b/tests/pckReleaseTyp_test.py @@ -0,0 +1,16 @@ +import struct +import unittest + +from npkpy.npk.pckReleaseTyp import PckReleaseTyp +from tests.constants import DummyBasicCnt + + +class Test_pckReleaseTyp(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 24) + + self.cnt = PckReleaseTyp(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(24, self.cnt.cnt_id) diff --git a/tests/testData/gps-6.45.6.npk b/tests/testData/gps-6.45.6.npk new file mode 100644 index 0000000000000000000000000000000000000000..9c190e2f8bf68a01d116b3d9b44631788c3037d9 GIT binary patch literal 53329 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 +CntNullBlock + Cnt id: 22 + Cnt offset: 189 + Cnt len: 3901 + Payload len: 3895 + Payload[0:10]: b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' [...] +CntSquashFsImage + Cnt id: 21 + Cnt offset: 4090 + Cnt len: 49158 + Payload len: 49152 + Payload[0:10]: b'hsqs\x15\x00\x00\x00\xa2m' [...] + calc Sha1Hash: b'c\x033\x82{\x9a$er\x92=o\x8a\xfeL-Q\x02hW' +CntSquashFsHashSignature + Cnt id: 9 + Cnt offset: 53248 + Cnt len: 74 + Payload len: 68 + Payload[0:10]: b'\x8e\xa2\xb1\x8e\xf7n\xef355' [...] + Payload[-10:]: b"Y\x9e'\xac\xccw\x91\x06]\t" +CntArchitectureTag + Cnt id: 16 + Cnt offset: 53322 + Cnt len: 7 + Payload len: 1 + Payload[0:1]: b'I' [...] diff --git a/tests/xCntFlagA_test.py b/tests/xCntFlagA_test.py new file mode 100644 index 0000000..5dd36f9 --- /dev/null +++ b/tests/xCntFlagA_test.py @@ -0,0 +1,17 @@ +import struct +import unittest + +from npkpy.npk.xCntFlagA import XCnt_flagA +from tests.constants import DummyBasicCnt + + +class Test_xCnt_flagB(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 7) + + self.cnt = XCnt_flagA(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(7, self.cnt.cnt_id) + diff --git a/tests/xCntFlagB_test.py b/tests/xCntFlagB_test.py new file mode 100644 index 0000000..e3d1d82 --- /dev/null +++ b/tests/xCntFlagB_test.py @@ -0,0 +1,18 @@ +import struct +import unittest + +from npkpy.npk.xCntFlagB import XCnt_flagB +from tests.constants import DummyBasicCnt + + +class Test_xCnt_flagB(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 8) + + self.cnt = XCnt_flagB(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(8, self.cnt.cnt_id) + + diff --git a/tests/xCntFlagC_test.py b/tests/xCntFlagC_test.py new file mode 100644 index 0000000..f9b0f10 --- /dev/null +++ b/tests/xCntFlagC_test.py @@ -0,0 +1,17 @@ +import struct +import unittest + +from npkpy.npk.xCntFlagC import XCnt_flagC +from tests.constants import DummyBasicCnt + + +class Test_xCnt_flagC(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 17) + + self.cnt = XCnt_flagC(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(17, self.cnt.cnt_id) + diff --git a/tests/xCntMpls_test.py b/tests/xCntMpls_test.py new file mode 100644 index 0000000..6758a3f --- /dev/null +++ b/tests/xCntMpls_test.py @@ -0,0 +1,16 @@ +import struct +import unittest + +from npkpy.npk.xCntMpls import XCntMpls +from tests.constants import DummyBasicCnt + + +class Test_xCntMpls(unittest.TestCase): + def setUp(self) -> None: + dummyCnt = DummyBasicCnt() + dummyCnt._00_cnt_id = struct.pack("h", 19) + + self.cnt = XCntMpls(dummyCnt.binCnt, offsetInPck=0) + + def test_validateCntId(self): + self.assertEqual(19, self.cnt.cnt_id) diff --git a/tests_acceptance_test/__init__.py b/tests_acceptance_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests_acceptance_test/acceptance_test.py b/tests_acceptance_test/acceptance_test.py new file mode 100644 index 0000000..8418bcf --- /dev/null +++ b/tests_acceptance_test/acceptance_test.py @@ -0,0 +1,66 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + + +class Test_npkPy(unittest.TestCase): + + def setUp(self) -> None: + # TODO: create DummyPkg and replace gps-6.45.6.npk + self.npkFile = Path("tests/testData/gps-6.45.6.npk") + self.pathToNpk = str(self.npkFile.absolute()) + self.npkContainerList = Path("tests/testData/gps-6.45.6.result").read_text() + self.dstFolder = Path(tempfile.mkdtemp()) + + def tearDown(self) -> None: + [f.unlink() for f in self.dstFolder.rglob("*") if f.is_file()] + [f.rmdir() for f in self.dstFolder.rglob("*")] + self.dstFolder.rmdir() + + def test_showAllContainersFromNpkPkg(self): + cmd = ["npkPy", "--file", self.pathToNpk, "--showContainer"] + output = runCmdInTerminal(cmd) + self.assertEqual(self.npkContainerList, output) + + def test_exportAllContainerFromNpk(self): + cmd = ["npkPy", "--file", self.pathToNpk, "--dstFolder", self.dstFolder.absolute(), "--exportAll"] + + runCmdInTerminal(cmd) + + exportedContainer = sorted(str(f.relative_to(self.dstFolder)) for f in self.dstFolder.rglob('*')) + self.assertEqual(['npkPyExport_gps-6.45.6', + 'npkPyExport_gps-6.45.6/000_cnt_PckHeader.raw', + 'npkPyExport_gps-6.45.6/001_cnt_PckReleaseTyp.raw', + 'npkPyExport_gps-6.45.6/002_cnt_CntArchitectureTag.raw', + 'npkPyExport_gps-6.45.6/003_cnt_PckDescription.raw', + 'npkPyExport_gps-6.45.6/004_cnt_PckEckcdsaHash.raw', + 'npkPyExport_gps-6.45.6/005_cnt_PckRequirementsHeader.raw', + 'npkPyExport_gps-6.45.6/006_cnt_CntNullBlock.raw', + 'npkPyExport_gps-6.45.6/007_cnt_CntSquashFsImage.raw', + 'npkPyExport_gps-6.45.6/008_cnt_CntSquashFsHashSignature.raw', + 'npkPyExport_gps-6.45.6/009_cnt_CntArchitectureTag.raw'], exportedContainer) + + def test_extractSquashFsContainerFromNpk(self): + cmd = ["npkPy", "--file", self.pathToNpk, "--dstFolder", self.dstFolder.absolute(), "--exportSquashFs"] + + runCmdInTerminal(cmd) + + self.assertContainerExtracted(['npkPyExport_gps-6.45.6', + 'npkPyExport_gps-6.45.6/007_cnt_CntSquashFsImage.raw']) + + # + def test_extractZlibContainerFromNpk_NonExisitngNotExtracted(self): + cmd = ["npkPy", "--file", self.pathToNpk, "--dstFolder", self.dstFolder.absolute(), "--exportZlib"] + + runCmdInTerminal(cmd) + + self.assertContainerExtracted([]) + + def assertContainerExtracted(self, expectedFiles): + extractedContainer = sorted(str(f.relative_to(self.dstFolder)) for f in self.dstFolder.rglob('*')) + self.assertEqual(expectedFiles, extractedContainer) + + +def runCmdInTerminal(cmd): + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout.decode("UTF-8") diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/download_all_packages.py b/tools/download_all_packages.py new file mode 100755 index 0000000..34b7817 --- /dev/null +++ b/tools/download_all_packages.py @@ -0,0 +1,128 @@ +import socket +import sys +import urllib.request +import zipfile +from datetime import datetime +from pathlib import Path +from time import sleep +from urllib.error import HTTPError, URLError +from zipfile import ZipFile + +from urlpath import URL + + +class MikroTikDownloader: + + def __init__(self, dstPath): + + self.dstPath = Path(dstPath) + self.sleepTime = 1 + socket.setdefaulttimeout(10) + + def downloadAll(self, urls): + missingFiles = self._determineMissingFiles(urls) + for link in missingFiles: + unpackFile(self._downloadFile(link)) + + def _downloadFile(self, url): + def _progress(count, block_size, total_size): + progress = (float(count * block_size) / float(total_size) * 100.0) + if int(progress) > 100: + raise RuntimeError("AccessDenied") + sys.stderr.write(self._msg(url) + ': [%.1f%%] ' % progress) + sys.stderr.flush() + + targetFile = self._convertToLocalFilePath(url) + targetFile.parent.mkdir(exist_ok=True, parents=True) + + while True: + try: + urllib.request.urlretrieve(f"http:{url.resolve()}", targetFile, _progress) + self.msg(url, "[100.0%]") + self.dec_sleep() + break + except HTTPError as e: + self.msg(url, f"[failed] (Error code: {e.code})") + break + except socket.timeout: + self.msg(url, "[failed] (Timeout)") + self.inc_sleep() + except RuntimeError as e: + self.msg(url, f"[failed] ([{e}])") + if targetFile.exists(): + targetFile.unlink() + break + except URLError as e: + self.msg(url, f"[failed] (Error code: {e})") + self.inc_sleep() + + return targetFile + + def inc_sleep(self): + sleep(self.sleepTime) + self.sleepTime += 1 + + def dec_sleep(self): + if self.sleepTime > 1: + self.sleepTime -= 1 + + def _determineMissingFiles(self, urls): + for url in urls: + if not self._convertToLocalFilePath(url).exists(): + yield url + else: + self.msg(url, "[ok]") + + def msg(self, url, msg): + print(f"{self._msg(url)}: {msg}") + + def _msg(self, url): + return f"\r[{datetime.now().time()}] Download {url.name:35}" + + def _convertToLocalFilePath(self, link: URL): + pktName = arc = version = "" + elements = link.stem.split("-") + count = len(elements) + if count == 2: + pktName, version = elements + if count == 3: + pktName, arc, version = elements + return self.dstPath / pktName / arc / version / link.name + + +def fetchWebsite(url): + with urllib.request.urlopen(url) as response: + return response.read().decode("utf-8") + + +def extractDownloadLinks(tHtml, filterString): + return (URL(line.split('"')[1]) for line in tHtml.split() if filterString in line) + + +def filterLinks(tLinks, filters): + return (link for link in tLinks if + any(True for name, suffix in filters + if name in link.stem and + suffix in link.suffix + ) + ) + + +def unpackFile(file: Path): + if file.exists() and file.suffix == ".zip": + try: + with ZipFile(file, 'r') as zipObj: + zipObj.extractall(file.parent) + except zipfile.BadZipFile: + file.unlink() + + +if __name__ == "__main__": + html = fetchWebsite("https://mikrotik.com/download/archive") + links = extractDownloadLinks(html, "download.mikrotik.com") + selectedLinks = filterLinks(links, [("all_packages", ".zip"), + (".", ".npk") + ] + ) + mtd = MikroTikDownloader("./downloads") + mtd.downloadAll(selectedLinks) diff --git a/tools/download_all_packages_test.py b/tools/download_all_packages_test.py new file mode 100644 index 0000000..7f2f056 --- /dev/null +++ b/tools/download_all_packages_test.py @@ -0,0 +1,105 @@ +import tempfile +import unittest +from pathlib import Path +from zipfile import ZipFile + +from pytest_httpserver import HTTPServer +from urlpath import URL + +from tools.download_all_packages import fetchWebsite, extractDownloadLinks, filterLinks, \ + unpackFile + + +class Test_MikroTikDownloader(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + print("setup Class!") + cls.s = HTTPServer() + cls.s.start() + cls.testUrl = f"http://localhost:{cls.s.port}" + + # cls.s.expect_request("/download/archive").respond_with_data(createArchiveWebsite(cls.testUrl)) + # cls.s.expect_request("/routeros/6.36.4/all_packages-mipsbe-6.36.4.zip").respond_with_data(b"data", + + # content_type="application/zip") + + @classmethod + def tearDownClass(cls) -> None: + cls.s.stop() + + def setUp(self) -> None: + self.s.clear() + + def test_downloadWebsite(self): + self.s.expect_request("/").respond_with_data(b"testData") + + result = fetchWebsite(f"http://localhost:{self.s.port}") + + self.assertEqual("testData", result) + + def test_extractDownloadLinks(self): + testData = "dummyTest \n" + testData += "program-arch-version.zip>\n" + testData += "dummyTest \n" + + result = extractDownloadLinks(testData, "filterString.com") + + self.assertEqual([URL("//filterString.com/prod/vers/programA-arch-version.zip")], list(result)) + + def test_filterForSpecificLinks_basedOnSuffix(self): + links = [URL("//filterString.com/prod/vers/programA-arch-version.zip"), + URL("//filterString.com/prod/vers/programB-arch-version.WRONGSUFFIX")] + + result = filterLinks(links, [("program", ".zip")]) + + self.assertEqual([URL("//filterString.com/prod/vers/programA-arch-version.zip")], list(result)) + + def test_filterForSpecificLinks_basedOnProgram(self): + links = [URL("//filterString.com/prod/vers/programA-arch-version.exe"), + URL("//filterString.com/prod/vers/programB-arch-version.zip")] + + result = filterLinks(links, ([("programB", ".zip")])) + + self.assertEqual([URL("//filterString.com/prod/vers/programB-arch-version.zip")], list(result)) + + def test_dontExtractZip_fileDontExist(self): + file = Path(tempfile.NamedTemporaryFile(suffix="WRONGSUFFIX").name) + + unpackFile(file) + + def test_dontExtractZip_fileHasNoZipSuffix(self): + file = Path(tempfile.NamedTemporaryFile(suffix="WRONGSUFFIX").name) + file.touch() + + unpackFile(file) + + def test_extractZip_validatePayload(self): + zipFile, origTxtFile = _createTxtInZipFile(txtPayload="TEST DATA") + unzipedFile = Path(f"{origTxtFile.parent}/{origTxtFile.absolute()}") + + unpackFile(zipFile) + + self.assertTrue(zipFile.exists()) + self.assertTrue(unzipedFile.exists()) + self.assertEqual("TEST DATA", unzipedFile.read_text()) + + +def _createTxtInZipFile(txtPayload): + txtFile = Path(tempfile.NamedTemporaryFile(suffix=".txt").name) + zipFile = Path(tempfile.NamedTemporaryFile(suffix=".zip").name) + with txtFile.open("w") as f: + f.write(txtPayload) + with ZipFile(zipFile, 'w') as zipObj: + zipObj.write(txtFile.absolute()) + return zipFile, txtFile + + +def createArchiveWebsite(testUrl): + return f""" + +
+
all_packages-mipsbe-6.36.4> + mipsbe + + """ diff --git a/tools/npkModify_test.py b/tools/npkModify_test.py new file mode 100644 index 0000000..678a0a1 --- /dev/null +++ b/tools/npkModify_test.py @@ -0,0 +1,97 @@ +import tempfile +import unittest +from pathlib import Path + +from tests.constants import MINIMAL_NPK_PACKAGE +from npkpy.npk.npk import Npk + + +class BasicNpkTestRequirements(unittest.TestCase): + def setUp(self) -> None: + self.testNpk = Path(tempfile.NamedTemporaryFile().name) + self.testNpk.write_bytes(MINIMAL_NPK_PACKAGE) + self.npk = Npk(self.testNpk) + + def tearDown(self) -> None: + self.testNpk.unlink() + + +class ParseTestPackage_Test(BasicNpkTestRequirements): + def test_minimalNPK_parseHeader(self): + self.assertEqual(b"\x1e\xf1\xd0\xba", self.npk.pck_magicBytes) + self.assertEqual(28, self.npk.pck_payloadLen) + + def test_minimalNPK_parseAllContainer(self): + listOfCnt = self.npk.pck_cntList + + self.assertEqual(1, listOfCnt[0].cnt_id) + self.assertEqual(15, listOfCnt[0].cnt_payloadLen) + self.assertEqual(b"NAME OF PROGRAM", listOfCnt[0].cnt_payload) + self.assertEqual(1, listOfCnt[1].cnt_id) + self.assertEqual(1, listOfCnt[1].cnt_payloadLen) + self.assertEqual(b"I", listOfCnt[1].cnt_payload) + + +class ModifyPayload_Test(BasicNpkTestRequirements): + + def setUp(self) -> None: + super().setUp() + self.cnt = self.npk.pck_cntList[0] + + def test_emptyPayload_emptyContainer(self): + self.cnt.cnt_payload = b"" + + self.assertEqual(1, self.cnt.cnt_id) + self.assertEqual(0, self.cnt.cnt_payloadLen) + self.assertEqual(b"", self.cnt.cnt_payload) + self.assertEqual(6, self.cnt.cnt_fullLength) + + self.assertEqual(13, self.npk.pck_payloadLen) + self.assertEqual(21, self.npk.pck_fullSize) + + def test_dontchangePayloadSize_recalculateContainerKeepSize(self): + self.cnt.cnt_payload = b"PROGRAM OF NAME" + + self.assertEqual(1, self.cnt.cnt_id) + self.assertEqual(15, self.cnt.cnt_payloadLen) + self.assertEqual(b"PROGRAM OF NAME", self.cnt.cnt_payload) + self.assertEqual(21, self.cnt.cnt_fullLength) + + self.assertEqual(28, self.npk.pck_payloadLen) + self.assertEqual(36, self.npk.pck_fullSize) + + def test_increasePayloadLen_recalculateContainerSizeBigger(self): + self.cnt.cnt_payload = b"NEW NAME OF PROGRAM" + + self.assertEqual(1, self.cnt.cnt_id) + self.assertEqual(19, self.cnt.cnt_payloadLen) + self.assertEqual(b"NEW NAME OF PROGRAM", self.cnt.cnt_payload) + self.assertEqual(25, self.cnt.cnt_fullLength) + + self.assertEqual(32, self.npk.pck_payloadLen) + self.assertEqual(40, self.npk.pck_fullSize) + + def test_decreasePayloadLen_recalculateContainerSmaller(self): + self.cnt.cnt_payload = b"SHORT NAME" + + self.assertEqual(1, self.cnt.cnt_id) + self.assertEqual(10, self.cnt.cnt_payloadLen) + self.assertEqual(b"SHORT NAME", self.cnt.cnt_payload) + self.assertEqual(16, self.cnt.cnt_fullLength) + + self.assertEqual(23, self.npk.pck_payloadLen) + self.assertEqual(31, self.npk.pck_fullSize) + + +class WriteModifiedFile_Test(BasicNpkTestRequirements): + def setUp(self) -> None: + super().setUp() + self.cnt = self.npk.pck_cntList[0] + + def test_createFile_withoutModification(self): + self.assertEqual(MINIMAL_NPK_PACKAGE, self.npk.pck_fullBinary) + + def test_createFile_changePayloadTwice(self): + self.cnt.cnt_payload = b"A" + self.cnt.cnt_payload = b"NAME OF PROGRAM" + self.assertEqual(MINIMAL_NPK_PACKAGE, self.npk.pck_fullBinary) diff --git a/tools/sections.py b/tools/sections.py new file mode 100644 index 0000000..eb5ee37 --- /dev/null +++ b/tools/sections.py @@ -0,0 +1,51 @@ +from collections import Counter +from pathlib import Path +from typing import Tuple + +from more_itertools import peekable + + +def getBinaryFromFile(file: Path): + with file.open("rb") as f: + data = f.read() + for d in data: + yield d + +def findDiffs(lastFile, file): + bLastFile = peekable(getBinaryFromFile(lastFile)) + bfile = peekable(getBinaryFromFile(file)) + + sections = [] + counter = -1 + hasChanged = (bLastFile.peek() == bfile.peek()) + sectionTracker = dict({True: 0, False: 0}) + + while True: + try: + while (next(bLastFile) == next(bfile)) is hasChanged: + counter += 1 + except StopIteration: + sections.append((max(sectionTracker.values()), counter, not hasChanged)) + break + + sectionTracker[hasChanged] = counter + 1 + hasChanged = not hasChanged + sections.append((sectionTracker[hasChanged], counter, hasChanged)) + counter += 1 + + return sections + + +def findSections(filesDict: dict) -> Tuple[str, Counter]: + cTotal = Counter() + for (program, versionFiles) in filesDict.items(): + lastFile = None + c = Counter() + for (version, file) in versionFiles.items(): + if lastFile is not None: + diffs = findDiffs(lastFile.file, file.file) + c.update(diffs) + cTotal.update(diffs) + lastFile = file + yield program, c + yield "__total", cTotal diff --git a/tools/sections_test.py b/tools/sections_test.py new file mode 100644 index 0000000..c08668e --- /dev/null +++ b/tools/sections_test.py @@ -0,0 +1,43 @@ +import unittest +from pathlib import Path +from tempfile import NamedTemporaryFile + +from tools.sections import findDiffs + + +class FindDiffs_Test(unittest.TestCase): + def setUp(self) -> None: + self.fileA = Path(NamedTemporaryFile(delete=False).name) + self.fileB = Path(NamedTemporaryFile(delete=False).name) + self.fileC = Path(NamedTemporaryFile(delete=False).name) + + def tearDown(self) -> None: + self.fileA.unlink() + self.fileB.unlink() + self.fileC.unlink() + + def test_filesEqual_minimumFile(self): + self.assertFileDiff("a", "a", [(0, 0, False)]) + + def test_filesUnequal_minimumFile(self): + self.assertFileDiff("a", "b", [(0, 0, True)]) + + def test_filesEqual_equalSize(self): + self.assertFileDiff("aabbcc", "aabbcc", [(0, 5, False)]) + + def test_diffInCenter_equalSize(self): + self.assertFileDiff("aabbcc", "aa cc", [(0, 1, False), (2, 3, True), (4, 5, False)]) + + def test_differInSize(self): + self.assertFileDiff("aa", "aabb", [(0, 1, False)]) + + def assertFileDiff(self, contentA, contentB, result): + writeFile(self.fileA, contentA) + writeFile(self.fileB, contentB) + + self.assertEqual(result, findDiffs(self.fileA, self.fileB)) + + +def writeFile(file, content): + with file.open("w") as f: + f.write(content)