diff --git a/.github/workflows/run_test.yaml b/.github/workflows/run_test.yaml index b8afd2b..4150b25 100644 --- a/.github/workflows/run_test.yaml +++ b/.github/workflows/run_test.yaml @@ -3,21 +3,23 @@ on: jobs: test: - runs-on: ${{ vars.RUNS_ON }} + runs-on: ubuntu-latest steps: - - name: Perform integration testing with ansible-test - uses: ansible-community/ansible-test-gh-action@release/v1 + - name: Checkout code + uses: actions/checkout@v4 with: - ansible-core-version: stable-2.14 - target-python-version: 3.9 - testing-type: integration - test-deps: community.crypto - pre-test-cmd: >- - echo "[endpoint: ${{ secrets.HORIZON_ENDPOINT }}, x_api_id: ${{ secrets.HORIZON_API_ID }}, x_api_key: ${{ secrets.HORIZON_API_KEY }}]" >> tests/integration/integration_config.yml + submodules: true + fetch-depth: 0 - - name: Perform unit testing with ansible-test - uses: ansible-community/ansible-test-gh-action@release/v1 - with: - ansible-core-version: stable-2.14 - target-python-version: 3.9 - testing-type: units + - name: Install requirements + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ansible + + - name: Set integration_config.yml + run: >- + echo "[endpoint: ${{ secrets.HORIZON_ENDPOINT }}, x_api_id: ${{ secrets.HORIZON_API_ID }}, x_api_key: ${{ secrets.HORIZON_API_KEY }}]" >> tests/integration/integration_config.yml + + - name: Run tests + run: make test diff --git a/Makefile b/Makefile index 7e54406..a79ada0 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,11 @@ ANSIBLEGALAXYBUILD ?= ansible-galaxy ANSIBLEGALAXYOPTS ?= --token="$(ANSIBLE_GALAXY_API_TOKEN)" ANTSIBULLBUILD ?= antsibull-docs -ANTSIBULLOPTS ?= +ANTSIBULLOPTS ?= SPHINXBUILD ?= sphinx-build SPHINXOPTS ?= +ANSIBLETEST ?= ansible-test +COLLECTIONPATH ?= ~/.ansible/collections/ansible_collections/evertrust/horizon .PHONY: docs @@ -16,6 +18,11 @@ docs: build: clean @$(ANSIBLEGALAXYBUILD) "collection" "build" "--output-path=build/" $(ANSIBLEGALAXYPOTS) +install: + ARTIFACT=$$(find "build" -name "*.tar.gz" ); \ + echo $$ARTIFACT; \ + $(ANSIBLEGALAXYBUILD) "collection" "install" $$ARTIFACT + clean: ## Clean build artifacts @rm -f build/* @@ -23,3 +30,8 @@ publish: build ## Build and publish build artifact to Ansible Galaxy ARTIFACT=$$(find "build" -name "*.tar.gz" ); \ echo $$ARTIFACT; \ $(ANSIBLEGALAXYBUILD) "collection" "publish" $$ARTIFACT $(ANSIBLEGALAXYOPTS) + +test: build install + cd $(COLLECTIONPATH); \ + $(ANSIBLETEST) "integration"; \ + $(ANSIBLETEST) "units" \ No newline at end of file diff --git a/meta/runtime.yml b/meta/runtime.yml index e2030bb..b20d277 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -10,4 +10,5 @@ action_groups: - horizon_feed - horion_inventory - horizon_lookup + - horizon_template - horizon diff --git a/plugins/action/horizon_renew.py b/plugins/action/horizon_renew.py index 898cc5d..7233ae5 100644 --- a/plugins/action/horizon_renew.py +++ b/plugins/action/horizon_renew.py @@ -16,7 +16,7 @@ class ActionModule(HorizonAction): TRANSFERS_FILES = True def _args(self): - return ['certificate_id', 'certificate_pem', 'password', 'csr'] + return ['certificate_id', 'certificate_pem', 'password', 'csr', 'private_key'] def run(self, tmp=None, task_vars=None): result = super(ActionModule, self).run(tmp, task_vars) diff --git a/plugins/action/horizon_revoke.py b/plugins/action/horizon_revoke.py index bd9bc5c..60345dc 100644 --- a/plugins/action/horizon_revoke.py +++ b/plugins/action/horizon_revoke.py @@ -15,7 +15,7 @@ class ActionModule(HorizonAction): TRANSFERS_FILES = True def _args(self): - return ["certificate_pem", "certificate_id", "revocation_reason", "skip_already_revoked"] + return ["certificate_pem", "certificate_id", "revocation_reason", "skip_already_revoked", "private_key"] def run(self, tmp=None, task_vars=None): result = super(ActionModule, self).run(tmp, task_vars) diff --git a/plugins/action/horizon_update.py b/plugins/action/horizon_update.py index 94d072e..5a9a0cb 100644 --- a/plugins/action/horizon_update.py +++ b/plugins/action/horizon_update.py @@ -16,7 +16,7 @@ class ActionModule(HorizonAction): TRANSFERS_FILES = True def _args(self): - return ["labels", "certificate_pem", "metadata", "owner", "team", "contact_email"] + return ["labels", "certificate_pem", "metadata", "owner", "team", "contact_email", "private_key"] def run(self, tmp=None, task_vars=None): result = super(ActionModule, self).run(tmp, task_vars) diff --git a/plugins/module_utils/horizon.py b/plugins/module_utils/horizon.py index e753d59..8346540 100644 --- a/plugins/module_utils/horizon.py +++ b/plugins/module_utils/horizon.py @@ -11,6 +11,7 @@ import requests from ansible.errors import AnsibleError +from ansible_collections.evertrust.horizon.plugins.module_utils.horizon_crypto import HorizonCrypto from ansible_collections.evertrust.horizon.plugins.module_utils.horizon_errors import HorizonError from ansible.utils.display import Display @@ -23,7 +24,7 @@ class Horizon: DISCOVERY_FEED_URL = "/api/v1/discovery/feed" RFC5280_TC_URL = "/api/v1/rfc5280/tc/" - def __init__(self, endpoint=None, x_api_id=None, x_api_key=None, client_cert=None, client_key=None, ca_bundle=None): + def __init__(self, endpoint=None, x_api_id=None, x_api_key=None, client_cert=None, client_key=None, ca_bundle=None, private_key=None): """ Initialize client with endpoint and authentication parameters :type endpoint: str @@ -51,7 +52,7 @@ def __init__(self, endpoint=None, x_api_id=None, x_api_key=None, client_cert=Non elif x_api_id is not None and x_api_key is not None: self.headers = {"x-api-id": str(x_api_id), "x-api-key": str(x_api_key)} - else: + elif private_key is None: # Authorizing missin authent parameters for pop request raise AnsibleError("Please inform authentication parameters : 'x_api_id' and 'x_api_key' or 'client_cert' and 'client_key'.") def enroll(self, profile, template, mode=None, csr=None, password=None, key_type=None, labels=None, metadata=None, @@ -155,7 +156,7 @@ def recover(self, certificate_pem, password): return self.post(self.REQUEST_SUBMIT_URL, json) - def renew(self, certificate_pem, certificate_id, password=None, csr=None): + def renew(self, certificate_pem, certificate_id, password=None, csr=None, private_key=None): """ Renew a certificate :type certificate_pem: Union[str,dict] @@ -163,12 +164,16 @@ def renew(self, certificate_pem, certificate_id, password=None, csr=None): :rtype: dict """ csr = self.__load_file_or_string(csr) + cert = self.__load_file_or_string(certificate_pem) + if private_key is not None: + key = self.__load_file_or_string(private_key) + self.set_jwt_headers(cert, key) json = { "module": "webra", "workflow": "renew", "certificateId": certificate_id, - "certificatePem": self.__load_file_or_string(certificate_pem), + "certificatePem": cert, "template": { "csr": csr } @@ -180,17 +185,22 @@ def renew(self, certificate_pem, certificate_id, password=None, csr=None): return self.post(self.REQUEST_SUBMIT_URL, json) - def revoke(self, certificate_pem, certificate_id, revocation_reason): + def revoke(self, certificate_pem, certificate_id, revocation_reason, private_key=None): """ Revoke a certificate :type certificate_pem: Union[str,dict] :type revocation_reason: str :rtype: dict """ + cert = self.__load_file_or_string(certificate_pem) + if private_key is not None: + key = self.__load_file_or_string(private_key) + self.set_jwt_headers(cert, key) + # Duplication of value is to support older API versions json = { "workflow": "revoke", - "certificatePem": self.__load_file_or_string(certificate_pem), + "certificatePem": cert, "certificateId": certificate_id, "revocationReason": revocation_reason, "template": { @@ -200,7 +210,7 @@ def revoke(self, certificate_pem, certificate_id, revocation_reason): return self.post(self.REQUEST_SUBMIT_URL, json) - def update(self, certificate_pem, labels=None, metadata=None, owner=None, team=None, contact_email=None): + def update(self, certificate_pem, labels=None, metadata=None, owner=None, team=None, contact_email=None, private_key=None): """ Update a certificate :param metadata: @@ -215,9 +225,14 @@ def update(self, certificate_pem, labels=None, metadata=None, owner=None, team=N if labels is None: labels = {} + cert = self.__load_file_or_string(certificate_pem) + if private_key is not None: + key = self.__load_file_or_string(private_key) + self.set_jwt_headers(cert, key) + json = { "workflow": "update", - "certificatePem": self.__load_file_or_string(certificate_pem), + "certificatePem": cert, "template": { "metadata": self.__set_metadata(metadata), "labels": self.__set_labels(labels) @@ -498,6 +513,12 @@ def send(self, method, path, **kwargs): method = method.upper() try: response = requests.request(method, uri, cert=self.cert, verify=self.bundle, headers=self.headers, **kwargs) + if "Replay-Nonce" in response.headers: + nonce = response.headers["Replay-Nonce"] + valid_jwt_token = HorizonCrypto.generate_jwt_token(self.certificate, self.private_key, nonce) + self.headers["X-JWT-CERT-POP"] = valid_jwt_token + response = requests.request(method, uri, cert=self.cert, verify=self.bundle, headers=self.headers, **kwargs) + except requests.exceptions.SSLError: raise AnsibleError("Got an SSL error try using the 'ca_bundle' paramater") @@ -771,4 +792,10 @@ def get_password(self, password_policy): Get a password from password_policy :param password_policy """ - return self.send('GET', "/api/v1/security/passwordpolicies/"+password_policy+"/generate") \ No newline at end of file + return self.send('GET', "/api/v1/security/passwordpolicies/"+password_policy+"/generate") + + def set_jwt_headers(self, cert, key): + jwt_token = HorizonCrypto.generate_jwt_token(cert, key, "") + self.headers = {"X-JWT-CERT-POP": jwt_token} + self.certificate = cert + self.private_key = key \ No newline at end of file diff --git a/plugins/module_utils/horizon_action.py b/plugins/module_utils/horizon_action.py index 7cec02f..8f3d8b5 100644 --- a/plugins/module_utils/horizon_action.py +++ b/plugins/module_utils/horizon_action.py @@ -17,7 +17,7 @@ def _args(self): return [] def _auth_args(self): - return ["endpoint", "x_api_id", "x_api_key", "ca_bundle", "client_cert", "client_key"] + return ["endpoint", "x_api_id", "x_api_key", "ca_bundle", "client_cert", "client_key", "private_key"] def _get_auth(self): auth = {} diff --git a/plugins/module_utils/horizon_crypto.py b/plugins/module_utils/horizon_crypto.py index 10c56bb..39bfe66 100644 --- a/plugins/module_utils/horizon_crypto.py +++ b/plugins/module_utils/horizon_crypto.py @@ -9,12 +9,20 @@ import base64 import secrets import re +import jwt +import time +import hashlib +import time +import json from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa, ec +from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature +from cryptography.hazmat.backends import default_backend + class HorizonCrypto: @@ -134,3 +142,33 @@ def generate_key_pair(key_type): public_key = private_key.public_key() return private_key, public_key + + @staticmethod + def generate_jwt_token(cert, private_key, nonce=""): + # Define payload + jwt_payload = { + "sub": cert, + "iat": int(time.time()), + "exp": int(time.time()) + 5 + } + if nonce != "": + jwt_payload["nonce"] = nonce + + # Load the private key from PEM format if it's a string + if isinstance(private_key, str): + private_key = private_key.encode('utf-8') + key = serialization.load_pem_private_key(private_key, password=None, backend=default_backend()) + + # Determine algorithm based on key type + if isinstance(key, rsa.RSAPrivateKey): + signing_method = "RS256" + elif isinstance(key, ec.EllipticCurvePrivateKey): + signing_method = "ES256" + else: + raise ValueError("Unsupported key type") + + # Use PyJWT to encode and sign the token + jwt_token = jwt.encode(jwt_payload, private_key, algorithm=signing_method) + + return jwt_token + diff --git a/plugins/modules/horizon_renew.py b/plugins/modules/horizon_renew.py index fc55e40..6b1a3d1 100644 --- a/plugins/modules/horizon_renew.py +++ b/plugins/modules/horizon_renew.py @@ -37,6 +37,19 @@ description: The path to a CSR file required: false type: path + password: + description: Security password of the certificate. + required: false + type: str + private_key: + description: The PEM encoded private key associated to the certificate. + required: false + type: str + suboptions: + src: + description: The path to the PEM encoded private key associated to the certificate. + required: false + type: path ''' # language=yaml @@ -47,7 +60,7 @@ x_api_id: "" x_api_key: "" certificate_pem: - src: pem/file/path + src: path/to/pem - name: renew a certificate from his ID evertrust.horizon.horizon_renew: @@ -63,7 +76,15 @@ x_api_key: "" certificate_id: csr: - src: csr/file/path + src: path/to/csr + +- name: renew a certificate with pop + evertrust.horizon_renew: + endpoint: "https://" + certificate_pem: + src: path/to/pem + private_key: + src: path/to/key ''' diff --git a/plugins/modules/horizon_revoke.py b/plugins/modules/horizon_revoke.py index 45df0c2..ad7be4f 100644 --- a/plugins/modules/horizon_revoke.py +++ b/plugins/modules/horizon_revoke.py @@ -30,6 +30,15 @@ description: The ID of the certificate to revoke. required: false type: str + private_key: + description: The PEM encoded private key associated to the certificate. + required: false + type: str + suboptions: + src: + description: The path to the PEM encoded private key associated to the certificate. + required: false + type: path revocation_reason: description: The reason for revoking the certificate. required: false @@ -64,7 +73,15 @@ x_api_id: "" x_api_key: "" certificate_pem: - src: /pem/file/path + src: path/to/pem + +- name: Revoke a certificate with pop + evertrust.horizon.horizon_revoke: + endpoint: "https://" + certificate_pem: + src: path/to/pem + private_key: + src: path/to/key ''' # language=yaml diff --git a/plugins/modules/horizon_update.py b/plugins/modules/horizon_update.py index b09381f..96b4bda 100644 --- a/plugins/modules/horizon_update.py +++ b/plugins/modules/horizon_update.py @@ -67,6 +67,15 @@ description: Certificate's owner. required: false type: str + private_key: + description: The PEM encoded private key associated to the certificate. + required: false + type: str + suboptions: + src: + description: The path to the PEM encoded private key associated to the certificate. + required: false + type: path team: description: Certificate's team. required: false @@ -101,7 +110,17 @@ labels: label1: "exampleLabel" certificate_pem: - src: /pem/file/path + src: path/to/pem + +- name: Update a certificate with pop + evertrust.horizon.horizon_update: + endpoint: "https://" + labels: + label1: "exampleLabel" + certificate_pem: + src: path/to/pem + private_key: + src: path/to/key ''' # language=yaml diff --git a/requirements.txt b/requirements.txt index bfbe776..185f1f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ -cryptography>=3.5.0 \ No newline at end of file +cryptography>=3.5.0 +pyjwt +requests +pytest-xdist \ No newline at end of file diff --git a/tests/integration/targets/horizon_centralized_enroll/tasks/main.yml b/tests/integration/targets/horizon_centralized_enroll/tasks/main.yml index 562e5d5..1e5e810 100644 --- a/tests/integration/targets/horizon_centralized_enroll/tasks/main.yml +++ b/tests/integration/targets/horizon_centralized_enroll/tasks/main.yml @@ -26,7 +26,8 @@ register: data -- copy: content="{{ data.certificate.certificate }}" dest="/PEM.pem" +- copy: content="{{ data.certificate.certificate }}" dest="/tmp/PEM.pem" +- copy: content="{{ data.key }}" dest="/tmp/KEY.key" - name: Test centralize enrollment with unidentified profile evertrust.horizon.horizon_enroll: diff --git a/tests/integration/targets/horizon_certificate_feed/tasks/main.yml b/tests/integration/targets/horizon_certificate_feed/tasks/main.yml index 54e6c55..94d8100 100644 --- a/tests/integration/targets/horizon_certificate_feed/tasks/main.yml +++ b/tests/integration/targets/horizon_certificate_feed/tasks/main.yml @@ -6,7 +6,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" campaign: "Ansible" ip: 0.0.0.1 @@ -19,7 +19,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" ip: 0.0.0.1 ignore_errors: yes @@ -41,7 +41,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" campaign: "Ansible" ignore_errors: yes diff --git a/tests/integration/targets/horizon_certificate_import/tasks/main.yml b/tests/integration/targets/horizon_certificate_import/tasks/main.yml index e0d365d..1da82b9 100644 --- a/tests/integration/targets/horizon_certificate_import/tasks/main.yml +++ b/tests/integration/targets/horizon_certificate_import/tasks/main.yml @@ -19,6 +19,7 @@ endpoint: "{{ endpoint }}" x_api_id: "{{ x_api_id }}" x_api_key: "{{ x_api_key }}" + profile: "Ansible" certificate_pem: src: /tmp/importcertandkey.pem @@ -47,6 +48,7 @@ endpoint: "{{ endpoint }}" x_api_id: "{{ x_api_id }}" x_api_key: "{{ x_api_key }}" + certificate_pem: src: /tmp/importkey.pem campaign: "Ansible" @@ -57,6 +59,7 @@ endpoint: "{{ endpoint }}" x_api_id: "{{ x_api_id }}" x_api_key: "{{ x_api_key }}" + profile: "Ansible" certificate_id: "{{ cert_id._id }}" private_key: @@ -64,4 +67,4 @@ vars: certificate: src: '/tmp/importkey.pem' - cert_id: "{{ lookup('evertrust.horizon.horizon_lookup', endpoint=endpoint, x_api_id=x_api_id, x_api_key=x_api_key, certificate_pem=certificate, fields='_id', wantlist=True) }}" \ No newline at end of file + cert_id: "{{ lookup('evertrust.horizon.horizon_lookup', ca_bundle=ca_bundle, endpoint=endpoint, x_api_id=x_api_id, x_api_key=x_api_key, certificate_pem=certificate, fields='_id', wantlist=True) }}" \ No newline at end of file diff --git a/tests/integration/targets/horizon_certificate_recover/tasks/main.yml b/tests/integration/targets/horizon_certificate_recover/tasks/main.yml index 3d66757..5390f68 100644 --- a/tests/integration/targets/horizon_certificate_recover/tasks/main.yml +++ b/tests/integration/targets/horizon_certificate_recover/tasks/main.yml @@ -6,7 +6,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" password: "Pass-W0rd" diff --git a/tests/integration/targets/horizon_certificate_renew/tasks/main.yml b/tests/integration/targets/horizon_certificate_renew/tasks/main.yml index 7442dea..1ab97a4 100644 --- a/tests/integration/targets/horizon_certificate_renew/tasks/main.yml +++ b/tests/integration/targets/horizon_certificate_renew/tasks/main.yml @@ -49,4 +49,12 @@ KXsklafhHYh4DCGAlfElS0Ye/TP+L5m0jS7IigrsrtqDeK8I+MBnQpyHUAZUhENp att/xqOGwLPrhZDgSQVtGnVFW2848ZCB4MjkvQFmF+rnqKP8DEDkjDujEPC27f/7 UOZa8OXHqLGN6HsCwd7gcJYwAA== - -----END CERTIFICATE REQUEST----- \ No newline at end of file + -----END CERTIFICATE REQUEST----- + +- name: Test pop renew + evertrust.horizon.horizon_renew: + + endpoint: "{{ endpoint }}" + + certificate_pem: "{{ data.certificate.certificate }}" + private_key: "{{ data.key }}" diff --git a/tests/integration/targets/horizon_certificate_update/tasks/main.yml b/tests/integration/targets/horizon_certificate_update/tasks/main.yml index 78fc27a..eecd022 100644 --- a/tests/integration/targets/horizon_certificate_update/tasks/main.yml +++ b/tests/integration/targets/horizon_certificate_update/tasks/main.yml @@ -6,7 +6,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" labels: label-1: "retest" @@ -22,7 +22,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" labels: label-1: "retest" @@ -49,7 +49,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" labels: unexistantLabel: "retest" @@ -65,4 +65,19 @@ that: - error2 is failed - "'Label element' in error2.msg" - - "'is not authorized' in error2.msg" \ No newline at end of file + - "'is not authorized' in error2.msg" + +- name: Update a certificate with pop + evertrust.horizon.horizon_update: + + endpoint: "{{endpoint }}" + + certificate_pem: + src: "/tmp/PEM.pem" + private_key: + src: "/tmp/KEY.key" + + labels: + label-1: "test" + + team: "TeamA" \ No newline at end of file diff --git a/tests/integration/targets/horizon_revoke_certificate/tasks/main.yml b/tests/integration/targets/horizon_revoke_certificate/tasks/main.yml index 9eac9a6..763b8bd 100644 --- a/tests/integration/targets/horizon_revoke_certificate/tasks/main.yml +++ b/tests/integration/targets/horizon_revoke_certificate/tasks/main.yml @@ -6,7 +6,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" revocation_reason: "unspecified" @@ -19,7 +19,7 @@ x_api_key: "{{ x_api_key }}" certificate_pem: - src: "/PEM.pem" + src: "/tmp/PEM.pem" revocation_reason: "unspecified" @@ -31,4 +31,39 @@ assert: that: - error1 is failed - - "'Certificate is already revoked' in error1.msg" \ No newline at end of file + - "'Certificate is already revoked' in error1.msg" + + +# Enroll a certificate for pop revoke +- name: Certificate enroll + evertrust.horizon.horizon_enroll: + + endpoint: "{{ endpoint }}" + x_api_id: "{{ x_api_id }}" + x_api_key: "{{ x_api_key }}" + + profile: "Ansible" + mode: "centralized" + key_type: "rsa-2048" + + subject: + cn.1: "IntegrationTestCI" + o.1: "Evertrust" + ou.1: "R&D" + + register: data + +- copy: content="{{ data.certificate.certificate }}" dest="/tmp/PEM2.pem" +- copy: content="{{ data.key }}" dest="/tmp/KEY2.key" + +- name: Revoke a certificate by its file + evertrust.horizon.horizon_revoke: + + endpoint: "{{ endpoint }}" + + certificate_pem: + src: "/tmp/PEM2.pem" + private_key: + src: "/tmp/KEY2.key" + + revocation_reason: "unspecified" \ No newline at end of file