Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Huge cleanups and optimizations #352

Merged
merged 60 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
d091172
fix: dependencies and config alignments
peppelinux Jan 28, 2025
ff92704
chore: add validation error exception
peppelinux Jan 30, 2025
ce0bc4a
Merge branch 'dev' into validationerr
peppelinux Jan 30, 2025
a94c739
fix: add validation error exception
peppelinux Jan 30, 2025
0bb65aa
Merge branch 'validationerr' of https://github.com/italia/eudi-wallet…
peppelinux Jan 30, 2025
ddd7cb5
fix: pydantic ValidationError dep is required for unit tests
peppelinux Jan 30, 2025
555d10f
fix: ci
peppelinux Jan 30, 2025
38561c3
fix: ci
peppelinux Jan 30, 2025
6014e33
fix: validation error from pydantic
peppelinux Jan 30, 2025
8fd4a74
chore: exceptions in exceptions dedicated file
peppelinux Jan 30, 2025
2676214
chore: flake linting
peppelinux Jan 30, 2025
1887e51
configuration aligments and cleanup
peppelinux Jan 31, 2025
8a61b9d
breaking change: huge refactor and cleanup to move federation in a se…
peppelinux Jan 31, 2025
2562fe7
fix: merge conflicts
peppelinux Feb 2, 2025
abe1fb5
Merge branch 'dev' into federation2
peppelinux Feb 2, 2025
3e7df8e
fix: missing imports
peppelinux Feb 2, 2025
e8e7f64
Merge branch 'dev' into federation2
peppelinux Feb 6, 2025
0749db6
chore: additional federation cleanup
peppelinux Feb 6, 2025
27a6554
Merge branch 'federation2' of https://github.com/italia/eudi-wallet-i…
peppelinux Feb 6, 2025
a7a0f0c
chore: jwt and sd-jwt cleanup
peppelinux Feb 6, 2025
f512e8b
chore_ black linting into the game
peppelinux Feb 6, 2025
b782655
fix: code alignments after refactor and cleanup
peppelinux Feb 6, 2025
e912c93
fix: merge conflicts
PascalDR Feb 17, 2025
e2e8a05
chore: moved unused code
PascalDR Feb 17, 2025
8f65d1a
fix: removed unused function
PascalDR Feb 17, 2025
9215035
fix: removed unused code
PascalDR Feb 17, 2025
e0bf334
fix: moved unused class
PascalDR Feb 17, 2025
6e24676
fix: duplicated code
PascalDR Feb 17, 2025
c1f38ef
fix: import
PascalDR Feb 17, 2025
e148edc
fix: removed unused function
PascalDR Feb 17, 2025
c609995
fix: removed unused function
PascalDR Feb 17, 2025
5bacb4c
fix: unused variables
PascalDR Feb 17, 2025
6f8e79a
fix: variable unpacking
PascalDR Feb 17, 2025
d9944a8
fix: removed unused class
PascalDR Feb 17, 2025
d92fca7
fix: unused import and functions
PascalDR Feb 17, 2025
74ff8c7
fix: removed unused class
PascalDR Feb 17, 2025
15f5b47
fix: removed unused classes
PascalDR Feb 17, 2025
fc685b0
fix: removed unused functions
PascalDR Feb 17, 2025
10f77ee
fix: removed unused functions
PascalDR Feb 17, 2025
9b04ebf
fix: moved unused file
PascalDR Feb 17, 2025
6d9fc5a
fix: removed exceptions
PascalDR Feb 17, 2025
09e1078
fix: removed unused code
PascalDR Feb 17, 2025
7368a66
fix: unused code
PascalDR Feb 17, 2025
99f9b93
fix: unused import
PascalDR Feb 17, 2025
f1d06ec
fix: removed unused function
PascalDR Feb 18, 2025
675ca6e
fix: removed unused class
PascalDR Feb 18, 2025
80746c9
fix: removed unused exception
PascalDR Feb 18, 2025
f4393d3
fix: removed unused class and refactoring
PascalDR Feb 18, 2025
1c068b6
fix: moved class
PascalDR Feb 18, 2025
82e3205
fix: removed unused functions
PascalDR Feb 18, 2025
e5cdf02
fix: refactoring
PascalDR Feb 18, 2025
753a0eb
fix: typing and docs
PascalDR Feb 18, 2025
bd8d905
fix: cleaning, typing and docs
PascalDR Feb 18, 2025
145d0d9
fix: removed exception
PascalDR Feb 18, 2025
ae0ed88
fix: import
PascalDR Feb 18, 2025
001a5f3
fix: removed unused class
PascalDR Feb 18, 2025
57c52c4
fix: moved unused file
PascalDR Feb 18, 2025
4920326
fix: moved unused functions
PascalDR Feb 18, 2025
9922865
fix: type
PascalDR Feb 19, 2025
688557f
fix: removed unused file
PascalDR Feb 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[flake8]
ignore = D203,E121,E124,E126,E203,E231,E261,E251,E701,F403,F405,E402
exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,*/migrations/*,tests.py,*/tests/*
max-line-length=180
max-complexity = 25
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,14 @@ Please consider the following branches:

### Executing Unit Tests

Once you have activate the virtualenv, unit tests can be executed as show below.
Once you have activate the virtualenv, further dependencies must be installed as show below.

````
pip install -r requirements-dev.txt

````

Therefore the unit tests can be executed as show below.

````
pytest pyeudiw -x
Expand All @@ -131,6 +138,23 @@ you can run the test by passing the mon user and password in this way
PYEUDIW_MONGO_TEST_AUTH_INLINE="satosa:thatpassword@" pytest pyeudiw -x
````

### Executing integration tests

iam-proxy-italia project must be configured and in execution.

Integrations tests checks bot hthe cross device flow and the same device flow.

The cross device flow requires `playwrite` to be installed.

````
cd examples/satosa/integration_tests

playwrite install

PYEUDIW_MONGO_TEST_AUTH_INLINE="satosa:thatpassword@" pytest pyeudiw -x
````



## Authors

Expand Down
6 changes: 3 additions & 3 deletions docs/TRUST.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,18 @@ Some HTTPC parameters are commonly used, have a default value and as an alternat

### Federation

Module `pyeudiw.trust.default.federation` provides a source of trusted entities and metadata based on [OpenID Federation](https://openid.net/specs/openid-federation-1_0.html) that is intended to be applicable to Issuer, Holders and Verifiers. In the specific case of the Verifier (this application), the module can expose verifier metadata at the `.well-known/openid-federation` endpoint.
Module `pyeudiw.trust.handler.federation` provides a source of trusted entities and metadata based on [OpenID Federation](https://openid.net/specs/openid-federation-1_0.html) that is intended to be applicable to Issuer, Holders and Verifiers. In the specific case of the Verifier (this application), the module can expose verifier metadata at the `.well-known/openid-federation` endpoint.

The configuration parameters of the module are the following.


| Parameter | Description | Example Value |
| -------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ |
| config.federation.metadata_type | The type of metadata to use for the federation | wallet_relying_party |
| config.federation.metadata_type | The type of metadata to use for the federation | openid_credential_verifier |
| config.federation.authority_hints | The list of authority hints to use for the federation | [http://127.0.0.1:10000] |
| config.federation.trust_anchors | The list of trust anchors to use for the federation | [http://127.0.0.1:10000] |
| config.federation.default_sig_alg | The default signature algorithm to use for the federation | RS256 |
| config.federation.federation_entity_metadata.organization_name | The organization name | Developers Italia SATOSA OpenID4VP backend policy_uri, tos_uri, logo_uri |
| config.federation.federation_entity_metadata.organization_name | The organization name | IAM Proxy Italia OpenID4VP backend policy_uri, tos_uri, logo_uri |
| config.federation.federation_entity_metadata.homepage_uri | The URI of the homepage | https://developers.italia.it |
| config.federation.federation_entity_metadata.policy_uri | The URI of the policy | https://developers.italia.it/policy.html |
| config.federation.federation_entity_metadata.tos_uri | The URI of the TOS | https://developers.italia.it/tos.html |
Expand Down
9 changes: 5 additions & 4 deletions example/satosa/integration_test/commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@
)
from pyeudiw.sd_jwt.holder import SDJWTHolder
from pyeudiw.trust.model.trust_source import TrustSourceData
from saml2_sp import saml2_request

from settings import (
from . saml2_sp import saml2_request

from . settings import (
IDP_BASEURL,
CONFIG_DB,
RP_EID,
Expand Down Expand Up @@ -177,8 +178,8 @@ def create_authorize_response(vp_token: str, state: str, response_uri: str) -> s
).content.decode()
rp_ec = decode_jwt_payload(rp_ec_jwt)

assert response_uri == rp_ec["metadata"]["wallet_relying_party"]["response_uris_supported"][0]
encryption_key = rp_ec["metadata"]["wallet_relying_party"]["jwks"]["keys"][1]
# assert response_uri == rp_ec["metadata"]["openid_credential_verifier"]["response_uris"][0]
encryption_key = rp_ec["metadata"]["openid_credential_verifier"]["jwks"]["keys"][1]

response = {
"state": state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from pyeudiw.jwt.utils import decode_jwt_payload

from commons import (
from . commons import (
ISSUER_CONF,
setup_test_db_engine,
apply_trust_settings,
Expand All @@ -18,7 +18,7 @@
extract_saml_attributes,
verify_request_object_jwt
)
from settings import TIMEOUT_S
from . settings import TIMEOUT_S

# put a trust attestation related itself into the storage
# this is then used as trust_chain header parameter in the signed request object
Expand Down Expand Up @@ -92,6 +92,7 @@ def run(playwright: Playwright):
request_object_claims["nonce"],
request_object_claims["client_id"]
)

wallet_response_data = create_authorize_response(
verifiable_presentations,
request_object_claims["state"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pyeudiw.jwt.utils import decode_jwt_payload

from commons import (
from . commons import (
ISSUER_CONF,
setup_test_db_engine,
apply_trust_settings,
Expand All @@ -15,7 +15,7 @@
extract_saml_attributes,
verify_request_object_jwt
)
from settings import TIMEOUT_S
from . settings import TIMEOUT_S

# put a trust attestation related itself into the storage
# this is then used as trust_chain header parameter in the signed request object
Expand Down
2 changes: 1 addition & 1 deletion example/satosa/integration_test/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"sub": RP_EID,
'jwks': {"keys": rp_jwks},
"metadata": {
"wallet_relying_party": {
"openid_credential_verifier": {
'jwks': {"keys": []}
},
"federation_entity": {
Expand Down
148 changes: 76 additions & 72 deletions example/satosa/pyeudiw_backend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module: pyeudiw.satosa.backend.OpenID4VPBackend
name: OpenID4VP

config:

ui:
static_storage_url: !ENV SATOSA_BASE_STATIC
template_folder: "templates" # project root
Expand All @@ -20,7 +20,6 @@ config:
module: pyeudiw.satosa.default.response_handler
class: ResponseHandler
path: '/response-uri'
entity_configuration: '/.well-known/openid-federation'
status: '/status'
get_response: '/get-response'

Expand Down Expand Up @@ -107,38 +106,108 @@ config:
subject_id_random_value: CHANGEME!

network:
httpc_params:
httpc_params: &httpc_params
connection:
ssl: true
session:
timeout: 6

# private jwk
metadata_jwks: &metadata_jwks
- crv: P-256
d: KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc
kid: dDwPWXz5sCtczj7CJbqgPGJ2qQ83gZ9Sfs-tJyULi6s
use: sig
kty: EC
x: TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk
y: ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7
- kty: RSA
d: QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q
e: AQAB
use: enc
kid: 9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w
n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw
p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0
q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM

#This is the configuration for the relaying party metadata
metadata: &metadata
application_type: web

#The following section contains all the algorithms supported for the encryption of response
authorization_encrypted_response_alg: *enc_alg_supported
authorization_encrypted_response_enc: *enc_enc_supported
authorization_signed_response_alg: *sig_alg_supported

#Various informations of the client
client_id: # this field is autopopulated using internal variables base_url and name using the following format: "<base_url>/<name>"
client_name: Name of an example organization
contacts:
- [email protected]
default_acr_values:
- https://www.spid.gov.it/SpidL2
- https://www.spid.gov.it/SpidL3

#The following section contains all the algorithms supported for the encryption of id token response
id_token_encrypted_response_alg: *enc_alg_supported
id_token_encrypted_response_enc: *enc_enc_supported
id_token_signed_response_alg: *sig_alg_supported

# loaded in the __init__
# jwks:

redirect_uris:
# This field is autopopulated using internal variables base_url and name using the following format: <base_url>/<name>/redirect-uri"
request_uris:
# This field is autopopulated using internal variables base_url and name using the following format: <base_url>/<name>/request-uri"

# not necessary according to openid4vp
# default_max_age: 1111
# require_auth_time: true
# subject_type: pairwise

vp_formats:
vc+sd-jwt:
sd-jwt_alg_values:
- ES256
- ES384
kb-jwt_alg_values:
- ES256
- ES384

trust:
direct_trust_sd_jwt_vc:
module: pyeudiw.trust.handler.direct_trust_sd_jwt_vc
class: DirectTrustSdJwtVc
config:
cache_ttl: 0
httpc_params: *httpc_params
jwk_endpoint: /.well-known/jwt-vc-issuer
direct_trust_jar:
module: pyeudiw.trust.handler.direct_trust_jar
class: DirectTrustJar
config:
cache_ttl: 0
httpc_params: *httpc_params
jwk_endpoint: /.well-known/jar-issuer
jwks: *metadata_jwks
federation:
module: pyeudiw.trust.handler.federation
class: FederationHandler
config:
metadata_type: "wallet_relying_party"
httpc_params: *httpc_params
cache_ttl: 0
entity_configuration_exp: 600
metadata_type: "openid_credential_verifier"
metadata: *metadata
authority_hints:
- http://127.0.0.1:8000
trust_anchors:
- public_keys: []
- http://127.0.0.1:8000
- http://127.0.0.1:8000: [] # array of public keys
default_sig_alg: "RS256"
trust_marks: []
federation_entity_metadata:
organization_name: Developers Italia SATOSA OpenID4VP backend
organization_name: IAM Proxy Italia OpenID4VP backend
homepage_uri: https://developers.italia.it
policy_uri: https://developers.italia.it
tos_uri: https://developers.italia.it
Expand Down Expand Up @@ -184,68 +253,3 @@ config:
db_trust_sources_collection: trust_sources
data_ttl: 63072000 # 2 years
# - connection_params:

# private jwk
metadata_jwks: &metadata_jwks
# !ENV PYEUDIW_METADATA_JWKS
- crv: P-256
d: KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc
kid: dDwPWXz5sCtczj7CJbqgPGJ2qQ83gZ9Sfs-tJyULi6s
use: sig
kty: EC
x: TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk
y: ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7
- kty: RSA
d: QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q
e: AQAB
use: enc
kid: 9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w
n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw
p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0
q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM

#This is the configuration for the relaying party metadata
metadata:
application_type: web

#The following section contains all the algorithms supported for the encryption of response
authorization_encrypted_response_alg: *enc_alg_supported
authorization_encrypted_response_enc: *enc_enc_supported
authorization_signed_response_alg: *sig_alg_supported

#Various informations of the client
client_id: # this field is autopopulated using internal variables base_url and name using the following format: "<base_url>/<name>"
client_name: Name of an example organization
contacts:
- [email protected]
default_acr_values:
- https://www.spid.gov.it/SpidL2
- https://www.spid.gov.it/SpidL3

default_max_age: 1111

#The following section contains all the algorithms supported for the encryption of id token response
id_token_encrypted_response_alg: *enc_alg_supported
id_token_encrypted_response_enc: *enc_enc_supported
id_token_signed_response_alg: *sig_alg_supported

# loaded in the __init__
# jwks:


redirect_uris:
# This field is autopopulated using internal variables base_url and name using the following format: <base_url>/<name>/redirect-uri"
request_uris:
# This field is autopopulated using internal variables base_url and name using the following format: <base_url>/<name>/request-uri"

require_auth_time: true
subject_type: pairwise

vp_formats:
vc+sd-jwt:
sd-jwt_alg_values:
- ES256
- ES384
kb-jwt_alg_values:
- ES256
- ES384
16 changes: 16 additions & 0 deletions html_linting.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
echo -e '\nHTML linting:'
shopt -s globstar nullglob
for file in `find example -type f | grep html`
do
echo -e "\n$file:"
html_lint.py "$file" | awk -v path="file://$PWD/$file:" '$0=path$0' | sed -e 's/: /:\n\t/';
done

errors=0
for file in "${array[@]}"
do
errors=$((errors + $(html_lint.py "$file" | grep -c 'Error')))
done

echo -e "\nHTML errors: $errors"
if [ "$errors" -gt 0 ]; then exit 1; fi;
24 changes: 7 additions & 17 deletions linting.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,13 @@ autopep8 -r --in-place $SRC
autoflake -r --in-place --remove-unused-variables --expand-star-imports --remove-all-unused-imports $SRC

flake8 $SRC --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 $SRC --max-line-length 120 --count --statistics

# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 $SRC --count --exit-zero --statistics

isort --atomic $SRC

black $SRC

bandit -r -x $SRC/test* $SRC/*

echo -e '\nHTML linting:'
shopt -s globstar nullglob
for file in `find example -type f | grep html`
do
echo -e "\n$file:"
html_lint.py "$file" | awk -v path="file://$PWD/$file:" '$0=path$0' | sed -e 's/: /:\n\t/';
done

errors=0
for file in "${array[@]}"
do
errors=$((errors + $(html_lint.py "$file" | grep -c 'Error')))
done

echo -e "\nHTML errors: $errors"
if [ "$errors" -gt 0 ]; then exit 1; fi;
Loading
Loading