Co-authored-by matejcik <ja@matejcik.cz>pull/3532/head
parent
b5c86a45ed
commit
b8ea21d24a
@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
TEST_FILE = HERE / "test-nonenglish.yml"
|
||||
|
||||
LANGS = {
|
||||
"cs": "czech",
|
||||
"fr": "french",
|
||||
"de": "german",
|
||||
"es": "spanish",
|
||||
}
|
||||
|
||||
MODELS = ["T", "R"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Replacement:
|
||||
start: str
|
||||
end: str
|
||||
replacement: str
|
||||
|
||||
|
||||
def replace_content_between_markers(
|
||||
file_path: Path | str, replacements: list[Replacement]
|
||||
) -> None:
|
||||
with open(file_path, "r") as file:
|
||||
content = file.read()
|
||||
|
||||
for replace in replacements:
|
||||
pattern = rf"({replace.start}.*?{replace.end})"
|
||||
content = re.sub(
|
||||
pattern,
|
||||
f"{replace.start}\n{replace.replacement}\n{replace.end}",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
file.write(content)
|
||||
|
||||
|
||||
def get_device_test(lang: str, model: str) -> str:
|
||||
lang_long = LANGS[lang]
|
||||
|
||||
model_or_empty = f" {model}" if model != "T" else ""
|
||||
model_needs_or_empty = f" {model}" if model != "T" else ""
|
||||
|
||||
return f"""\
|
||||
core device{model_or_empty} test {lang_long}:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen{model_needs_or_empty} debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "{model}"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "{lang}" # {lang_long}
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
"""
|
||||
|
||||
|
||||
def get_click_test(lang: str, model: str) -> str:
|
||||
lang_long = LANGS[lang]
|
||||
|
||||
model_or_empty = f" {model}" if model != "T" else ""
|
||||
model_needs_or_empty = f" {model}" if model != "T" else ""
|
||||
|
||||
return f"""\
|
||||
core click{model_or_empty} test {lang_long}:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen{model_needs_or_empty} debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "{model}"
|
||||
TEST_LANG: "{lang}" # {lang_long}
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
"""
|
||||
|
||||
|
||||
def get_all_tests_text(func: Callable[[str, str], str]) -> str:
|
||||
text = ""
|
||||
for model in MODELS:
|
||||
for lang in LANGS:
|
||||
content = func(lang, model)
|
||||
text += content + "\n"
|
||||
return text
|
||||
|
||||
|
||||
def fill_device_tests() -> None:
|
||||
replacement = Replacement(
|
||||
start=r"##START_DEVICE_TESTS",
|
||||
end=r"##END_DEVICE_TESTS",
|
||||
replacement=get_all_tests_text(get_device_test),
|
||||
)
|
||||
replace_content_between_markers(TEST_FILE, [replacement])
|
||||
|
||||
|
||||
def fill_click_tests() -> None:
|
||||
replacement = Replacement(
|
||||
start=r"##START_CLICK_TESTS",
|
||||
end=r"##END_CLICK_TESTS",
|
||||
replacement=get_all_tests_text(get_click_test),
|
||||
)
|
||||
replace_content_between_markers(TEST_FILE, [replacement])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fill_device_tests()
|
||||
fill_click_tests()
|
@ -0,0 +1,587 @@
|
||||
# Tests for non-english languages, that run only nightly
|
||||
# - apart from that, they run also for every branch containing "translations" in its name
|
||||
|
||||
image: registry.gitlab.com/satoshilabs/trezor/trezor-firmware/trezor-firmware-env.nix
|
||||
|
||||
# Caching
|
||||
.gitlab_caching: &gitlab_caching
|
||||
cache:
|
||||
key: "$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .venv/
|
||||
|
||||
##START_DEVICE_TESTS
|
||||
core device test czech:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "cs" # czech
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device test french:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "fr" # french
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device test german:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "de" # german
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device test spanish:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "es" # spanish
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device R test czech:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "cs" # czech
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device R test french:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "fr" # french
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device R test german:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "de" # german
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
core device R test spanish:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
MULTICORE: "4" # more could interfere with other jobs
|
||||
TEST_LANG: "es" # spanish
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_ui_multicore | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage.* core # there will be more coverage files (one per core)
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/junit.xml
|
||||
- tests/trezor.log
|
||||
- core/.coverage.*
|
||||
when: always
|
||||
expire_in: 1 week
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
|
||||
|
||||
##END_DEVICE_TESTS
|
||||
|
||||
##START_CLICK_TESTS
|
||||
core click test czech:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
TEST_LANG: "cs" # czech
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click test french:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
TEST_LANG: "fr" # french
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click test german:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
TEST_LANG: "de" # german
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click test spanish:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "T"
|
||||
TEST_LANG: "es" # spanish
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click R test czech:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
TEST_LANG: "cs" # czech
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click R test french:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
TEST_LANG: "fr" # french
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click R test german:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
TEST_LANG: "de" # german
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
core click R test spanish:
|
||||
stage: test
|
||||
<<: *gitlab_caching
|
||||
needs:
|
||||
- core unix frozen R debug build
|
||||
variables:
|
||||
TREZOR_PROFILING: "1" # so that we get coverage data
|
||||
TREZOR_MODEL: "R"
|
||||
TEST_LANG: "es" # spanish
|
||||
only:
|
||||
- schedules # nightly build
|
||||
- /translations/ # translations branches
|
||||
script:
|
||||
- $NIX_SHELL --run "poetry run make -C core test_emu_click_ui | ts -s"
|
||||
after_script:
|
||||
- mv core/src/.coverage core/.coverage.test_click
|
||||
- mv tests/ui_tests/reports/test/ test_ui_report
|
||||
- $NIX_SHELL --run "poetry run python ci/prepare_ui_artifacts.py | ts -s"
|
||||
- diff -u tests/ui_tests/fixtures.json tests/ui_tests/fixtures.suggestion.json
|
||||
artifacts:
|
||||
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- ci/ui_test_records/
|
||||
- test_ui_report
|
||||
- tests/ui_tests/screens/
|
||||
- tests/ui_tests/fixtures.suggestion.json
|
||||
- tests/ui_tests/fixtures.results.json
|
||||
- tests/trezor.log
|
||||
- tests/junit.xml
|
||||
- core/.coverage.*
|
||||
reports:
|
||||
junit: tests/junit.xml
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
|
||||
|
||||
##END_CLICK_TESTS
|
@ -0,0 +1 @@
|
||||
const uint8_t *get_utf8_glyph(uint16_t char_code, int font);
|
@ -0,0 +1,386 @@
|
||||
use core::{mem, str};
|
||||
|
||||
use crate::{
|
||||
crypto::{cosi, ed25519, merkle::merkle_root, sha256},
|
||||
error::Error,
|
||||
io::InputStream,
|
||||
};
|
||||
|
||||
use super::public_keys;
|
||||
|
||||
pub const MAX_HEADER_LEN: u16 = 1024;
|
||||
pub const EMPTY_BYTE: u8 = 0xFF;
|
||||
const SENTINEL_ID: u16 = 0xFFFF;
|
||||
|
||||
const SIGNATURE_THRESHOLD: u8 = 2;
|
||||
|
||||
// Maximum padding at the end of an offsets table (typically for alignment
|
||||
// purposes). We allow at most 3 for alignment 4. In practice right now this
|
||||
// should be max 1.
|
||||
const MAX_TABLE_PADDING: usize = 3;
|
||||
|
||||
const INVALID_TRANSLATIONS_BLOB: Error = value_error!("Invalid translations blob");
|
||||
|
||||
#[repr(packed)]
|
||||
struct OffsetEntry {
|
||||
pub id: u16,
|
||||
pub offset: u16,
|
||||
}
|
||||
|
||||
pub struct Table<'a> {
|
||||
offsets: &'a [OffsetEntry],
|
||||
data: &'a [u8],
|
||||
}
|
||||
|
||||
fn validate_offset_table(
|
||||
data_len: usize,
|
||||
mut iter: impl Iterator<Item = u16>,
|
||||
) -> Result<(), Error> {
|
||||
// every offset table must have at least the sentinel
|
||||
let mut prev = iter.next().ok_or(INVALID_TRANSLATIONS_BLOB)?;
|
||||
if prev != 0 {
|
||||
// first offset must always be 0 (even as a sentinel, indicating no data)
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
for next in iter {
|
||||
// offsets must be in ascending order
|
||||
if prev > next {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
prev = next;
|
||||
}
|
||||
// sentinel needs to be at least data_len - MAX_TABLE_PADDING, and at most
|
||||
// data_len
|
||||
let sentinel = prev as usize;
|
||||
if sentinel < data_len - MAX_TABLE_PADDING || sentinel > data_len {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'a> Table<'a> {
|
||||
pub fn new(mut reader: InputStream<'a>) -> Result<Self, Error> {
|
||||
let count = reader.read_u16_le()?;
|
||||
// The offsets table is (count + 1) entries long, the last entry is a sentinel.
|
||||
let offsets_data = reader.read((count + 1) as usize * mem::size_of::<OffsetEntry>())?;
|
||||
// SAFETY: OffsetEntry is repr(packed) of two u16 values, so any four bytes are
|
||||
// a valid OffsetEntry value.
|
||||
let (_prefix, offsets, _suffix) = unsafe { offsets_data.align_to::<OffsetEntry>() };
|
||||
if !_prefix.is_empty() || !_suffix.is_empty() {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
offsets,
|
||||
data: reader.rest(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
validate_offset_table(self.data.len(), self.offsets.iter().map(|it| it.offset))?;
|
||||
if !matches!(
|
||||
self.offsets.iter().last().map(|it| it.id),
|
||||
Some(SENTINEL_ID)
|
||||
) {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
// check that the ids are sorted
|
||||
let Some(first_entry) = self.offsets.first() else {
|
||||
// empty table is sorted
|
||||
return Ok(());
|
||||
};
|
||||
let mut prev_id = first_entry.id;
|
||||
for entry in self.offsets.iter().skip(1) {
|
||||
if entry.id <= prev_id {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
prev_id = entry.id;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, id: u16) -> Option<&'a [u8]> {
|
||||
self.offsets
|
||||
.binary_search_by_key(&id, |it| it.id)
|
||||
.ok()
|
||||
.and_then(|idx| {
|
||||
let start = self.offsets[idx].offset as usize;
|
||||
let end = self.offsets[idx + 1].offset as usize;
|
||||
self.data.get(start..end)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u16, &'a [u8])> + '_ {
|
||||
let mut prev_offset = 0usize;
|
||||
self.offsets.iter().skip(1).map(move |entry| {
|
||||
let start = prev_offset;
|
||||
let end = entry.offset as usize;
|
||||
prev_offset = end;
|
||||
(entry.id, &self.data[start..end])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Translations<'a> {
|
||||
pub header: TranslationsHeader<'a>,
|
||||
translations: &'a [u8],
|
||||
translations_offsets: &'a [u16],
|
||||
fonts: Table<'a>,
|
||||
}
|
||||
|
||||
fn read_u16_prefixed_block<'a>(reader: &mut InputStream<'a>) -> Result<InputStream<'a>, Error> {
|
||||
let len = reader.read_u16_le()? as usize;
|
||||
reader.read_stream(len)
|
||||
}
|
||||
|
||||
impl<'a> Translations<'a> {
|
||||
const MAGIC: &'static [u8] = b"TRTR00";
|
||||
|
||||
pub fn new(blob: &'a [u8]) -> Result<Self, Error> {
|
||||
let mut blob_reader = InputStream::new(blob);
|
||||
|
||||
let (header, payload_reader) = TranslationsHeader::parse_from(&mut blob_reader)?;
|
||||
|
||||
// validate that the trailing bytes, if any, are empty
|
||||
let remaining = blob_reader.rest();
|
||||
if !remaining.iter().all(|&b| b == EMPTY_BYTE) {
|
||||
// TODO optimize to quadwords?
|
||||
return Err(value_error!("Trailing data in translations blob"));
|
||||
}
|
||||
|
||||
let payload_bytes = payload_reader.rest();
|
||||
|
||||
let payload_digest = sha256::digest(payload_bytes);
|
||||
if payload_digest != header.data_hash {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
let mut payload_reader = InputStream::new(payload_bytes);
|
||||
|
||||
let mut translations_reader = read_u16_prefixed_block(&mut payload_reader)?;
|
||||
let fonts_reader = read_u16_prefixed_block(&mut payload_reader)?;
|
||||
|
||||
if payload_reader.remaining() > 0 {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
// construct translations data
|
||||
let translations_count = translations_reader.read_u16_le()? as usize;
|
||||
let translations_offsets_bytes =
|
||||
translations_reader.read((translations_count + 1) * mem::size_of::<u16>())?;
|
||||
// SAFETY: any bytes are valid u16 values, so casting any data to
|
||||
// a sequence of u16 values is safe.
|
||||
let (_prefix, translations_offsets, _suffix) =
|
||||
unsafe { translations_offsets_bytes.align_to::<u16>() };
|
||||
if !_prefix.is_empty() || !_suffix.is_empty() {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
let translations = translations_reader.rest();
|
||||
validate_offset_table(translations.len(), translations_offsets.iter().copied())?;
|
||||
|
||||
// construct and validate font table
|
||||
let fonts = Table::new(fonts_reader)?;
|
||||
fonts.validate()?;
|
||||
for (_, font_data) in fonts.iter() {
|
||||
let reader = InputStream::new(font_data);
|
||||
let font_table = Table::new(reader)?;
|
||||
font_table.validate()?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
header,
|
||||
translations,
|
||||
translations_offsets,
|
||||
fonts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the translation at the given index.
|
||||
pub fn translation(&self, index: usize) -> Option<&str> {
|
||||
if index + 1 >= self.translations_offsets.len() {
|
||||
// The index is out of bounds.
|
||||
// (The last entry is a sentinel, so the last valid index is len - 2)
|
||||
// May happen when new firmware is using older translations and the string
|
||||
// is not defined yet.
|
||||
// Fallback to english.
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_offset = self.translations_offsets[index] as usize;
|
||||
let end_offset = self.translations_offsets[index + 1] as usize;
|
||||
|
||||
// Construct the relevant slice
|
||||
let string = &self.translations[start_offset..end_offset];
|
||||
|
||||
if string.is_empty() {
|
||||
// The string is not defined in the blob.
|
||||
// May happen when old firmware is using newer translations and the string
|
||||
// was deleted in the newer version.
|
||||
// Fallback to english.
|
||||
return None;
|
||||
}
|
||||
|
||||
str::from_utf8(string).ok()
|
||||
}
|
||||
|
||||
pub fn font(&'a self, index: u16) -> Option<Table<'a>> {
|
||||
self.fonts
|
||||
.get(index)
|
||||
.and_then(|data| Table::new(InputStream::new(data)).ok())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TranslationsHeader<'a> {
|
||||
/// Raw content of the header, for signature verification
|
||||
pub header_bytes: &'a [u8],
|
||||
/// BCP 47 language tag (cs-CZ, en-US, ...)
|
||||
pub language: &'a str,
|
||||
/// 4 bytes of version (major, minor, patch, build)
|
||||
pub version: [u8; 4],
|
||||
/// Length of the raw data, i.e. translations section + fonts section
|
||||
pub data_len: usize,
|
||||
/// Hash of the data blob (excluding the header)
|
||||
pub data_hash: sha256::Digest,
|
||||
/// Merkle proof items
|
||||
pub merkle_proof: &'a [sha256::Digest],
|
||||
/// CoSi signature
|
||||
pub signature: cosi::Signature,
|
||||
/// Expected total length of the blob
|
||||
pub total_len: usize,
|
||||
}
|
||||
|
||||
fn read_fixedsize_str<'a>(reader: &mut InputStream<'a>, len: usize) -> Result<&'a str, Error> {
|
||||
let bytes = reader.read(len)?;
|
||||
let find_zero = bytes.iter().position(|&b| b == 0).unwrap_or(len);
|
||||
let bytes_trimmed = &bytes[..find_zero];
|
||||
core::str::from_utf8(bytes_trimmed).map_err(|_| INVALID_TRANSLATIONS_BLOB)
|
||||
}
|
||||
|
||||
impl<'a> TranslationsHeader<'a> {
|
||||
const BLOB_MAGIC: &'static [u8] = b"TRTR00";
|
||||
const HEADER_MAGIC: &'static [u8] = b"TR";
|
||||
const LANGUAGE_TAG_LEN: usize = 8;
|
||||
|
||||
/// Parse a translations header out of a stream.
|
||||
///
|
||||
/// The returned tuple consists of:
|
||||
/// (a) the parsed header and
|
||||
/// (b) reader of the payload section of the translations blob.
|
||||
/// The caller can use the returned reader to parse the payload.
|
||||
///
|
||||
/// The input stream is positioned at the end of the translations blob (or
|
||||
/// at the end of stream, whichever comes sooner). The caller can use this
|
||||
/// to verify that there is no unexpected trailing data in the input
|
||||
/// stream. (Also, you cannot make a mistake and read the payload out of
|
||||
/// the input stream).
|
||||
pub fn parse_from(reader: &mut InputStream<'a>) -> Result<(Self, InputStream<'a>), Error> {
|
||||
//
|
||||
// 1. parse outer container
|
||||
//
|
||||
let magic = reader.read(Self::BLOB_MAGIC.len())?;
|
||||
if magic != Self::BLOB_MAGIC {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
// read length of contained data
|
||||
let container_length = reader.read_u16_le()? as usize;
|
||||
// continue working on the contained data (i.e., read beyond the bounds of
|
||||
// container_length will result in EOF).
|
||||
let mut reader = reader.read_stream(container_length.min(reader.remaining()))?;
|
||||
|
||||
//
|
||||
// 2. parse the header section
|
||||
//
|
||||
let header_bytes = read_u16_prefixed_block(&mut reader)?.rest();
|
||||
|
||||
let mut header_reader = InputStream::new(header_bytes);
|
||||
|
||||
let magic = header_reader.read(Self::HEADER_MAGIC.len())?;
|
||||
if magic != Self::HEADER_MAGIC {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
let language = read_fixedsize_str(&mut header_reader, Self::LANGUAGE_TAG_LEN)?;
|
||||
if language.is_empty() {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
let model = read_fixedsize_str(&mut header_reader, 4)?;
|
||||
if model != crate::trezorhal::model::INTERNAL_NAME {
|
||||
return Err(value_error!("Wrong Trezor model"));
|
||||
}
|
||||
|
||||
let version_bytes = header_reader.read(4)?;
|
||||
let version = unwrap!(version_bytes.try_into());
|
||||
|
||||
let data_len = header_reader.read_u16_le()? as usize;
|
||||
let data_hash: sha256::Digest =
|
||||
unwrap!(header_reader.read(sha256::DIGEST_SIZE)?.try_into());
|
||||
|
||||
// ignore the rest of the header reader - this allows older firmware to
|
||||
// understand newer header if there are only added items
|
||||
_ = header_reader.rest();
|
||||
|
||||
//
|
||||
// 3. parse the proof section
|
||||
//
|
||||
let mut proof_reader = read_u16_prefixed_block(&mut reader)?;
|
||||
let proof_count = proof_reader.read_byte()? as usize;
|
||||
let proof_length = proof_count * sha256::DIGEST_SIZE;
|
||||
let proof_bytes = proof_reader.read(proof_length)?;
|
||||
|
||||
// create a list of the proof items
|
||||
// SAFETY: sha256::Digest is a plain array of u8, so any bytes are valid
|
||||
let (_prefix, merkle_proof, _suffix) = unsafe { proof_bytes.align_to::<sha256::Digest>() };
|
||||
if !_prefix.is_empty() || !_suffix.is_empty() {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
let signature = cosi::Signature::new(
|
||||
proof_reader.read_byte()?,
|
||||
unwrap!(proof_reader.read(ed25519::SIGNATURE_SIZE)?.try_into()),
|
||||
);
|
||||
|
||||
// check that there is no trailing data in the proof section
|
||||
if proof_reader.remaining() > 0 {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
// check that the declared data section length matches the container size
|
||||
if container_length - reader.tell() != data_len {
|
||||
return Err(INVALID_TRANSLATIONS_BLOB);
|
||||
}
|
||||
|
||||
let new = Self {
|
||||
header_bytes,
|
||||
language,
|
||||
version,
|
||||
data_len,
|
||||
data_hash,
|
||||
merkle_proof,
|
||||
signature,
|
||||
total_len: container_length + Self::BLOB_MAGIC.len() + mem::size_of::<u16>(),
|
||||
};
|
||||
new.verify()?;
|
||||
Ok((new, reader))
|
||||
}
|
||||
|
||||
fn verify_with_keys(&self, public_keys: &[ed25519::PublicKey]) -> Result<(), Error> {
|
||||
let merkle_root = merkle_root(self.header_bytes, self.merkle_proof);
|
||||
Ok(cosi::verify(
|
||||
SIGNATURE_THRESHOLD,
|
||||
&merkle_root,
|
||||
public_keys,
|
||||
&self.signature,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> Result<(), Error> {
|
||||
let mut result = self.verify_with_keys(&public_keys::PUBLIC_KEYS);
|
||||
#[cfg(feature = "debug")]
|
||||
if result.is_err() {
|
||||
// allow development keys
|
||||
result = self.verify_with_keys(&public_keys::PUBLIC_KEYS_DEVEL);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
use crate::{error::Error, trezorhal::translations};
|
||||
|
||||
use super::blob::Translations;
|
||||
|
||||
static mut TRANSLATIONS_ON_FLASH: Option<Translations> = None;
|
||||
|
||||
pub fn erase() -> Result<(), Error> {
|
||||
// SAFETY: Looking is safe (in a single threaded environment).
|
||||
if unsafe { TRANSLATIONS_ON_FLASH.is_some() } {
|
||||
return Err(value_error!("Translations blob already set"));
|
||||
}
|
||||
|
||||
// SAFETY: The blob is not set, so there are no references to it.
|
||||
unsafe { translations::erase() };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(data: &[u8], offset: usize) -> Result<(), Error> {
|
||||
// SAFETY: Looking is safe (in a single threaded environment).
|
||||
if unsafe { TRANSLATIONS_ON_FLASH.is_some() } {
|
||||
return Err(value_error!("Translations blob already set"));
|
||||
}
|
||||
|
||||
// SAFETY: The blob is not set, so there are no references to it.
|
||||
let result = unsafe { translations::write(data, offset) };
|
||||
if result {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(value_error!("Failed to write translations blob"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Load translations from flash, validate, and cache references to lookup
|
||||
/// tables.
|
||||
unsafe fn try_init<'a>() -> Result<Option<Translations<'a>>, Error> {
|
||||
// load from flash
|
||||
let flash_data = unsafe { translations::get_blob() };
|
||||
// check if flash is empty
|
||||
// TODO perhaps we should check the full area?
|
||||
if flash_data[0..16] == [super::blob::EMPTY_BYTE; 16] {
|
||||
return Ok(None);
|
||||
}
|
||||
// try to parse the data
|
||||
Translations::new(flash_data).map(Some)
|
||||
}
|
||||
|
||||
pub fn init() {
|
||||
// unsafe block because every individual operation here is unsafe
|
||||
unsafe {
|
||||
// SAFETY: it is OK to look
|
||||
if TRANSLATIONS_ON_FLASH.is_some() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: try_init unconditionally loads the translations from flash.
|
||||
// No other reference exists (TRANSLATIONS_ON_FLASH is None) so this is safe.
|
||||
match try_init() {
|
||||
// SAFETY: We are in a single-threaded environment so setting is OK.
|
||||
// (note that from this point on a reference to flash data is held)
|
||||
Ok(Some(t)) => TRANSLATIONS_ON_FLASH = Some(t),
|
||||
Ok(None) => {}
|
||||
// SAFETY: No reference to flash data exists so it is OK to erase it.
|
||||
Err(_) => translations::erase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: Invalidates all references coming from the flash-based blob.
|
||||
// In other words, none should exist when this function is called.
|
||||
pub unsafe fn deinit() {
|
||||
// SAFETY: Given the above, we can safely clear the cached object.
|
||||
unsafe { TRANSLATIONS_ON_FLASH = None };
|
||||
}
|
||||
|
||||
// SAFETY: Gives out a reference to a TranslationsBlob which can be invalidated
|
||||
// by calling `erase()`. The caller must not store this reference, nor any that
|
||||
// come from it, beyond the lifetime of the current function.
|
||||
pub unsafe fn get<'a>() -> Option<&'a Translations<'a>> {
|
||||
// SAFETY: We are in a single-threaded environment.
|
||||
unsafe { TRANSLATIONS_ON_FLASH.as_ref() }
|
||||
}
|
@ -0,0 +1 @@
|
||||
pub mod translated_string;
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@
|
||||
//! generated from ${THIS_FILE.name}
|
||||
//! (by running `make templates` in `core`)
|
||||
//! do not edit manually!
|
||||
|
||||
#![cfg_attr(rustfmt, rustfmt_skip)]
|
||||
<%
|
||||
import json
|
||||
|
||||
TR_DIR = ROOT / "core" / "translations"
|
||||
|
||||
order_file = TR_DIR / "order.json"
|
||||
order_index_name = json.loads(order_file.read_text())
|
||||
order = {int(k): v for k, v in order_index_name.items()}
|
||||
|
||||
|
||||
en_file = TR_DIR / "en.json"
|
||||
en_data = json.loads(en_file.read_text())["translations"]
|
||||
|
||||
%>\
|
||||
#[cfg(feature = "micropython")]
|
||||
use crate::micropython::qstr::Qstr;
|
||||
|
||||
#[derive(Debug, Copy, Clone, FromPrimitive)]
|
||||
#[repr(u16)]
|
||||
#[allow(non_camel_case_types)]
|
||||
pub enum TranslatedString {
|
||||
% for idx, name in order.items():
|
||||
${name} = ${idx},
|
||||
% endfor
|
||||
}
|
||||
|
||||
impl TranslatedString {
|
||||
pub fn untranslated(self) -> &'static str {
|
||||
match self {
|
||||
% for name in order.values():
|
||||
Self::${name} => ${json.dumps(en_data.get(name, '""'))},
|
||||
% endfor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "micropython")]
|
||||
pub fn from_qstr(qstr: Qstr) -> Option<Self> {
|
||||
match qstr {
|
||||
% for name in order.values():
|
||||
Qstr::MP_QSTR_${name} => Some(Self::${name}),
|
||||
% endfor
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
mod blob;
|
||||
mod flash;
|
||||
mod generated;
|
||||
#[cfg(feature = "micropython")]
|
||||
mod obj;
|
||||
mod public_keys;
|
||||
mod translated_string;
|
||||
|
||||
pub use blob::MAX_HEADER_LEN;
|
||||
pub use translated_string::TranslatedString as TR;
|
||||
pub const DEFAULT_LANGUAGE: &str = "en-US";
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// Returned pointer will only point to valid font data for as long as
|
||||
/// the flash content is not invalidated by `erase()` or `write()`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn get_utf8_glyph(codepoint: cty::uint16_t, font: cty::c_int) -> *const u8 {
|
||||
// C will send a negative number
|
||||
let font_abs = font.unsigned_abs() as u16;
|
||||
|
||||
// SAFETY: Reference is discarded at the end of the function.
|
||||
// We do return a _pointer_ to the same memory location, but the pointer is
|
||||
// always valid.
|
||||
let Some(tr) = (unsafe { flash::get() }) else {
|
||||
return core::ptr::null();
|
||||
};
|
||||
if let Some(glyph) = tr.font(font_abs).and_then(|t| t.get(codepoint)) {
|
||||
glyph.as_ptr()
|
||||
} else {
|
||||
core::ptr::null()
|
||||
}
|
||||
}
|
@ -0,0 +1,241 @@
|
||||
use crate::{
|
||||
error::Error,
|
||||
io::InputStream,
|
||||
micropython::{
|
||||
buffer::{get_buffer, StrBuffer},
|
||||
ffi,
|
||||
map::Map,
|
||||
module::Module,
|
||||
obj::Obj,
|
||||
qstr::Qstr,
|
||||
simple_type::SimpleTypeObj,
|
||||
typ::Type,
|
||||
util,
|
||||
},
|
||||
trezorhal::translations,
|
||||
};
|
||||
|
||||
use super::translated_string::TranslatedString;
|
||||
|
||||
impl TryFrom<TranslatedString> for StrBuffer {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: TranslatedString) -> Result<Self, Self::Error> {
|
||||
// SAFETY: The translated string is copied into a new memory. Reference to flash
|
||||
// data is discarded at the end of this function.
|
||||
let translated = value.translate(unsafe { super::flash::get() });
|
||||
StrBuffer::alloc(translated)
|
||||
// TODO fall back to English (which is static and can be converted
|
||||
// infallibly) if the allocation fails?
|
||||
}
|
||||
}
|
||||
|
||||
fn translate(translation: TranslatedString) -> Result<Obj, Error> {
|
||||
// SAFETY: TryFrom<&str> for Obj allocates a copy of the passed in string.
|
||||
// The reference to flash data is discarded at the end of this function.
|
||||
let stored_translations = unsafe { super::flash::get() };
|
||||
translation.translate(stored_translations).try_into()
|
||||
}
|
||||
|
||||
// SAFETY: Caller is supposed to be MicroPython, or copy MicroPython contracts
|
||||
// about the meaning of arguments.
|
||||
unsafe extern "C" fn tr_attr_fn(_self_in: Obj, attr: ffi::qstr, dest: *mut Obj) {
|
||||
let block = || {
|
||||
let arg = unsafe { dest.read() };
|
||||
if !arg.is_null() {
|
||||
// Null destination would mean a `setattr`.
|
||||
return Err(Error::TypeError);
|
||||
}
|
||||
let attr = Qstr::from_u16(attr as u16);
|
||||
let result = if let Some(translation) = TranslatedString::from_qstr(attr) {
|
||||
translate(translation)?
|
||||
} else {
|
||||
return Err(Error::AttributeError(attr));
|
||||
};
|
||||
unsafe { dest.write(result) };
|
||||
Ok(())
|
||||
};
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
static TR_TYPE: Type = obj_type! {
|
||||
name: Qstr::MP_QSTR_TR,
|
||||
attr_fn: tr_attr_fn,
|
||||
};
|
||||
|
||||
static TR_OBJ: SimpleTypeObj = SimpleTypeObj::new(&TR_TYPE);
|
||||
|
||||
fn make_translations_header(header: &super::blob::TranslationsHeader<'_>) -> Result<Obj, Error> {
|
||||
let version_objs: [Obj; 4] = {
|
||||
let v = header.version;
|
||||
[v[0].into(), v[1].into(), v[2].into(), v[3].into()]
|
||||
};
|
||||
attr_tuple! {
|
||||
Qstr::MP_QSTR_language => header.language.try_into()?,
|
||||
Qstr::MP_QSTR_version => util::new_tuple(&version_objs)?,
|
||||
Qstr::MP_QSTR_data_len => header.data_len.try_into()?,
|
||||
Qstr::MP_QSTR_data_hash => header.data_hash.as_ref().try_into()?,
|
||||
Qstr::MP_QSTR_total_len => header.total_len.try_into()?,
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn translations_header_new(
|
||||
_self_in: Obj,
|
||||
n_args: usize,
|
||||
n_kw: usize,
|
||||
args: *const Obj,
|
||||
) -> Obj {
|
||||
let block = |args: &[Obj], kwargs: &Map| {
|
||||
if args.len() != 1 || !kwargs.is_empty() {
|
||||
return Err(Error::TypeError);
|
||||
}
|
||||
// SAFETY: reference is discarded at the end of this function.
|
||||
let buffer = unsafe { get_buffer(args[0])? };
|
||||
let (header, _) =
|
||||
super::blob::TranslationsHeader::parse_from(&mut InputStream::new(buffer))?;
|
||||
make_translations_header(&header)
|
||||
};
|
||||
unsafe { util::try_with_args_and_kwargs_inline(n_args, n_kw, args, block) }
|
||||
}
|
||||
|
||||
pub extern "C" fn translations_header_from_flash(_cls_in: Obj) -> Obj {
|
||||
let block = || {
|
||||
// SAFETY: reference is discarded at the end of this function.
|
||||
match unsafe { super::flash::get() } {
|
||||
Some(translations) => make_translations_header(&translations.header),
|
||||
None => Ok(Obj::const_none()),
|
||||
}
|
||||
};
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
static TRANSLATIONS_HEADER_TYPE: Type = obj_type! {
|
||||
name: Qstr::MP_QSTR_TranslationsHeader,
|
||||
locals: &obj_dict!(obj_map! {
|
||||
Qstr::MP_QSTR_load_from_flash => obj_fn_1!(translations_header_from_flash).as_obj(),
|
||||
}),
|
||||
call_fn: translations_header_new,
|
||||
};
|
||||
|
||||
static TRANSLATIONS_HEADER_OBJ: SimpleTypeObj = SimpleTypeObj::new(&TRANSLATIONS_HEADER_TYPE);
|
||||
|
||||
extern "C" fn area_bytesize() -> Obj {
|
||||
let bytesize = translations::area_bytesize();
|
||||
unsafe { util::try_or_raise(|| bytesize.try_into()) }
|
||||
}
|
||||
|
||||
extern "C" fn get_language() -> Obj {
|
||||
let block = || {
|
||||
// SAFETY: reference is discarded at the end of the block
|
||||
let lang_name = unsafe { super::flash::get() }.map(|t| t.header.language);
|
||||
lang_name.unwrap_or(super::DEFAULT_LANGUAGE).try_into()
|
||||
};
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
extern "C" fn init() -> Obj {
|
||||
let block = || {
|
||||
super::flash::init();
|
||||
Ok(Obj::const_none())
|
||||
};
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
extern "C" fn deinit() -> Obj {
|
||||
// SAFETY: Safe by itself. Any unsafety stems from some other piece of code
|
||||
// not upholding the safety parameters.
|
||||
unsafe { super::flash::deinit() };
|
||||
Obj::const_none()
|
||||
}
|
||||
|
||||
extern "C" fn erase() -> Obj {
|
||||
let block = || {
|
||||
super::flash::erase()?;
|
||||
Ok(Obj::const_none())
|
||||
};
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
extern "C" fn write(data: Obj, offset: Obj) -> Obj {
|
||||
let block = || {
|
||||
// SAFETY: reference is discarded at the end of the block
|
||||
let data = unsafe { get_buffer(data)? };
|
||||
let offset: usize = offset.try_into()?;
|
||||
super::flash::write(data, offset)?;
|
||||
Ok(Obj::const_none())
|
||||
};
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
extern "C" fn verify(data: Obj) -> Obj {
|
||||
let block = || {
|
||||
// SAFETY: reference is discarded at the end of the block
|
||||
let data = unsafe { get_buffer(data)? };
|
||||
super::blob::Translations::new(data)?;
|
||||
Ok(Obj::const_none())
|
||||
};
|
||||
|
||||
unsafe { util::try_or_raise(block) }
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[rustfmt::skip]
|
||||
pub static mp_module_trezortranslate: Module = obj_module! {
|
||||
/// from trezortranslate_keys import TR as TR # noqa: F401
|
||||
/// """Translation object with attributes."""
|
||||
Qstr::MP_QSTR_TR => TR_OBJ.as_obj(),
|
||||
|
||||
/// def area_bytesize() -> int:
|
||||
/// """Maximum size of the translation blob that can be stored."""
|
||||
Qstr::MP_QSTR_area_bytesize => obj_fn_0!(area_bytesize).as_obj(),
|
||||
|
||||
/// def get_language() -> str:
|
||||
/// """Get the current language."""
|
||||
Qstr::MP_QSTR_get_language => obj_fn_0!(get_language).as_obj(),
|
||||
|
||||
/// def init() -> None:
|
||||
/// """Initialize the translations system.
|
||||
///
|
||||
/// Loads and verifies translation data from flash. If the verification passes,
|
||||
/// Trezor UI is translated from that point forward.
|
||||
/// """
|
||||
Qstr::MP_QSTR_init => obj_fn_0!(init).as_obj(),
|
||||
|
||||
/// def deinit() -> None:
|
||||
/// """Deinitialize the translations system.
|
||||
///
|
||||
/// Translations must be deinitialized before erasing or writing to flash.
|
||||
/// """
|
||||
Qstr::MP_QSTR_deinit => obj_fn_0!(deinit).as_obj(),
|
||||
|
||||
/// def erase() -> None:
|
||||
/// """Erase the translations blob from flash."""
|
||||
Qstr::MP_QSTR_erase => obj_fn_0!(erase).as_obj(),
|
||||
|
||||
/// def write(data: bytes, offset: int) -> None:
|
||||
/// """Write data to the translations blob in flash."""
|
||||
Qstr::MP_QSTR_write => obj_fn_2!(write).as_obj(),
|
||||
|
||||
/// def verify(data: bytes) -> None:
|
||||
/// """Verify the translations blob."""
|
||||
Qstr::MP_QSTR_verify => obj_fn_1!(verify).as_obj(),
|
||||
|
||||
/// class TranslationsHeader:
|
||||
/// """Metadata about the translations blob."""
|
||||
///
|
||||
/// language: str
|
||||
/// version: tuple[int, int, int, int]
|
||||
/// data_len: int
|
||||
/// data_hash: bytes
|
||||
/// total_len: int
|
||||
///
|
||||
/// def __init__(self, header_bytes: bytes) -> None:
|
||||
/// """Parse header from bytes.
|
||||
/// The header has variable length.
|
||||
/// """
|
||||
///
|
||||
/// @staticmethod
|
||||
/// def load_from_flash() -> TranslationsHeader | None:
|
||||
/// """Load translations from flash."""
|
||||
Qstr::MP_QSTR_TranslationsHeader => TRANSLATIONS_HEADER_OBJ.as_obj(),
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
use crate::crypto::ed25519;
|
||||
|
||||
#[cfg(feature = "debug")]
|
||||
pub const PUBLIC_KEYS_DEVEL: [ed25519::PublicKey; 3] = [
|
||||
*b"\x68\x46\x0e\xbe\xf3\xb1\x38\x16\x4e\xc7\xfd\x86\x10\xe9\x58\x00\xdf\x75\x98\xf7\x0f\x2f\x2e\xa7\xdb\x51\x72\xac\x74\xeb\xc1\x44",
|
||||
*b"\x8d\x4a\xbe\x07\x4f\xef\x92\x29\xd3\xb4\x41\xdf\xea\x4f\x98\xf8\x05\xb1\xa2\xb3\xa0\x6a\xe6\x45\x81\x0e\xfe\xce\x77\xfd\x50\x44",
|
||||
*b"\x97\xf7\x13\x5a\x9a\x26\x90\xe7\x3b\xeb\x26\x55\x6f\x1c\xb1\x63\xbe\xa2\x53\x2a\xff\xa1\xe7\x78\x24\x30\xbe\x98\xc0\xe5\x68\x12",
|
||||
];
|
||||
|
||||
pub const PUBLIC_KEYS: [ed25519::PublicKey; 3] = [
|
||||
// TODO replace with production keys
|
||||
*b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f",
|
||||
*b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f",
|
||||
*b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f",
|
||||
];
|
@ -0,0 +1,41 @@
|
||||
use crate::strutil::TString;
|
||||
|
||||
use super::blob::Translations;
|
||||
pub use super::generated::translated_string::TranslatedString;
|
||||
|
||||
impl TranslatedString {
|
||||
pub(super) fn translate<'a>(self, source: Option<&'a Translations>) -> &'a str {
|
||||
source
|
||||
.and_then(|s| s.translation(self as _))
|
||||
.unwrap_or(self.untranslated())
|
||||
}
|
||||
|
||||
pub fn map_translated<F, T>(self, fun: F) -> T
|
||||
where
|
||||
F: for<'a> FnOnce(&'a str) -> T,
|
||||
T: 'static,
|
||||
{
|
||||
// SAFETY: The bound on F _somehow_ ensures that the reference cannot escape
|
||||
// the closure. (I don't understand how, but it does), see soundness test below.
|
||||
// For good measure, we limit the return value to 'static.
|
||||
let translations = unsafe { super::flash::get() };
|
||||
fun(self.translate(translations))
|
||||
}
|
||||
|
||||
pub const fn as_tstring(self) -> TString<'static> {
|
||||
TString::Translation(self)
|
||||
}
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use super::TranslatedString;
|
||||
|
||||
// #[test]
|
||||
// fn test_soundness() {
|
||||
// let tr = TranslatedString::address__public_key;
|
||||
// let mut opt: Option<&str> = None;
|
||||
// tr.map_translated(|s| opt = Some(s));
|
||||
// assert!(matches!(opt, Some("Address / Public key")));
|
||||
// }
|
||||
// }
|
@ -0,0 +1,31 @@
|
||||
use super::ffi;
|
||||
|
||||
// SAFETY: Returned slice is valid and immutable until a call to `erase()`
|
||||
// and/or `set_blob()`. Caller is responsible for disposing of all references to
|
||||
// the slice before touching the flash contents.
|
||||
pub unsafe fn get_blob<'a>() -> &'a [u8] {
|
||||
let mut len: u32 = 0;
|
||||
let ptr = unsafe { ffi::translations_read(&mut len, 0) };
|
||||
if ptr.is_null() {
|
||||
fatal_error!("Translations read failed", "");
|
||||
}
|
||||
// SAFETY: The pointer is always valid.
|
||||
unsafe { core::slice::from_raw_parts(ptr, len as usize) }
|
||||
}
|
||||
|
||||
// SAFETY: This call invalidates the reference to the blob returned by
|
||||
// `get_blob()`.
|
||||
pub unsafe fn erase() {
|
||||
unsafe { ffi::translations_erase() };
|
||||
}
|
||||
|
||||
pub fn area_bytesize() -> usize {
|
||||
// SAFETY: Safe, no side effects.
|
||||
unsafe { ffi::translations_area_bytesize() as usize }
|
||||
}
|
||||
|
||||
// SAFETY: This call may invalidate the reference to the blob returned by
|
||||
// `get_blob()`.
|
||||
pub unsafe fn write(data: &[u8], offset: usize) -> bool {
|
||||
unsafe { ffi::translations_write(data.as_ptr(), offset as u32, data.len() as u32) }
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
pub mod choice;
|
||||
pub mod choice_item;
|
||||
|
||||
#[cfg(feature = "translations")]
|
||||
pub mod number_input;
|
||||
#[cfg(feature = "translations")]
|
||||
pub mod passphrase;
|
||||
#[cfg(feature = "translations")]
|
||||
pub mod pin;
|
||||
#[cfg(feature = "translations")]
|
||||
pub mod simple_choice;
|
||||
#[cfg(feature = "translations")]
|
||||
pub mod wordlist;
|
||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue