From 2f0f7900cadcd87738b9649d9b3d5285780b5861 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:11:10 +0000 Subject: [PATCH] feat: enforce NomadNet limits and observability --- .gitattributes | 2 + .github/workflows/test.yml | 8 +- lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h | 14 +- lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp | 92 ++++- lib/tdeck_ui/UI/LXMF/NomadNetDocument.h | 3 + lib/tdeck_ui/UI/LXMF/NomadNetHistory.h | 149 +++++++- lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp | 98 +++++ lib/tdeck_ui/UI/LXMF/NomadNetOwner.h | 84 +++++ .../UI/LXMF/NomadNetPageApplication.h | 136 +++++++ lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp | 155 +++++--- lib/tdeck_ui/UI/LXMF/NomadNetScreen.h | 21 ++ lib/tdeck_ui/UI/LXMF/UIManager.cpp | 315 ++++++++-------- lib/tdeck_ui/UI/LXMF/UIManager.h | 13 +- patch_lvgl_textarea.py | 84 ++++- platformio.ini | 2 +- .../fixtures/nomadnet_feature_matrix.mu | 96 +++++ .../native/lvgl_oom/test_password_mode_oom.c | 12 + .../nomadnet_lvgl_acceptance/CMakeLists.txt | 38 ++ .../nomadnet_lvgl_acceptance/acceptance.cpp | 352 ++++++++++++++++++ .../native/nomadnet_lvgl_acceptance/lv_conf.h | 14 + .../nomadnet_lvgl_acceptance/stubs/Arduino.h | 14 + .../stubs/esp32-hal-psram.h | 3 + .../nomadnet_x86_flow/BuildManifest.h.in | 5 + tests/native/nomadnet_x86_flow/CMakeLists.txt | 42 +++ tests/native/nomadnet_x86_flow/client.cpp | 169 ++++++++- tests/native/nomadnet_x86_flow/run_flow.py | 102 ++++- tests/native/nomadnet_x86_flow/server.py | 26 +- tests/native/test_app_launcher_nomadnet.cpp | 138 ++++++- tests/native/test_app_launcher_nomadnet.py | 141 +++++-- tests/native/test_lvgl_dependency.py | 141 +++++++ tests/native/test_nomadnet_lvgl_acceptance.py | 47 +++ ...est_nomadnet_parse_apply_alloc_failure.cpp | 233 ++++++++++++ ...test_nomadnet_parse_apply_alloc_failure.py | 38 ++ tests/native/test_nomadnet_table_limits.cpp | 80 ++++ tests/native/test_nomadnet_table_limits.py | 32 ++ tests/native/test_nomadnet_x86_flow.py | 130 ++++++- tools/resolve_lvgl.py | 135 +++++++ 37 files changed, 2861 insertions(+), 303 deletions(-) create mode 100644 .gitattributes create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetOwner.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetPageApplication.h create mode 100644 tests/native/fixtures/nomadnet_feature_matrix.mu create mode 100644 tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt create mode 100644 tests/native/nomadnet_lvgl_acceptance/acceptance.cpp create mode 100644 tests/native/nomadnet_lvgl_acceptance/lv_conf.h create mode 100644 tests/native/nomadnet_lvgl_acceptance/stubs/Arduino.h create mode 100644 tests/native/nomadnet_lvgl_acceptance/stubs/esp32-hal-psram.h create mode 100644 tests/native/nomadnet_x86_flow/BuildManifest.h.in create mode 100644 tests/native/test_lvgl_dependency.py create mode 100644 tests/native/test_nomadnet_lvgl_acceptance.py create mode 100644 tests/native/test_nomadnet_parse_apply_alloc_failure.cpp create mode 100644 tests/native/test_nomadnet_parse_apply_alloc_failure.py create mode 100644 tests/native/test_nomadnet_table_limits.cpp create mode 100644 tests/native/test_nomadnet_table_limits.py create mode 100755 tools/resolve_lvgl.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1f13ef21 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Canonical Micron fixture excerpts preserve source bytes, including spaces. +tests/native/fixtures/nomadnet_feature_matrix.mu whitespace=-trailing-space diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27602f44..78e22fd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,12 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libbz2-dev - pip install pytest rns==1.4.2 nomadnet==1.2.8 + pip install pytest platformio rns==1.4.2 nomadnet==1.2.8 + + - name: Resolve and attest pinned LVGL dependency + env: + PYXIS_PLATFORMIO_BIN: ${{ env.pythonLocation }}/bin/pio + run: python tools/resolve_lvgl.py --root "$PWD" - name: Build NomadNet real-peer client run: | @@ -43,6 +48,7 @@ jobs: - name: Run pytest env: + PYXIS_PLATFORMIO_BIN: ${{ env.pythonLocation }}/bin/pio PYXIS_NOMADNET_X86_CLIENT: ${{ github.workspace }}/build/nomadnet-x86-flow/pyxis_nomadnet_x86_flow PYXIS_NOMADNET_RNS_PYTHON: ${{ env.pythonLocation }}/bin/python PYXIS_NOMADNET_REFERENCE_SOURCE: ${{ env.pythonLocation }}/lib/python3.11/site-packages diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h index 137fe501..57466e47 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h @@ -21,12 +21,19 @@ inline bool block_has_layout_content(BlockType type, uint16_t run_count) { type == BlockType::TABLE || run_count != 0; } -enum class TableLayoutTier : uint8_t { FIT, REFLOW }; +enum class TableLayoutTier : uint8_t { FIT, REFLOW, STACKED }; inline TableLayoutTier choose_table_layout(int32_t structural_minimum_width, - int32_t content_width) { + int32_t natural_width, + int32_t content_width, + uint8_t column_count = 1, + int16_t minimum_readable_cell_width = 0) { + if (column_count != 0 && minimum_readable_cell_width > 0 && + content_width / column_count < minimum_readable_cell_width) + return TableLayoutTier::STACKED; + if (natural_width <= content_width) return TableLayoutTier::FIT; return structural_minimum_width <= content_width - ? TableLayoutTier::FIT : TableLayoutTier::REFLOW; + ? TableLayoutTier::REFLOW : TableLayoutTier::STACKED; } inline int16_t fit_table_columns(int16_t* widths, uint8_t column_count, @@ -192,6 +199,7 @@ public: bool empty() const { return _blocks.empty(); } std::size_t arena_bytes() const { return _arena.size(); } + const ExternalVector& blocks() const { return _blocks; } const ExternalVector& runs() const { return _runs; } const ExternalVector& links() const { return _links; } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp index 581d9fa1..650f2e20 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp @@ -1,6 +1,7 @@ #include "NomadNetDocument.h" #include +#include #include #include @@ -433,7 +434,14 @@ std::string trim_table_cell(const std::string& value) { return value.substr(first, last - first); } -std::vector parse_table_row(const std::string& source) { +struct BoundedTableRow { + std::array cells; + std::size_t count = 0; + bool columns_exceeded = false; + bool cell_bytes_exceeded = false; +}; + +BoundedTableRow parse_table_row(const std::string& source) { std::size_t first = 0; std::size_t last = source.size(); while (first < last && std::isspace(static_cast(source[first]))) ++first; @@ -441,25 +449,57 @@ std::vector parse_table_row(const std::string& source) { if (first < last && source[first] == '|') ++first; if (last > first && source[last - 1] == '|') --last; - std::vector cells; + BoundedTableRow row; std::string current; + current.reserve(std::min(last - first, + DocumentParser::MAX_TABLE_CELL_BYTES)); bool escaped = false; + bool current_overflow_seen = false; + auto append_cell_byte = [&](char value) { + if (row.count >= DocumentParser::MAX_TABLE_COLUMNS) return; + if (current_overflow_seen) { + row.cell_bytes_exceeded = true; + return; + } + if (current.size() < DocumentParser::MAX_TABLE_CELL_BYTES) { + current.push_back(value); + return; + } + row.cell_bytes_exceeded = true; + if (!current_overflow_seen && + (static_cast(value) & 0xc0) == 0x80) { + while (!current.empty() && + (static_cast(current.back()) & 0xc0) == 0x80) + current.pop_back(); + if (!current.empty() && static_cast(current.back()) >= 0xc2) + current.pop_back(); + } + current_overflow_seen = true; + }; + auto finish_cell = [&]() { + if (row.count < DocumentParser::MAX_TABLE_COLUMNS) { + row.cells[row.count++] = trim_table_cell(current); + } else { + row.columns_exceeded = true; + } + current.clear(); + current_overflow_seen = false; + }; for (std::size_t i = first; i < last; ++i) { const char value = source[i]; if (escaped) { - current.push_back(value); + append_cell_byte(value); escaped = false; } else if (value == '\\') { escaped = true; } else if (value == '|') { - cells.push_back(trim_table_cell(current)); - current.clear(); + finish_cell(); } else { - current.push_back(value); + append_cell_byte(value); } } - cells.push_back(trim_table_cell(current)); - return cells; + finish_cell(); + return row; } Alignment table_cell_alignment(const std::string& source) { @@ -527,8 +567,11 @@ bool append_table(Document& doc, const std::vector& lines, const auto header = parse_table_row(lines.front()); const auto alignment_cells = parse_table_row(lines[1]); - const std::size_t columns = std::min(header.size(), DocumentParser::MAX_TABLE_COLUMNS); - if (header.size() > columns) doc.mark_truncated(TruncationReason::TABLE_COLUMNS); + const std::size_t columns = header.count; + if (header.columns_exceeded || alignment_cells.columns_exceeded) + doc.mark_truncated(TruncationReason::TABLE_COLUMNS); + if (header.cell_bytes_exceeded || alignment_cells.cell_bytes_exceeded) + doc.mark_truncated(TruncationReason::TABLE_CELL_BYTES); if (columns == 0) { append_malformed_table(doc, lines, total_runs); return false; @@ -554,15 +597,18 @@ bool append_table(Document& doc, const std::vector& lines, for (std::size_t row = 0; row < rows; ++row) { const auto cells = row == 0 ? header : parse_table_row(lines[row + 1]); + if (cells.columns_exceeded) doc.mark_truncated(TruncationReason::TABLE_COLUMNS); + if (cells.cell_bytes_exceeded) doc.mark_truncated(TruncationReason::TABLE_CELL_BYTES); for (std::size_t column = 0; column < columns; ++column) { TableCell cell; cell.first_run = static_cast(doc.table_runs.size()); cell.alignment = row == 0 ? Alignment::LEFT : - (column < alignment_cells.size() ? table_cell_alignment(alignment_cells[column]) : Alignment::LEFT); + (column < alignment_cells.count ? table_cell_alignment(alignment_cells.cells[column]) : Alignment::LEFT); Block parsed; - if (column < cells.size()) { - std::string cell_source = cells[column]; - const bool cell_truncated = cell_source.size() > DocumentParser::MAX_TABLE_CELL_BYTES; + if (column < cells.count) { + std::string cell_source = cells.cells[column]; + const bool cell_truncated = cells.cell_bytes_exceeded && + cell_source.size() == DocumentParser::MAX_TABLE_CELL_BYTES; const Style style_before_cell = style; if (cell_truncated) { std::size_t retained = DocumentParser::MAX_TABLE_CELL_BYTES; @@ -602,6 +648,24 @@ Document DocumentParser::parse(const std::string& source) const { return parse(source.data(), source.size()); } +ParseStatus DocumentParser::parse_into(const char* source, std::size_t size, + Document& output) const noexcept { + if (!source && size != 0) { + output = Document{}; + output.malformed = true; + return ParseStatus::INVALID_INPUT; + } + try { + Document candidate = parse(source, size); + output = std::move(candidate); + return ParseStatus::OK; + } catch (const std::bad_alloc&) { + output = Document{}; + output.allocation_failed = true; + return ParseStatus::ALLOCATION_FAILED; + } +} + Document DocumentParser::parse(const char* source, std::size_t size) const { Document doc; if (!source && size != 0) { diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h index a22494be..10abc2d4 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h @@ -10,6 +10,7 @@ namespace UI::LXMF::NomadNet { enum class BlockType { TEXT, HEADING, DIVIDER, TABLE, UNSUPPORTED }; enum class Alignment { LEFT, CENTER, RIGHT }; enum class FormFieldType : uint8_t { TEXT, PASSWORD, CHECKBOX, RADIO }; +enum class ParseStatus : uint8_t { OK, INVALID_INPUT, ALLOCATION_FAILED }; enum class TruncationReason : uint32_t { DOCUMENT_BYTES = 1 << 0, @@ -121,6 +122,7 @@ struct Document { bool truncated = false; bool malformed = false; bool unsupported = false; + bool allocation_failed = false; std::size_t source_bytes = 0; std::size_t source_lines = 0; uint32_t truncation_reasons = 0; @@ -167,6 +169,7 @@ public: Document parse(const std::string& source) const; Document parse(const char* source, std::size_t size) const; + ParseStatus parse_into(const char* source, std::size_t size, Document& output) const noexcept; }; std::string truncation_notice(const Document& document); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h b/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h index db98eb18..a7d64d13 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h @@ -15,6 +15,68 @@ class PageHistory { public: static constexpr std::size_t MAX_DEPTH = 16; + class PendingOpen { + public: + PendingOpen() = default; + PendingOpen(const PendingOpen&) = delete; + PendingOpen& operator=(const PendingOpen&) = delete; + PendingOpen(PendingOpen&& other) noexcept { move_from(other); } + PendingOpen& operator=(PendingOpen&& other) noexcept { + if (this != &other) { clear(); move_from(other); } + return *this; + } + ~PendingOpen() { clear(); } + + bool ready() const { return _ready; } + const std::string& address() const { return _address; } + const ExternalVector& request_data() const { return _request_data; } + bool has_request_data() const { return _has_request_data; } + int32_t restore_scroll() const { return _restore_scroll; } + void clear() noexcept { + clear_encoded_form(_request_data); + _address.clear(); + _current_scroll = 0; + _restore_scroll = -1; + _revision = 0; + _has_request_data = false; + _add_history = true; + _ready = false; + _operation = Operation::NONE; + } + + private: + friend class PageHistory; + enum class Operation : uint8_t { NONE, OPEN, BACK }; + + void move_from(PendingOpen& other) noexcept { + _address = std::move(other._address); + _request_data = std::move(other._request_data); + _current_scroll = other._current_scroll; + _restore_scroll = other._restore_scroll; + _revision = other._revision; + _has_request_data = other._has_request_data; + _add_history = other._add_history; + _ready = other._ready; + _operation = other._operation; + other._current_scroll = 0; + other._restore_scroll = -1; + other._revision = 0; + other._has_request_data = false; + other._ready = false; + other._operation = Operation::NONE; + } + + std::string _address; + ExternalVector _request_data; + int32_t _current_scroll = 0; + int32_t _restore_scroll = -1; + uint32_t _revision = 0; + bool _has_request_data = false; + bool _add_history = true; + bool _ready = false; + Operation _operation = Operation::NONE; + }; + const std::string& current() const { return _current.address; } int32_t current_scroll() const { return _current.logical_scroll; } bool current_has_request_data() const { return _current.has_request_data; } @@ -24,24 +86,84 @@ public: bool open(const std::string& address, bool add_history = true, int32_t current_logical_scroll = 0, const uint8_t* request_data = nullptr, std::size_t request_size = 0) { + PendingOpen pending; + return prepare_open(address, add_history, current_logical_scroll, + request_data, request_size, pending) && + commit(std::move(pending)); + } + + bool prepare_open(const std::string& address, bool add_history, + int32_t current_logical_scroll, + const uint8_t* request_data, std::size_t request_size, + PendingOpen& pending) const { if ((!request_data && request_size != 0) || request_size > FormState::MAX_ENCODED_BYTES) return false; - Entry next; + PendingOpen next; try { - next.address = address; + next._address = address; if (request_size != 0) { - next.request_data.assign(request_data, request_data + request_size); - next.has_request_data = true; + next._request_data.assign(request_data, request_data + request_size); + next._has_request_data = true; } } catch (const std::bad_alloc&) { return false; } - const bool next_has_request_data = next.has_request_data; - const bool same_request_data = next_has_request_data == _current.has_request_data && - (!next_has_request_data || next.request_data == _current.request_data); - if (address == _current.address && (!add_history || same_request_data)) { + next._current_scroll = current_logical_scroll; + next._add_history = add_history; + next._revision = _revision; + next._operation = PendingOpen::Operation::OPEN; + next._ready = true; + pending = std::move(next); + return true; + } + + bool prepare_back(PendingOpen& pending) const { + if (_depth == 0) return false; + PendingOpen next; + const Entry& prior = _entries[_depth - 1]; + try { + next._address = prior.address; + if (prior.has_request_data) { + next._request_data.assign(prior.request_data.begin(), prior.request_data.end()); + next._has_request_data = true; + } + } catch (const std::bad_alloc&) { + return false; + } + next._restore_scroll = prior.logical_scroll; + next._revision = _revision; + next._operation = PendingOpen::Operation::BACK; + next._ready = true; + pending = std::move(next); + return true; + } + + bool commit(PendingOpen&& pending) noexcept { + if (!pending._ready || pending._revision != _revision) return false; + if (pending._operation == PendingOpen::Operation::BACK) { + pending._ready = false; + pending._operation = PendingOpen::Operation::NONE; + _current = std::move(_entries[--_depth]); + ++_revision; + return true; + } + if (pending._operation != PendingOpen::Operation::OPEN) return false; + + Entry next; + next.address = std::move(pending._address); + next.request_data = std::move(pending._request_data); + next.has_request_data = pending._has_request_data; + const bool add_history = pending._add_history; + const int32_t current_logical_scroll = pending._current_scroll; + pending._ready = false; + pending._has_request_data = false; + pending._operation = PendingOpen::Operation::NONE; + const bool same_request_data = next.has_request_data == _current.has_request_data && + (!next.has_request_data || next.request_data == _current.request_data); + if (next.address == _current.address && (!add_history || same_request_data)) { next.logical_scroll = _current.logical_scroll; _current = std::move(next); + ++_revision; return true; } if (add_history && !_current.address.empty()) { @@ -54,21 +176,22 @@ public: _entries[_depth++] = std::move(_current); } _current = std::move(next); + ++_revision; return true; } void reload() {} bool back() { - if (_depth == 0) return false; - _current = std::move(_entries[--_depth]); - return true; + PendingOpen pending; + return prepare_back(pending) && commit(std::move(pending)); } void clear() { _current = Entry{}; for (std::size_t i = 0; i < _depth; ++i) _entries[i] = Entry{}; _depth = 0; + ++_revision; } private: @@ -78,8 +201,7 @@ private: Entry& operator=(const Entry&) = delete; Entry(Entry&& other) noexcept : address(std::move(other.address)), logical_scroll(other.logical_scroll), - request_data(std::move(other.request_data)), - has_request_data(other.has_request_data) { + request_data(std::move(other.request_data)), has_request_data(other.has_request_data) { other.logical_scroll = 0; other.has_request_data = false; } @@ -105,6 +227,7 @@ private: std::array _entries{}; Entry _current; std::size_t _depth = 0; + uint32_t _revision = 0; }; } // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp new file mode 100644 index 00000000..59845ad9 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp @@ -0,0 +1,98 @@ +#include "NomadNetOwner.h" + +#include +#include + +namespace UI::LXMF::NomadNet { + +uint32_t OwnerController::next_generation() { + ++_generation; + if (_generation == 0) ++_generation; + return _generation; +} + +OwnerCommand OwnerController::service(const UserAction& action, PageHistory& history, + OwnerSubmissionSource& source, + int32_t current_scroll) { + OwnerCommand command; + command.generation = next_generation(); + try { + if (action.kind == UserActionKind::SUBMIT) { + FormEncodeResult encoded = FormEncodeResult::INVALID_STATE; + if (!source.prepare_submission(action.item_id, action.generation, + command.target, command.request_data, encoded)) { + command.result = encoded == FormEncodeResult::ALLOCATION_FAILED + ? OwnerResult::ALLOCATION_FAILED : OwnerResult::INVALID_SUBMISSION; + return command; + } + if (!history.prepare_open(command.target, true, current_scroll, + command.request_data.data(), command.request_data.size(), + command.pending_history)) { + command.result = OwnerResult::HISTORY_FULL; + return command; + } + command.result = OwnerResult::REQUEST; + return command; + } + if (action.kind == UserActionKind::RELOAD) { + if (history.current().empty()) { + command.result = OwnerResult::BACK_EMPTY; + return command; + } + command.target = history.current(); + command.restore_scroll = -1; + command.cache_bypass = true; + } else if (action.kind == UserActionKind::BACK) { + if (history.depth() == 0) { + command.result = OwnerResult::BACK_EMPTY; + return command; + } + if (!history.prepare_back(command.pending_history)) { + command.result = OwnerResult::ALLOCATION_FAILED; + return command; + } + command.target = command.pending_history.address(); + command.restore_scroll = command.pending_history.restore_scroll(); + } else { + command.result = OwnerResult::NONE; + return command; + } + const bool pending_back = command.pending_history.ready(); + const bool has_saved = pending_back ? command.pending_history.has_request_data() + : history.current_has_request_data(); + if (has_saved) { + const auto& saved = pending_back ? command.pending_history.request_data() + : history.current_request_data(); + command.request_data.assign(saved.begin(), saved.end()); + } + command.result = OwnerResult::REQUEST; + return command; + } catch (const std::bad_alloc&) { + command.result = OwnerResult::ALLOCATION_FAILED; + command.target.clear(); + clear_encoded_form(command.request_data); + command.pending_history.clear(); + return command; + } +} + +bool OwnerController::retain_active_link(const std::string& requested_destination, + const std::string& link_owner_destination, + bool link_active) { + return link_active && !requested_destination.empty() && + requested_destination == link_owner_destination; +} + +const char* OwnerController::status(OwnerResult result) { + switch (result) { + case OwnerResult::INVALID_SUBMISSION: return "Form changed before submission"; + case OwnerResult::ALLOCATION_FAILED: return "Form submission exceeds available memory"; + case OwnerResult::HISTORY_FULL: return "Form history exceeds available memory"; + case OwnerResult::BACK_EMPTY: return "Enter a NomadNet address"; + case OwnerResult::NONE: + case OwnerResult::REQUEST: return "Requesting page..."; + } + return "NomadNet owner error"; +} + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetOwner.h b/lib/tdeck_ui/UI/LXMF/NomadNetOwner.h new file mode 100644 index 00000000..e83c9f25 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetOwner.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +#include "NomadNetActionMailbox.h" +#include "NomadNetHistory.h" + +namespace UI::LXMF::NomadNet { + +class OwnerSubmissionSource { +public: + virtual ~OwnerSubmissionSource() = default; + virtual bool prepare_submission(uint16_t item_id, uint32_t generation, + std::string& target, + ExternalVector& request_data, + FormEncodeResult& result) = 0; +}; + +enum class OwnerResult : uint8_t { + NONE, + REQUEST, + BACK_EMPTY, + INVALID_SUBMISSION, + ALLOCATION_FAILED, + HISTORY_FULL, +}; + +struct OwnerCommand { + OwnerResult result = OwnerResult::NONE; + std::string target; + ExternalVector request_data; + PageHistory::PendingOpen pending_history; + int32_t restore_scroll = -1; + uint32_t generation = 0; + bool add_history = false; + bool cache_bypass = false; + + OwnerCommand() = default; + OwnerCommand(const OwnerCommand&) = delete; + OwnerCommand& operator=(const OwnerCommand&) = delete; + OwnerCommand(OwnerCommand&& other) noexcept { move_from(other); } + OwnerCommand& operator=(OwnerCommand&& other) noexcept { + if (this != &other) { clear_encoded_form(request_data); pending_history.clear(); move_from(other); } + return *this; + } + ~OwnerCommand() { clear_encoded_form(request_data); } +private: + void move_from(OwnerCommand& other) noexcept { + result = other.result; + target = std::move(other.target); + request_data = std::move(other.request_data); + pending_history = std::move(other.pending_history); + restore_scroll = other.restore_scroll; + generation = other.generation; + add_history = other.add_history; + cache_bypass = other.cache_bypass; + other.result = OwnerResult::NONE; + other.restore_scroll = -1; + other.generation = 0; + other.add_history = false; + other.cache_bypass = false; + } +}; + +// Single production owner for SUBMIT/history/Back/Reload request-byte decisions. +// UIManager supplies UI effects; the native two-process client supplies a narrow +// form source, but both execute these exact transitions and status results. +class OwnerController { +public: + OwnerCommand service(const UserAction& action, PageHistory& history, + OwnerSubmissionSource& source, int32_t current_scroll); + static bool retain_active_link(const std::string& requested_destination, + const std::string& link_owner_destination, + bool link_active); + static const char* status(OwnerResult result); + +private: + uint32_t next_generation(); + uint32_t _generation = 0; +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetPageApplication.h b/lib/tdeck_ui/UI/LXMF/NomadNetPageApplication.h new file mode 100644 index 00000000..a8c1e93c --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetPageApplication.h @@ -0,0 +1,136 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "NomadNetDocument.h" +#include "NomadNetHistory.h" +#include "NomadNetLibrary.h" +#include "NomadNetUrl.h" + +namespace UI::LXMF::NomadNet { + +enum class LocalNavigationResult : uint8_t { + APPLIED, + STAGING_FAILED, + PREPARATION_FAILED, + PUBLICATION_FAILED, + SUPERSEDED, +}; + +// Shared production seam for same-resource navigation. OwnerController may have +// already staged BACK; in that case this function must not replace it with OPEN. +// Transport/canonical preparation happens before visible publication, and the +// staged history transition is committed only after publication succeeds. +template +LocalNavigationResult apply_local_navigation_transaction( + const std::string& canonical_address, bool add_history, + int32_t current_scroll, int32_t restore_scroll, bool history_prepared, + PageHistory& history, PageHistory::PendingOpen& pending, + PreparePublication&& prepare_publication, Publisher&& publish) noexcept { + try { + if (history_prepared) { + if (!pending.ready() || pending.address() != canonical_address) { + pending.clear(); + return LocalNavigationResult::SUPERSEDED; + } + } else { + const auto& request = history.current_request_data(); + const uint8_t* request_bytes = history.current_has_request_data() + ? request.data() : nullptr; + const std::size_t request_size = history.current_has_request_data() + ? request.size() : 0; + if (!history.prepare_open(canonical_address, add_history, current_scroll, + request_bytes, request_size, pending)) { + pending.clear(); + return LocalNavigationResult::STAGING_FAILED; + } + } + if (!prepare_publication()) { + pending.clear(); + return LocalNavigationResult::PREPARATION_FAILED; + } + if (!publish(canonical_address, restore_scroll)) { + pending.clear(); + return LocalNavigationResult::PUBLICATION_FAILED; + } + if (!history.commit(std::move(pending))) { + pending.clear(); + return LocalNavigationResult::SUPERSEDED; + } + return LocalNavigationResult::APPLIED; + } catch (const std::bad_alloc&) { + pending.clear(); + return LocalNavigationResult::STAGING_FAILED; + } +} + +enum class PageApplyResult : uint8_t { + APPLIED, + ALLOCATION_FAILED, + PUBLICATION_FAILED, +}; + +struct PagePublication { + const Library& library; + bool library_changed; + bool page_saved; + bool identify_enabled; + const std::string& unknown_anchor_status; +}; + +// The production application transaction. All heading/title/library preparation +// is staged, publication is attempted against the still-visible prior page, and +// durable owner state is finalized only after publication succeeds. +template +PageApplyResult apply_page_transaction(const Document& document, + const std::string& path, const std::string& url, + const std::string& destination_hex, const std::string& fragment, + uint64_t timestamp, Library& library, Publisher&& publish, + Finalizer&& finalize) noexcept { + try { + std::vector heading_runs; + for (const auto& block : document.blocks) { + if (block.type != BlockType::HEADING) continue; + heading_runs.reserve(block.runs.size()); + for (const auto& run : block.runs) heading_runs.push_back(run.text); + break; + } + const std::string title = page_title(path, heading_runs); + Library candidate = library; + const bool changed = candidate.record_page(url, title, timestamp); + const bool saved = candidate.page_saved(url); + const bool identified = candidate.node_identified(destination_hex); + const std::string anchor_status = fragment.empty() + ? std::string() : "Unknown anchor: #" + fragment; + const PagePublication publication{ + candidate, changed, saved, identified, anchor_status}; + if (!publish(publication)) return PageApplyResult::PUBLICATION_FAILED; + finalize(); + library = std::move(candidate); + return PageApplyResult::APPLIED; + } catch (const std::bad_alloc&) { + return PageApplyResult::ALLOCATION_FAILED; + } +} + +// Canonical URL construction can allocate. Keep it inside the same noexcept +// boundary as page staging so UIManager cannot leak bad_alloc before apply. +template +PageApplyResult apply_page_transaction_for_url(const Document& document, + const Url& url, uint64_t timestamp, Library& library, + Publisher&& publish, Finalizer&& finalize) noexcept { + try { + const std::string canonical_url = url.str(); + return apply_page_transaction(document, url.path, canonical_url, + url.destination_hex, url.fragment, timestamp, library, + std::forward(publish), std::forward(finalize)); + } catch (const std::bad_alloc&) { + return PageApplyResult::ALLOCATION_FAILED; + } +} + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index 3bc3dca8..d5e9f366 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -129,10 +129,18 @@ NomadNetScreen::~NomadNetScreen(){ if(_screen)lv_obj_del(_screen); } void NomadNetScreen::set_address(const std::string& value){ - lv_textarea_set_text(_address,value.c_str()); const auto summary=NomadNet::display_text(NomadNet::compact_address(value)); + lv_textarea_set_text(_address,value.c_str()); lv_label_set_text(_address_summary,summary.c_str()); } +bool NomadNetScreen::set_local_address(const std::string& value){ + // A local navigation changes only the fragment. The compact destination + // summary is therefore already correct. The patched textarea replacement + // is transactional; verify publication without allocating before changing + // scroll or committing the staged history transaction. + lv_textarea_set_text(_address,value.c_str()); + return std::strcmp(lv_textarea_get_text(_address),value.c_str())==0; +} std::string NomadNetScreen::address()const{return lv_textarea_get_text(_address);} void NomadNetScreen::set_library(const NomadNet::Library& library){ _library=library; @@ -174,6 +182,7 @@ void NomadNetScreen::clear_document(){ _logical_scroll=0; _layout_window_top=0; _layout_window_bottom=0; + _table_layout=TableLayoutObservation{}; _selected_link=-1; _selected_field=-1; _selected_focus=-1; @@ -374,57 +383,91 @@ void NomadNetScreen::set_address_editing(bool editing){ } } void NomadNetScreen::set_status(const char* value){ - const std::string status=value?value:""; - lv_label_set_text(_status,status.c_str()); - const bool loaded_ack=_page_loaded&&status.rfind("Page loaded",0)==0; + const char* status=value?value:""; + lv_label_set_text(_status,status); + const bool loaded_ack=_page_loaded&&std::strncmp(status,"Page loaded",11)==0; apply_browser_layout(!loaded_ack); } bool NomadNetScreen::set_page(const NomadNet::Document& document) { + // Prepare every heap-backed model before touching the published page. The + // temporary owns and releases partial capacities on every failure. + NomadNet::CompactPage candidate_page; + NomadNet::FormState candidate_form; + try { + if(!candidate_page.assign(document)||!candidate_form.assign(candidate_page)){ + set_status("Page is too large for available memory"); + return false; + } + if(candidate_page.truncated()){ + const std::string notice=NomadNet::truncation_notice(document); + if(!candidate_page.append_notice(notice)){ + set_status("Page truncation notice could not be retained"); + return false; + } + }else if(candidate_page.unsupported()&& + !candidate_page.append_notice("[Unsupported Micron content]")){ + set_status("Unsupported-content notice could not be retained"); + return false; + } + }catch(const std::bad_alloc&){ + set_status("Page is too large for available memory"); + return false; + } + + auto old_page=std::move(_page); + auto old_form=std::move(_form_state); + auto old_page_layout=std::move(_page_layout); + auto old_line_layout=std::move(_line_layout); + auto old_checkpoints=std::move(_layout_checkpoints); + auto old_link_y=std::move(_link_y); + auto old_link_bottom=std::move(_link_bottom); + auto old_field_y=std::move(_field_y); + auto old_field_bottom=std::move(_field_bottom); + auto old_focus=std::move(_focus_order); + const int32_t old_page_height=_page_height,old_physical_extent=_physical_extent; + const int32_t old_logical_scroll=_logical_scroll,old_window_top=_layout_window_top; + const int32_t old_window_bottom=_layout_window_bottom; + const TableLayoutObservation old_table_layout=_table_layout; + const int16_t old_selected_link=_selected_link,old_selected_field=_selected_field; + const int16_t old_selected_focus=_selected_focus; + const bool old_page_loaded=_page_loaded; + + _page=std::move(candidate_page); + _form_state=std::move(candidate_form); + bool laid_out=false; + try{laid_out=layout_page();}catch(const std::bad_alloc&){laid_out=false;} + if(!laid_out){ + _page=std::move(old_page);_form_state=std::move(old_form); + _page_layout=std::move(old_page_layout);_line_layout=std::move(old_line_layout); + _layout_checkpoints=std::move(old_checkpoints);_link_y=std::move(old_link_y); + _link_bottom=std::move(old_link_bottom);_field_y=std::move(old_field_y); + _field_bottom=std::move(old_field_bottom);_focus_order=std::move(old_focus); + _page_height=old_page_height;_physical_extent=old_physical_extent; + _logical_scroll=old_logical_scroll;_layout_window_top=old_window_top; + _layout_window_bottom=old_window_bottom;_table_layout=old_table_layout; + _selected_link=old_selected_link; + _selected_field=old_selected_field;_selected_focus=old_selected_focus; + _page_loaded=old_page_loaded; + set_status("Page viewport could not be retained"); + return false; + } + auto* group=LVGL::LVGLInit::get_default_group(); if(group)lv_group_remove_obj(_content); finish_field_edit(false); ++_form_generation; - _form_state.clear(); - if(!_page.assign(document) || !_form_state.assign(_page)) { - clear_document(); - set_status("Page is too large for available memory"); - return false; - } - try { - if(_page.truncated()){ - const std::string notice=NomadNet::truncation_notice(document); - if(!_page.append_notice(notice)){ - clear_document(); - set_status("Page truncation notice could not be retained"); - return false; - } - } - else if(_page.unsupported()&&!_page.append_notice("[Unsupported Micron content]")){ - clear_document(); - set_status("Unsupported-content notice could not be retained"); - return false; - } - show_browser(false); - if(!layout_page()){ - clear_document(); - set_status("Page viewport could not be retained"); - return false; - } - lv_obj_set_style_bg_color(_content,_page.has_background() - ?lv_color_hex(_page.background()):Theme::surface(),0); - _page_loaded=true; - lv_obj_scroll_to_y(_content,0,LV_ANIM_OFF); - lv_obj_refresh_self_size(_content); - lv_obj_invalidate(_content); - return true; - }catch(const std::bad_alloc&){ - clear_document(); - set_status("Page is too large for available memory"); - return false; - } + show_browser(false); + lv_obj_set_style_bg_color(_content,_page.has_background() + ?lv_color_hex(_page.background()):Theme::surface(),0); + _page_loaded=true; + lv_obj_scroll_to_y(_content,0,LV_ANIM_OFF); + lv_obj_refresh_self_size(_content); + lv_obj_invalidate(_content); + return true; } bool NomadNetScreen::layout_page(){ + _table_layout=TableLayoutObservation{}; _page_layout.clear(); _line_layout.clear(); _layout_checkpoints.clear(); @@ -774,15 +817,35 @@ bool NomadNetScreen::layout_table(const NomadNet::CompactPage::BlockRecord& bloc const int32_t metadata_width=std::min(content_width, static_cast(table.max_width)*space_width); const int32_t structural_minimum=static_cast(minimum_width)*table.column_count; - if(NomadNet::choose_table_layout(structural_minimum,content_width)== - NomadNet::TableLayoutTier::FIT){ + const int16_t minimum_readable_cell_width=static_cast(lv_txt_get_width( + "Readable",8,&nomadnet_font_12,0,LV_TEXT_FLAG_NONE)+8); + const auto tier=NomadNet::choose_table_layout( + structural_minimum,natural_width,content_width,table.column_count, + minimum_readable_cell_width); + const int32_t table_top=y; + if(tier!=NomadNet::TableLayoutTier::STACKED){ const int16_t target_width=static_cast(std::max( structural_minimum,std::min(metadata_width,content_width))); const int16_t table_fit_width=NomadNet::fit_table_columns( column_widths,table.column_count,minimum_width,target_width); - return layout_table_fit(table,column_widths,table_fit_width,y,window_top,window_bottom); + if(!layout_table_fit(table,column_widths,table_fit_width,y,window_top,window_bottom))return false; + if(!_table_layout.valid){ + const int16_t left=table.alignment==NomadNet::Alignment::CENTER? + static_cast((content_width-table_fit_width)/2): + table.alignment==NomadNet::Alignment::RIGHT? + static_cast(content_width-table_fit_width):0; + _table_layout={tier,left,table_top,table_fit_width,y-table_top, + table.column_count,0,true}; + } + return true; } - return layout_table_reflow(table,y,window_top,window_bottom); + if(!layout_table_reflow(table,y,window_top,window_bottom))return false; + if(!_table_layout.valid){ + const uint16_t rows=table.row_count>1?static_cast(table.row_count-1):1; + _table_layout={tier,0,table_top,content_width,y-table_top,0, + static_cast(rows*table.column_count),true}; + } + return true; } bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y, diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h index 4139c341..900b4828 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -30,6 +30,7 @@ public: void set_save_callback(SaveCallback cb) { _save = std::move(cb); } void set_identify_callback(IdentifyCallback cb) { _identify = std::move(cb); } void set_address(const std::string& address); + bool set_local_address(const std::string& address); std::string address() const; void set_status(const char* status); bool set_page(const NomadNet::Document& document); @@ -50,6 +51,25 @@ public: bool directory_visible() const { return _directory_visible.load(std::memory_order_acquire); } void show(); void hide(); private: + struct TableLayoutObservation { + NomadNet::TableLayoutTier tier = NomadNet::TableLayoutTier::FIT; + int16_t x = 0; + int32_t y = 0; + int16_t width = 0; + int32_t height = 0; + uint8_t columns = 0; + uint16_t cards = 0; + bool valid = false; + TableLayoutObservation() = default; + TableLayoutObservation(NomadNet::TableLayoutTier tier_value, + int16_t x_value, int32_t y_value, + int16_t width_value, int32_t height_value, + uint8_t columns_value, uint16_t cards_value, + bool valid_value) + : tier(tier_value), x(x_value), y(y_value), width(width_value), + height(height_value), columns(columns_value), cards(cards_value), + valid(valid_value) {} + }; // Only the visible region plus bounded overscan is retained. The parser // admits at most 1024 runs, so this also covers a pathological viewport // containing every styled run plus bounded dividers. @@ -120,6 +140,7 @@ private: int32_t _logical_scroll = 0; int32_t _layout_window_top = 0; int32_t _layout_window_bottom = 0; + TableLayoutObservation _table_layout; int16_t _selected_link = -1; int16_t _selected_field = -1; int16_t _selected_focus = -1; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 771d92fa..73de175b 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -963,16 +963,16 @@ void UIManager::replace_route(Route route) { } void UIManager::back() { - if (_navigation.current() == Route::NOMADNET && _nomad_history.back()) { - if (!nomad_restore_history_submission()) { - LVGL_LOCK(); - _nomadnet_screen->set_status("Saved form request exceeds available memory"); - return; - } - nomad_open(_nomad_history.current(), false, _nomad_history.current_scroll(), - _nomad_history.current_has_request_data()); + if (_navigation.current() == Route::NOMADNET) { + _nomad_actions.publish(NomadNet::UserActionKind::BACK, {}); return; } + LVGL_LOCK(); + if (!_navigation.back()) return; + render_route(_navigation.current()); +} + +void UIManager::nomad_back_empty() { if (_navigation.current() == Route::NOMADNET) { nomad_advance_navigation_generation(); bool handled = false; @@ -1949,6 +1949,18 @@ void UIManager::nomad_update_library() { } void UIManager::nomad_update_user_actions() { + class ScreenSubmissionSource final : public NomadNet::OwnerSubmissionSource { + public: + explicit ScreenSubmissionSource(NomadNetScreen& screen) : _screen(screen) {} + bool prepare_submission(uint16_t item_id, uint32_t generation, + std::string& target, NomadNet::ExternalVector& bytes, + NomadNet::FormEncodeResult& result) override { + LVGL_LOCK(); + return _screen.prepare_submission(item_id, generation, target, bytes, result); + } + private: + NomadNetScreen& _screen; + } source(*_nomadnet_screen); // Back/Home is a terminal slot behind at most CAPACITY retained explicit // Saves. Drain that bounded batch in one owner-loop pass so the terminal // action cannot sit behind the synchronous library persistence below. @@ -1965,35 +1977,29 @@ void UIManager::nomad_update_user_actions() { nomad_open(target); break; case NomadNet::UserActionKind::RELOAD: - // Use the exact current history entry and its retained request bytes; - // reload never appends history and always bypasses the cache once. - nomad_reload(); - break; + case NomadNet::UserActionKind::BACK: case NomadNet::UserActionKind::SUBMIT: { - std::string submit_target; - NomadNet::ExternalVector submission_data; - NomadNet::FormEncodeResult result = NomadNet::FormEncodeResult::INVALID_STATE; - bool prepared = false; - { + int32_t current_scroll = 0; + { LVGL_LOCK(); current_scroll = _nomadnet_screen->logical_scroll(); } + auto command = _nomad_owner.service(action, _nomad_history, source, current_scroll); + if (command.result != NomadNet::OwnerResult::REQUEST) { + if (action.kind == NomadNet::UserActionKind::BACK && + command.result == NomadNet::OwnerResult::BACK_EMPTY) { + nomad_back_empty(); + if (_navigation.current() != Route::NOMADNET) _nomad_actions.clear(); + break; + } LVGL_LOCK(); - prepared = _nomadnet_screen->prepare_submission( - action.item_id, action.generation, submit_target, - submission_data, result); - } - if (!prepared) { - LVGL_LOCK(); - _nomadnet_screen->set_status( - result == NomadNet::FormEncodeResult::INVALID_STATE - ? "Form changed before submission" - : result == NomadNet::FormEncodeResult::ALLOCATION_FAILED - ? "Form submission exceeds available memory" - : "Form submission exceeds device limits"); + _nomadnet_screen->set_status(NomadNet::OwnerController::status(command.result)); break; } NomadNet::clear_encoded_form(_nomad_submission_data); - _nomad_submission_data.swap(submission_data); - _nomad_submission_ready = true; - nomad_open(submit_target, true, -1, true); + _nomad_submission_data.swap(command.request_data); + _nomad_submission_ready = !_nomad_submission_data.empty(); + _nomad_pending_history = std::move(command.pending_history); + _nomad_cache_bypass_once = command.cache_bypass; + nomad_open(command.target, false, command.restore_scroll, + !_nomad_submission_data.empty(), true); break; } case NomadNet::UserActionKind::SAVE: { @@ -2031,10 +2037,7 @@ void UIManager::nomad_update_user_actions() { ? "Identity enabled for this node" : "Anonymous browsing enabled for this node"); break; } - case NomadNet::UserActionKind::BACK: - back(); - if (_navigation.current() != Route::NOMADNET) _nomad_actions.clear(); - break; + case NomadNet::UserActionKind::HOME: _nomad_actions.clear(); home(); @@ -2121,6 +2124,7 @@ void UIManager::nomad_finish_request_keep_link() { bool UIManager::nomad_stop_transport() { NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; + _nomad_pending_history.clear(); // Link teardown can synchronously cancel an in-flight response Resource, // update its shared RequestReceipt and invoke callbacks. Keep that mutation // in the Reticulum serialization domain, and let teardown mark the receipt @@ -2197,7 +2201,8 @@ bool UIManager::nomad_supersede_transport(const std::string& destination_hex) { } void UIManager::nomad_open(const std::string& address, bool add_history, - int32_t restore_logical_scroll, bool preserve_submission) { + int32_t restore_logical_scroll, bool preserve_submission, + bool history_prepared) { if (!preserve_submission) { NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; @@ -2205,10 +2210,22 @@ void UIManager::nomad_open(const std::string& address, bool add_history, nomad_heap_checkpoint("open-enter"); NomadNet::Url parsed; std::string error; - if (!NomadNet::Url::parse(address, parsed, error, _nomad_url.destination_hex, - _nomad_url.path,_nomad_url.fields)) { + bool parsed_ok = false; + try { + parsed_ok = NomadNet::Url::parse(address, parsed, error, + _nomad_url.destination_hex, _nomad_url.path, _nomad_url.fields); + } catch (const std::bad_alloc&) { NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; + _nomad_pending_history.clear(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Address exceeds available memory"); + return; + } + if (!parsed_ok) { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + _nomad_pending_history.clear(); LVGL_LOCK(); _nomadnet_screen->set_status(error.c_str()); return; @@ -2224,44 +2241,53 @@ void UIManager::nomad_open(const std::string& address, bool add_history, } const bool restoring_history=restore_logical_scroll>=0; if(!preserve_submission&&NomadNet::should_jump_locally(_nomad_url,parsed,page_loaded,restoring_history)){ - bool resolved=true; - { - LVGL_LOCK(); - if(restoring_history) - _nomadnet_screen->restore_logical_scroll(restore_logical_scroll); - else - resolved=_nomadnet_screen->jump_to_anchor(parsed.fragment); - if(!resolved&&parsed.fragment.empty())return; - if(!resolved){ - const std::string status="Unknown anchor: #"+parsed.fragment; - _nomadnet_screen->set_status(status.c_str()); + NomadNet::LocalNavigationResult local_result = + NomadNet::LocalNavigationResult::STAGING_FAILED; + try { + // Materialize every fallible canonical owner value before transport, + // visible scroll/address, or history state is changed. + const std::string canonical_address = parsed.str(); + NomadNet::Url next_url = parsed; + local_result = NomadNet::apply_local_navigation_transaction( + canonical_address, add_history, current_scroll, + restore_logical_scroll, history_prepared, + _nomad_history, _nomad_pending_history, + [&]() { + _nomad_cache_flow.cancel(); + return nomad_supersede_transport(parsed.destination_hex); + }, + [&](const std::string& published_address, int32_t restore_scroll) { + bool resolved = true; + LVGL_LOCK(); + if (!_nomadnet_screen->set_local_address(published_address)) + return false; + if (restore_scroll >= 0) + _nomadnet_screen->restore_logical_scroll(restore_scroll); + else + resolved = _nomadnet_screen->jump_to_anchor(parsed.fragment); + if (!resolved) return false; + _nomadnet_screen->set_status("Page loaded"); + return true; + }); + if (local_result == NomadNet::LocalNavigationResult::APPLIED) { + _nomad_url = std::move(next_url); + _nomad_pending_scroll = -1; + nomad_advance_navigation_generation(); + return; } + } catch (const std::bad_alloc&) { + _nomad_pending_history.clear(); + local_result = NomadNet::LocalNavigationResult::STAGING_FAILED; } - if(!resolved)return; - const auto& current_request = _nomad_history.current_request_data(); - const uint8_t* request_bytes = _nomad_history.current_has_request_data() - ? current_request.data() : nullptr; - const std::size_t request_size = _nomad_history.current_has_request_data() - ? current_request.size() : 0; - if (!_nomad_history.open(parsed.str(), add_history, current_scroll, - request_bytes, request_size)) { - LVGL_LOCK(); - _nomadnet_screen->set_status("Form history exceeds available memory"); - return; - } - nomad_advance_navigation_generation(); - _nomad_cache_flow.cancel(); - if (!nomad_supersede_transport(parsed.destination_hex)) { - LVGL_LOCK(); + LVGL_LOCK(); + if (local_result == NomadNet::LocalNavigationResult::PUBLICATION_FAILED && + !parsed.fragment.empty()) { + const std::string status = "Unknown anchor: #" + parsed.fragment; + _nomadnet_screen->set_status(status.c_str()); + } else if (local_result == NomadNet::LocalNavigationResult::PREPARATION_FAILED) { _nomadnet_screen->set_status("Navigation is busy; try again"); - return; - } - _nomad_url=parsed; - _nomad_pending_scroll=-1; - { - LVGL_LOCK(); - _nomadnet_screen->set_address(parsed.str()); - _nomadnet_screen->set_status("Page loaded"); + } else { + _nomadnet_screen->set_status("Form history exceeds available memory"); } return; } @@ -2272,8 +2298,9 @@ void UIManager::nomad_open(const std::string& address, bool add_history, ? _nomad_submission_data.data() : nullptr; const std::size_t history_request_size = _nomad_submission_ready ? _nomad_submission_data.size() : 0; - if (!_nomad_history.open(parsed.str(), add_history, current_scroll, - history_request, history_request_size)) { + if (!history_prepared && !_nomad_history.prepare_open(parsed.str(), add_history, + current_scroll, history_request, history_request_size, + _nomad_pending_history)) { NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; LVGL_LOCK(); @@ -2285,6 +2312,7 @@ void UIManager::nomad_open(const std::string& address, bool add_history, if (!nomad_supersede_transport(parsed.destination_hex)) { NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; + _nomad_pending_history.clear(); LVGL_LOCK(); _nomadnet_screen->set_status("Navigation is busy; try again"); return; @@ -2321,17 +2349,17 @@ void UIManager::nomad_begin_live_transport() { return; } { - // Cache lookup and Router contention are deliberately non-destructive. - // Clear the old model only after live transport admission owns Router. + // Transport admission is non-destructive. The old model remains visible + // until the complete replacement has been published successfully. LVGL_LOCK(); nomad_heap_checkpoint("action-before-navigation"); - _nomadnet_screen->begin_navigation(_nomad_url.str()); + _nomadnet_screen->set_address(_nomad_url.str()); nomad_heap_checkpoint("action-after-navigation"); } - const bool same_destination = _nomad_link && - _nomad_link.status() == Type::Link::ACTIVE && - _nomad_destination_hash && - _nomad_destination_hash.toHex() == _nomad_url.destination_hex; + const bool same_destination = NomadNet::OwnerController::retain_active_link( + _nomad_url.destination_hex, + _nomad_destination_hash ? _nomad_destination_hash.toHex() : std::string(), + _nomad_link && _nomad_link.status() == Type::Link::ACTIVE); if (same_destination) { _nomad_response.clear(); _nomad_request_policy.reset(); @@ -2367,37 +2395,6 @@ void UIManager::nomad_begin_live_transport() { } } -void UIManager::nomad_reload() { - if (_nomad_history.current().empty()) { - LVGL_LOCK(); - _nomadnet_screen->set_status("Enter a NomadNet address"); - return; - } - if (!nomad_restore_history_submission()) { - LVGL_LOCK(); - _nomadnet_screen->set_status("Saved form request exceeds available memory"); - return; - } - _nomad_cache_bypass_once = true; - nomad_open(_nomad_history.current(), false, -1, - _nomad_history.current_has_request_data()); -} - -bool UIManager::nomad_restore_history_submission() { - NomadNet::clear_encoded_form(_nomad_submission_data); - _nomad_submission_ready = false; - if (!_nomad_history.current_has_request_data()) return true; - try { - const auto& request = _nomad_history.current_request_data(); - _nomad_submission_data.assign(request.begin(), request.end()); - _nomad_submission_ready = true; - return true; - } catch (const std::bad_alloc&) { - NomadNet::clear_encoded_form(_nomad_submission_data); - return false; - } -} - void UIManager::nomad_start_link() { nomad_heap_checkpoint("link-enter"); Identity identity = Identity::recall(_nomad_destination_hash); @@ -2495,65 +2492,68 @@ void UIManager::nomad_send_request() { bool UIManager::nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached) { NomadNet::Document document; - try { - document = _nomad_parser.parse(reinterpret_cast(data), size); - } catch (const std::bad_alloc&) { + const auto parse_status = _nomad_parser.parse_into( + reinterpret_cast(data), size, document); + if (parse_status == NomadNet::ParseStatus::ALLOCATION_FAILED) { LVGL_LOCK(); _nomadnet_screen->set_status("Page is too large for available memory"); return false; } + if (parse_status == NomadNet::ParseStatus::INVALID_INPUT) { + LVGL_LOCK(); + _nomadnet_screen->set_status("Page input is invalid"); + return false; + } if (document.malformed && document.blocks.empty()) { LVGL_LOCK(); _nomadnet_screen->set_status("Page is not valid UTF-8/Micron"); return false; } - return nomad_apply_page_document(document, cached); + return nomad_apply_page_document(document, cached) == + NomadNet::PageApplyResult::APPLIED; } -bool UIManager::nomad_apply_page_document(const NomadNet::Document& document, bool cached) { - std::vector heading_runs; - for (const auto& block : document.blocks) { - if (block.type != NomadNet::BlockType::HEADING) continue; - for (const auto& run : block.runs) heading_runs.push_back(run.text); - break; - } - const std::string title = NomadNet::page_title(_nomad_url.path, heading_runs); - if (_nomad_library.record_page(_nomad_url.str(), title, - static_cast(Utilities::OS::time()))) _nomad_library_dirty = true; - const bool page_saved = _nomad_library.page_saved(_nomad_url.str()); - bool applied = false; - bool anchor_resolved = true; - { - LVGL_LOCK(); - if (cached) { - // Parsing and semantic admission succeeded while the prior page was - // still visible. Destructive replacement occurs only at publication. - _nomadnet_screen->begin_navigation(_nomad_url.str()); - } - _nomadnet_screen->set_library(_nomad_library); - applied = _nomadnet_screen->set_page(document); - if (applied && _nomad_pending_scroll >= 0) - _nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll); - else if (applied && _nomad_url.has_fragment) - anchor_resolved = _nomadnet_screen->jump_to_anchor(_nomad_url.fragment); - if (applied) { - _nomadnet_screen->set_page_saved(page_saved); - _nomadnet_screen->set_identify_enabled( - _nomad_library.node_identified(_nomad_url.destination_hex)); - if (!anchor_resolved && !_nomad_url.fragment.empty()) { - const std::string status = "Unknown anchor: #" + _nomad_url.fragment; - _nomadnet_screen->set_status(status.c_str()); - } else { +NomadNet::PageApplyResult UIManager::nomad_apply_page_document( + const NomadNet::Document& document, bool cached) { + bool library_changed = false; + const auto result = NomadNet::apply_page_transaction_for_url( + document, _nomad_url, static_cast(Utilities::OS::time()), + _nomad_library, + [&](const NomadNet::PagePublication& publication) { + bool anchor_resolved = true; + LVGL_LOCK(); + if (!_nomadnet_screen->set_page(document)) return false; + if (_nomad_pending_scroll >= 0) + _nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll); + else if (_nomad_url.has_fragment) + anchor_resolved = _nomadnet_screen->jump_to_anchor(_nomad_url.fragment); + _nomadnet_screen->set_page_saved(publication.page_saved); + _nomadnet_screen->set_identify_enabled(publication.identify_enabled); + if (!anchor_resolved && !publication.unknown_anchor_status.empty()) + _nomadnet_screen->set_status(publication.unknown_anchor_status.c_str()); + else _nomadnet_screen->set_status(cached ? "Cached page; current reachability not checked" : "Page loaded (live)"); - } - } + library_changed = publication.library_changed; + return true; + }, + [&]() noexcept { + if (_nomad_pending_history.ready()) + _nomad_history.commit(std::move(_nomad_pending_history)); + }); + if (result == NomadNet::PageApplyResult::ALLOCATION_FAILED) { + LVGL_LOCK(); + _nomadnet_screen->set_status("Page is too large for available memory"); } + if (result != NomadNet::PageApplyResult::APPLIED) { + _nomad_pending_history.clear(); + return result; + } + if (library_changed) _nomad_library_dirty = true; _nomad_pending_scroll = -1; - return applied; + return result; } - void UIManager::nomad_update() { // Filesystem chunks are owner-loop work and are always serviced before the // Router serialization domain. LVGL is taken only later to apply a complete, @@ -2683,7 +2683,8 @@ void UIManager::nomad_update() { _nomadnet_screen->set_status("Page is not valid UTF-8/Micron"); break; } - const bool page_applied = nomad_apply_page_document(document, false); + const bool page_applied = nomad_apply_page_document(document, false) == + NomadNet::PageApplyResult::APPLIED; nomad_heap_checkpoint("response-page-applied"); if (!page_applied) { _nomad_response.release(); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 6e3e8b91..78ca5995 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -20,6 +20,8 @@ #include "NomadNetMailbox.h" #include "NomadNetRequestPolicy.h" #include "NomadNetActionMailbox.h" +#include "NomadNetOwner.h" +#include "NomadNetPageApplication.h" #include "NomadNetLibrary.h" #include "NomadNetCacheFlow.h" #include "Hardware/TDeck/NomadNetStorageSD.h" @@ -410,6 +412,8 @@ private: NomadNet::DocumentParser _nomad_parser; NomadNet::ResponseBuffer _nomad_response; NomadNet::PageHistory _nomad_history; + NomadNet::PageHistory::PendingOpen _nomad_pending_history; + NomadNet::OwnerController _nomad_owner; NomadNet::AsyncMailbox _nomad_mailbox; NomadNet::ActionMailbox _nomad_actions; NomadNet::ExternalVector _nomad_submission_data; @@ -451,15 +455,16 @@ private: void hide_all_screens(); void nomad_open(const std::string& address, bool add_history = true, int32_t restore_logical_scroll = -1, - bool preserve_submission = false); - void nomad_reload(); - bool nomad_restore_history_submission(); + bool preserve_submission = false, + bool history_prepared = false); void nomad_update(); + void nomad_back_empty(); uint32_t nomad_advance_navigation_generation(); bool nomad_supersede_transport(const std::string& destination_hex); void nomad_begin_live_transport(); bool nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached); - bool nomad_apply_page_document(const NomadNet::Document& document, bool cached); + NomadNet::PageApplyResult nomad_apply_page_document( + const NomadNet::Document& document, bool cached); void nomad_start_link(); void nomad_identify_link_if_configured(); void nomad_send_request(); diff --git a/patch_lvgl_textarea.py b/patch_lvgl_textarea.py index fbe33ffc..befe6e3b 100644 --- a/patch_lvgl_textarea.py +++ b/patch_lvgl_textarea.py @@ -6,20 +6,31 @@ fields are remote input, so editor creation must return NULL to the checked application path instead of crashing under memory pressure. """ -pio_import = globals().get("Import") -if pio_import is None: - raise RuntimeError("PlatformIO Import helper is unavailable") -pio_import("env") -env = globals()["env"] - +import argparse import os import sys -sys.path.insert(0, env.get("PROJECT_DIR", ".")) -from _build_helpers import env_libdeps_dir +pio_import = globals().get("Import") +if pio_import is not None: + pio_import("env") + env = globals()["env"] + sys.path.insert(0, env.get("PROJECT_DIR", ".")) + from _build_helpers import env_libdeps_dir + + def lvgl_path(*parts): + return env_libdeps_dir(env, "lvgl", *parts) +else: + parser = argparse.ArgumentParser( + description="Apply only Pyxis' deterministic LVGL source hardening patch") + parser.add_argument("--lvgl-root", required=True) + args = parser.parse_args() + standalone_lvgl_root = os.path.abspath(args.lvgl_root) + + def lvgl_path(*parts): + return os.path.join(standalone_lvgl_root, *parts) -path = env_libdeps_dir(env, "lvgl", "src", "widgets", "lv_textarea.c") +path = lvgl_path("src", "widgets", "lv_textarea.c") if not os.path.exists(path): raise RuntimeError(f"LVGL textarea source not found: {path}") @@ -159,7 +170,7 @@ set_prefix_old = """ lv_textarea_t * ta = (lv_textarea_t *)obj; /*Clear the existing selection*/ lv_textarea_clear_selection(obj); """ -set_prefix_new = """ lv_textarea_t * ta = (lv_textarea_t *)obj; +set_prefix_intermediate = """ lv_textarea_t * ta = (lv_textarea_t *)obj; const bool pwd_set_characterwise = lv_textarea_get_accepted_chars(obj) || lv_textarea_get_max_length(obj); if(ta->pwd_mode) { /* Reserve password storage before mutating the label. Patched by Pyxis. */ @@ -172,6 +183,47 @@ set_prefix_new = """ lv_textarea_t * ta = (lv_textarea_t *)obj; /*Clear the existing selection*/ lv_textarea_clear_selection(obj); """ +set_prefix_new = """ lv_textarea_t * ta = (lv_textarea_t *)obj; + const uint32_t set_max_length = lv_textarea_get_max_length(obj); + const bool pwd_set_characterwise = lv_textarea_get_accepted_chars(obj) || + (set_max_length && _lv_txt_get_encoded_length(txt) > set_max_length); + if(ta->pwd_mode) { + /* Reserve password storage before mutating the label. Patched by Pyxis. */ + char * pwd_set_resized = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); + LV_ASSERT_MALLOC(pwd_set_resized); + if(pwd_set_resized == NULL) return; + ta->pwd_tmp = pwd_set_resized; + } + + /*Clear the existing selection*/ + lv_textarea_clear_selection(obj); +""" + +set_characterwise_old = """ if(lv_textarea_get_accepted_chars(obj) || lv_textarea_get_max_length(obj)) { + lv_label_set_text(ta->label, ""); + lv_textarea_set_cursor_pos(obj, LV_TEXTAREA_CURSOR_LAST); +""" +set_characterwise_new = """ if(pwd_set_characterwise) { + lv_label_set_text(ta->label, ""); + if(lv_label_get_text(ta->label)[0] != '\\0') return; + lv_textarea_set_cursor_pos(obj, LV_TEXTAREA_CURSOR_LAST); +""" + +set_direct_old = """ else { + lv_label_set_text(ta->label, txt); + lv_textarea_set_cursor_pos(obj, LV_TEXTAREA_CURSOR_LAST); + } + + /*If the textarea is empty, invalidate it to hide the placeholder*/ +""" +set_direct_new = """ else { + lv_label_set_text(ta->label, txt); + if(strcmp(lv_label_get_text(ta->label), txt) != 0) return; + lv_textarea_set_cursor_pos(obj, LV_TEXTAREA_CURSOR_LAST); + } + + /*If the textarea is empty, invalidate it to hide the placeholder*/ +""" set_final_old = """ if(ta->pwd_mode) { ta->pwd_tmp = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); LV_ASSERT_MALLOC(ta->pwd_tmp); @@ -256,7 +308,9 @@ for old, new, label in ( ((password_old, password_intermediate, password_pointer_new), password_new, "password backing realloc guard"), ((bulk_old, bulk_intermediate, bulk_pointer_new), bulk_new, "password bulk realloc guard"), (delete_old, delete_new, "password shrink realloc guard"), - (set_prefix_old, set_prefix_new, "password set preallocation guard"), + ((set_prefix_old, set_prefix_intermediate), set_prefix_new, "password set preallocation guard"), + (set_characterwise_old, set_characterwise_new, "constrained set clear guard"), + (set_direct_old, set_direct_new, "transactional constrained replacement guard"), ((set_final_old, set_final_intermediate), set_final_new, "password set mutation ordering guard"), (password_mode_old, password_mode_new, "password mode allocation guard"), ): @@ -303,7 +357,7 @@ def patch_lvgl_source(source_path, replacements): source.write(source_content) -obj_class_path = env_libdeps_dir(env, "lvgl", "src", "core", "lv_obj_class.c") +obj_class_path = lvgl_path("src", "core", "lv_obj_class.c") obj_parent_old = """ if(parent->spec_attr == NULL) { lv_obj_allocate_spec_attr(parent); } @@ -353,7 +407,7 @@ obj_parent_new = """ if(parent->spec_attr == NULL) { """ patch_lvgl_source(obj_class_path, ((obj_parent_old, obj_parent_new, "fallible child bookkeeping"),)) -label_path = env_libdeps_dir(env, "lvgl", "src", "widgets", "lv_label.c") +label_path = lvgl_path("src", "widgets", "lv_label.c") label_create_old = """ lv_obj_t * obj = lv_obj_class_create_obj(MY_CLASS, parent); lv_obj_class_init_obj(obj); return obj; @@ -529,7 +583,7 @@ textarea_label_init_new = """ ta->label = lv_obj_class_create_obj(&lv_label_c """ patch_lvgl_source(path, ((textarea_label_init_old, textarea_label_init_new, "usable label initialization"),)) -event_path = env_libdeps_dir(env, "lvgl", "src", "core", "lv_event.c") +event_path = lvgl_path("src", "core", "lv_event.c") event_add_old = """ lv_obj_allocate_spec_attr(obj); obj->spec_attr->event_dsc_cnt++; @@ -562,7 +616,7 @@ event_add_new = """ lv_obj_allocate_spec_attr(obj); """ patch_lvgl_source(event_path, ((event_add_old, event_add_new, "transactional event registration"),)) -group_path = env_libdeps_dir(env, "lvgl", "src", "core", "lv_group.c") +group_path = lvgl_path("src", "core", "lv_group.c") group_add_old = """ if(obj->spec_attr == NULL) lv_obj_allocate_spec_attr(obj); obj->spec_attr->group_p = group; diff --git a/platformio.ini b/platformio.ini index 571b7cbd..a0812c0f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -118,7 +118,7 @@ lib_deps = ; persist-only-after-preparation semantics, and serialized native bridge ; transport callbacks/conformance closure from microLXMF PR #9. https://github.com/torlando-tech/microLXMF.git#60fb7d6951bd17275da83fdd9581900d55b0a2ab - lvgl/lvgl@^8.3.11 + lvgl/lvgl@8.4.0 bblanchon/ArduinoJson@^7.4.2 hideakitai/MsgPack@^0.4.2 rweather/Crypto@^0.4.0 diff --git a/tests/native/fixtures/nomadnet_feature_matrix.mu b/tests/native/fixtures/nomadnet_feature_matrix.mu new file mode 100644 index 00000000..de6ab84e --- /dev/null +++ b/tests/native/fixtures/nomadnet_feature_matrix.mu @@ -0,0 +1,96 @@ +# NomadNet/Micron feature-conformance inventory (byte-exact UTF-8, LF only) +# canonical-commit: 89e3eea10c60d8fe597d36d2e091d5aab86bdfb8 +# canonical-source-sha256: nomadnet/ui/textui/MicronParser.py c4b40918fe813a7cfbb696f33df8a08451fd0156a6919a185b75225f52402ffb +# canonical-source-sha256: nomadnet/ui/textui/Browser.py b7bc37e0fd4e72261703a037ab1967ea4cc43b837dc1cd74f92a835bacab40a1 +# canonical-source-sha256: nomadnet/Node.py 2461a592b731cb1469bebb5ccc5f523892127881cb7a7e8ed586ac62a8c0c23a +# canonical-source-sha256: nomadnet/examples/various/input_fields.py c72e87170a3d833fc18b9c9d12299a4a056d8f46c4f8a43e3f872fc0b7b69dc9 +# BEGIN AUTHORITATIVE +# Verbatim byte-exact excerpts from nomadnet/examples/various/input_fields.py. +# source-range: lines 18-18 +# feature: custom-divider status: supported +-= +# source-range: lines 22-30 +# feature: canonical-input-and-submit status: supported +An input field : `B444``b + +An masked field : `B444``b + +An small field : `B444`<8|small`test>`b, and some more text. + +Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b + +The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. +# source-range: lines 34-44 +# feature: canonical-checkbox-radio status: supported +`B444``b Sign me up + +>> Radio group + +Select your favorite color: + +`B900`<^|color|Red`>`b Red + +`B090`<^|color|Green`>`b Green + +`B009`<^|color|Blue`>`b Blue +# source-range: lines 49-57 +# feature: canonical-submit-variants status: supported +You can `!`[submit`:/page/input_fields.mu`one|password|small|color]`! other fields, or just `!`[a single one`:/page/input_fields.mu`username]`!. + +Or simply `!`[submit them all`:/page/input_fields.mu`*]`!. + +Submission links can also `!`[include pre-configured variables`:/page/input_fields.mu`username|two|entitiy_id=4611|action=view]`!. + +Or take all fields and `!`[pre-configured variables`:/page/input_fields.mu`*|entitiy_id=4611|action=view]`!. + +Or only `!`[pre-configured variables`:/page/input_fields.mu`entitiy_id=4688|task=something]`! +# END AUTHORITATIVE +# BEGIN ADAPTED +# Focused vectors below are adapted from canonical syntax, not byte-exact excerpts. +# feature: default-divider status: supported +- +# feature: rgb-color status: supported +`F900RGB foreground`f and `B00ff00RGB background`b +# feature: grayscale-color status: supported +`Fg50Grayscale foreground`f and `Bg75Grayscale background`b +# feature: unknown-modifier status: supported +Unknown command is consumed: before`zafter +# feature: anchor status: supported +`:explicit-anchor +# feature: heading status: supported +>Primary heading +>>Secondary heading +# feature: table-delimiters status: supported +`tc80 +Name|Value +:---|---: +alpha|one +`t +# feature: field-text status: supported +`<24|username`Entered data> +# feature: field-password status: supported +` +# feature: field-checkbox status: supported +` +# feature: field-radio status: supported +`<^|color|red|*`Red> `<^|color|blue`Blue> +# feature: submit-named status: supported +`[Submit named`:/page/form.mu`username|password|color] +# feature: submit-wildcard status: supported +`[Submit all`:/page/form.mu`*] +# feature: submit-preconfigured status: supported +`[Submit configured`:/page/form.mu`username|action=view|entity_id=4611] +# feature: partial-descriptor status: future +`{:/page/partial.mu`5`username|pid=matrix} +# END ADAPTED +# BEGIN SYNTHETIC +# Synthetic adversarial cases are intentionally outside the canonical excerpt. +# More than eight separators must retain only a bounded deterministic prefix. +`tl304 +c0|c1|c2|c3|c4|c5|c6|c7|c8|||||||||||||||| +---|---|---|---|---|---|---|---|---|||||||||||||||| +v0|v1|v2|v3|v4|v5|v6|v7|v8|||||||||||||||| +`t +# A partial placeholder remains visible/degraded but is not accepted as support. +`{:/page/missing.mu`0.1`*|pid=synthetic} +# END SYNTHETIC diff --git a/tests/native/lvgl_oom/test_password_mode_oom.c b/tests/native/lvgl_oom/test_password_mode_oom.c index 7109eae3..09d0dcfa 100644 --- a/tests/native/lvgl_oom/test_password_mode_oom.c +++ b/tests/native/lvgl_oom/test_password_mode_oom.c @@ -1,6 +1,7 @@ #include #include #include +#include #include "lvgl.h" @@ -53,6 +54,17 @@ int main(void) { assert(lv_textarea_get_text(textarea) != NULL); assert(lv_textarea_get_text(textarea)[0] == '\0'); + /* A constrained non-password replacement must be atomic as a whole. A + * failed clear may not be followed by characterwise append, which would + * corrupt the visible browser address while Back remains uncommitted. */ + lv_textarea_set_max_length(textarea, 511); + lv_textarea_set_text(textarea, "node:/page/a.mu#details"); + assert(strcmp(lv_textarea_get_text(textarea), "node:/page/a.mu#details") == 0); + fail_allocations = true; + lv_textarea_set_text(textarea, "node:/page/a.mu"); + fail_allocations = false; + assert(strcmp(lv_textarea_get_text(textarea), "node:/page/a.mu#details") == 0); + lv_obj_del(textarea); return 0; } diff --git a/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt b/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt new file mode 100644 index 00000000..0d306d12 --- /dev/null +++ b/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.15) +project(nomadnet_lvgl_acceptance LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT PYXIS_ROOT OR NOT LVGL_SOURCE) + message(FATAL_ERROR "PYXIS_ROOT and LVGL_SOURCE are required") +endif() +set(LV_CONF_PATH "${CMAKE_CURRENT_LIST_DIR}/lv_conf.h" CACHE STRING "" FORCE) +set(LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +add_subdirectory("${LVGL_SOURCE}" lvgl) +set(NN "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF") +add_executable(nomadnet_lvgl_acceptance + acceptance.cpp + "${NN}/NomadNetDocument.cpp" + "${NN}/NomadNetCompactPage.cpp" + "${NN}/NomadNetForm.cpp" + "${NN}/NomadNetGlyphs.cpp" + "${NN}/NomadNetLibrary.cpp" + "${NN}/NomadNetScreen.cpp" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_12.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_12_bold.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_12_italic.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_12_bold_italic.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_16.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_16_bold.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_16_italic.c" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts/nomadnet_font_16_bold_italic.c" +) +target_include_directories(nomadnet_lvgl_acceptance PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/stubs" "${NN}" "${PYXIS_ROOT}/lib/tdeck_ui/UI" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts" + "${LVGL_SOURCE}" "${LVGL_SOURCE}/src") +target_compile_definitions(nomadnet_lvgl_acceptance PRIVATE ARDUINO=1) +target_compile_options(nomadnet_lvgl_acceptance PRIVATE -Wall -Wextra + -fsanitize=address,undefined -fno-omit-frame-pointer) +target_link_options(nomadnet_lvgl_acceptance PRIVATE -fsanitize=address,undefined) +target_link_libraries(nomadnet_lvgl_acceptance PRIVATE lvgl) diff --git a/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp b/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp new file mode 100644 index 00000000..3bb9f34a --- /dev/null +++ b/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp @@ -0,0 +1,352 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#define private public +#include "NomadNetScreen.h" +#undef private +#include "LVGL/LVGLInit.h" +#include "Theme.h" +#include "../../../lib/tdeck_ui/UI/Clipboard.h" + +namespace UI { +String Clipboard::_content; +bool Clipboard::_has_content = false; +} + +namespace { +lv_group_t* group = nullptr; +lv_disp_t* display = nullptr; +lv_indev_t* keyboard = nullptr; +std::vector framebuffer(320 * 240); + +struct KeyFeed { uint32_t key = 0; uint8_t phase = 0; } key_feed; + +void flush(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* colors) { + for (int y = area->y1; y <= area->y2; ++y) { + for (int x = area->x1; x <= area->x2; ++x) { + if (x >= 0 && x < 320 && y >= 0 && y < 240) + framebuffer[static_cast(y) * 320 + x] = *colors; + ++colors; + } + } + lv_disp_flush_ready(drv); +} + +void read_key(lv_indev_drv_t*, lv_indev_data_t* data) { + data->key = key_feed.key; + if (key_feed.phase == 2) { + data->state = LV_INDEV_STATE_PRESSED; + --key_feed.phase; + } else if (key_feed.phase == 1) { + data->state = LV_INDEV_STATE_RELEASED; + --key_feed.phase; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } + data->continue_reading = key_feed.phase != 0; +} + +void pump() { + for (int i = 0; i < 4; ++i) { + lv_tick_inc(35); + lv_timer_handler(); + } +} + +void dispatch_key(uint32_t key) { + key_feed = {key, 2}; + pump(); + assert(key_feed.phase == 0); +} + +void render() { + std::fill(framebuffer.begin(), framebuffer.end(), lv_color_hex(0xA55A5A)); + lv_obj_invalidate(lv_scr_act()); + pump(); +} + +bool same(lv_color_t left, lv_color_t right) { return left.full == right.full; } + +std::size_t count_color(int x1, int y1, int x2, int y2, lv_color_t color) { + x1 = std::max(0, x1); y1 = std::max(0, y1); + x2 = std::min(319, x2); y2 = std::min(239, y2); + std::size_t count = 0; + for (int y = y1; y <= y2; ++y) + for (int x = x1; x <= x2; ++x) + if (same(framebuffer[static_cast(y) * 320 + x], color)) ++count; + return count; +} + +struct DeleteAudit { + int deleted = 0; + bool empty = false; + bool password_disabled = false; +}; + +void audit_editor_delete(lv_event_t* event) { + if (lv_event_get_code(event) != LV_EVENT_DELETE) return; + auto* audit = static_cast(lv_event_get_user_data(event)); + auto* editor = lv_event_get_target(event); + const char* value = lv_textarea_get_text(editor); + ++audit->deleted; + audit->empty = value && value[0] == '\0'; + audit->password_disabled = !lv_textarea_get_password_mode(editor); +} + +bool editor_round_trip(UI::LXMF::NomadNetScreen& screen, lv_event_code_t completion, + uint32_t key, const char* replacement, bool expected_commit, + bool password, int& delete_events) { + lv_group_focus_obj(screen._content); + lv_group_set_editing(group, true); + screen._selected_focus = -1; + screen._selected_field = -1; + dispatch_key(LV_KEY_DOWN); + if (screen._selected_field < 0) return false; + if (password != (screen._page.fields()[screen._selected_field].type == + UI::LXMF::NomadNet::FormFieldType::PASSWORD)) { + dispatch_key(LV_KEY_DOWN); + } + const int16_t field = screen._selected_field; + const std::string before(screen._form_state.fields()[field].value.data(), + screen._form_state.fields()[field].value_length); + dispatch_key(LV_KEY_ENTER); + if (!screen._field_editor || lv_group_get_focused(group) != screen._field_editor) return false; + lv_textarea_set_text(screen._field_editor, replacement); + DeleteAudit audit; + lv_obj_add_event_cb(screen._field_editor, audit_editor_delete, LV_EVENT_DELETE, &audit); + if (completion != LV_EVENT_ALL) lv_event_send(screen._field_editor, completion, nullptr); + else dispatch_key(key); + pump(); + const std::string after(screen._form_state.fields()[field].value.data(), + screen._form_state.fields()[field].value_length); + delete_events += audit.deleted; + return screen._field_editor == nullptr && audit.deleted == 1 && audit.empty && + audit.password_disabled && lv_group_get_focused(group) == screen._content && + lv_group_get_editing(group) && + (expected_commit ? after == replacement : after == before); +} + +bool group_contains(lv_obj_t* object) { return object && lv_obj_get_group(object) == group; } +} // namespace + +namespace UI::LVGL { +bool LVGLInit::_initialized = true; +lv_disp_t* LVGLInit::_display = nullptr; +lv_indev_t* LVGLInit::_keyboard = nullptr; +lv_indev_t* LVGLInit::_touch = nullptr; +lv_indev_t* LVGLInit::_trackball = nullptr; +lv_group_t* LVGLInit::_default_group = nullptr; +TaskHandle_t LVGLInit::_task_handle = nullptr; +SemaphoreHandle_t LVGLInit::_mutex = nullptr; +bool LVGLInit::init() { return true; } +bool LVGLInit::init_display_only() { return true; } +void LVGLInit::task_handler() { lv_timer_handler(); } +bool LVGLInit::start_task(int, int) { return false; } +bool LVGLInit::is_task_running() { return false; } +SemaphoreHandle_t LVGLInit::get_mutex() { return nullptr; } +uint32_t LVGLInit::get_tick() { return lv_tick_get(); } +bool LVGLInit::is_initialized() { return true; } +void LVGLInit::set_theme(bool) {} +lv_disp_t* LVGLInit::get_display() { return display; } +lv_indev_t* LVGLInit::get_keyboard() { return keyboard; } +lv_indev_t* LVGLInit::get_touch() { return _touch; } +lv_indev_t* LVGLInit::get_trackball() { return _trackball; } +lv_group_t* LVGLInit::get_default_group() { return group; } +void LVGLInit::focus_widget(lv_obj_t* obj) { lv_group_add_obj(group, obj); lv_group_focus_obj(obj); } +} + +int main() { + lv_init(); + static lv_disp_draw_buf_t draw_buffer; + static lv_color_t draw_pixels[320 * 20]; + lv_disp_draw_buf_init(&draw_buffer, draw_pixels, nullptr, 320 * 20); + static lv_disp_drv_t driver; + lv_disp_drv_init(&driver); + driver.hor_res = 320; driver.ver_res = 240; + driver.draw_buf = &draw_buffer; driver.flush_cb = flush; + display = lv_disp_drv_register(&driver); + group = lv_group_create(); + lv_group_set_default(group); + static lv_indev_drv_t keyboard_driver; + lv_indev_drv_init(&keyboard_driver); + keyboard_driver.type = LV_INDEV_TYPE_KEYPAD; + keyboard_driver.read_cb = read_key; + keyboard = lv_indev_drv_register(&keyboard_driver); + lv_indev_set_group(keyboard, group); + + + const uint32_t baseline = lv_obj_get_child_cnt(lv_scr_act()); + bool fit_tier = false, fit_columns = false, reflow_tier = false, reflow_cards = false; + bool stacked_tier = false, stacked_cards = false, stacked_pixels = false; + bool stacked_scroll = false, stacked_objects = false; + bool focus_events = false, edge_scroll = false; + bool ready = false, cancel = false, enter = false, escape = false, focus_restore = false; + bool table_pixels = false, form_pixels = false, focus_pixels = false, glyph_pixels = false; + bool background_pixels = false, teardown = false; + int delete_events = 0; + + UI::LXMF::NomadNet::DocumentParser parser; + { + UI::LXMF::NomadNetScreen screen; + screen.show(); + auto fit_doc = parser.parse( + "`tc80\nName|Value\n:---|---:\nalpha|one\n`t\n" + "`<24|username`Initial> `\n" + "---\n`[Next`:/page/next.mu]\n`[Submit`:/page/form.mu`username]"); + assert(screen.set_page(fit_doc)); + lv_obj_update_layout(screen._screen); + const auto fit = screen._table_layout; + fit_tier = fit.valid && fit.tier == UI::LXMF::NomadNet::TableLayoutTier::FIT; + fit_columns = fit.columns == 2 && fit.cards == 0 && fit.x >= 0 && fit.width > 0 && + fit.x + fit.width <= 304 && fit.y >= 0 && fit.height > 0; + + auto reflow_doc = parser.parse( + "`tc304\nFirst very wide heading|Second very wide heading|Third very wide heading\n" + "---|---|---\nA long value that cannot fit beside peers|" + "Another long value that wraps repeatedly|Final long value\n`t"); + assert(screen.set_page(reflow_doc)); + lv_obj_update_layout(screen._screen); + const auto reflow = screen._table_layout; + reflow_tier = reflow.valid && reflow.tier == UI::LXMF::NomadNet::TableLayoutTier::REFLOW; + reflow_cards = reflow.columns == 3 && reflow.cards == 0 && reflow.width == 304 && + reflow.x == 0 && reflow.height > 22; + + auto stacked_doc = parser.parse( + "`tc304\nH1|H2|H3|H4|H5|H6|H7|H8\n" + "---|---|---|---|---|---|---|---\n" + "one|two|three|four|five|six|seven|`[eight`:/eight]\n`t"); + assert(screen.set_page(stacked_doc)); + lv_obj_update_layout(screen._screen); + const auto stacked = screen._table_layout; + stacked_tier = stacked.valid && stacked.tier == UI::LXMF::NomadNet::TableLayoutTier::STACKED; + stacked_cards = stacked.columns == 0 && stacked.cards == 8 && stacked.x == 0 && + stacked.width == 304 && stacked.height > 8 * 16 && + screen._page_layout.size() <= screen.MAX_WINDOW_FRAGMENTS; + render(); + lv_area_t stacked_content; + lv_obj_get_content_coords(screen._content, &stacked_content); + const int stacked_mid = (stacked_content.y1 + stacked_content.y2) / 2; + stacked_pixels = count_color(stacked_content.x1, stacked_content.y1, + stacked_content.x2, stacked_mid, + UI::LXMF::Theme::border()) > 10 && + count_color(stacked_content.x1, stacked_mid + 1, + stacked_content.x2, stacked_content.y2, + UI::LXMF::Theme::border()) > 10; + stacked_objects = lv_obj_get_child_cnt(screen._screen) <= 16; + lv_group_focus_obj(screen._content); + lv_group_set_editing(group, true); + screen._selected_focus = -1; + dispatch_key(LV_KEY_DOWN); + stacked_scroll = screen._selected_link >= 0 && screen.logical_scroll() > 0 && + lv_obj_get_scroll_y(screen._content) > 0; + + std::string edge_page = "`<24|username`Initial>\n"; + for (int i = 0; i < 70; ++i) edge_page += "viewport edge acceptance line\n"; + edge_page += "`[Bottom link`:/page/bottom.mu]\n"; + assert(screen.set_page(parser.parse(edge_page))); + lv_obj_update_layout(screen._screen); + lv_group_focus_obj(screen._edit_button); + lv_group_set_editing(group, false); + lv_obj_t* before_focus = lv_group_get_focused(group); + dispatch_key(LV_KEY_NEXT); + focus_events = before_focus != lv_group_get_focused(group) && + lv_group_get_focused(group) != nullptr; + lv_group_focus_obj(screen._content); + lv_group_set_editing(group, true); + const int32_t before_scroll = screen.logical_scroll(); + dispatch_key(LV_KEY_DOWN); + pump(); + edge_scroll = lv_group_get_focused(group) == screen._content && + screen._selected_link >= 0 && screen.logical_scroll() > before_scroll && + screen.logical_scroll() > 0 && lv_obj_get_scroll_y(screen._content) > 0; + + assert(screen.set_page(fit_doc)); + ready = editor_round_trip(screen, LV_EVENT_READY, 0, "ReadyValue", true, false, delete_events); + cancel = editor_round_trip(screen, LV_EVENT_CANCEL, 0, "Cancelled", false, false, delete_events); + enter = editor_round_trip(screen, LV_EVENT_ALL, LV_KEY_ENTER, "EnterValue", true, false, delete_events); + escape = editor_round_trip(screen, LV_EVENT_ALL, LV_KEY_ESC, "SecretChanged", false, true, delete_events); + focus_restore = ready && cancel && enter && escape && delete_events == 4 && + screen._field_editor == nullptr && lv_indev_get_obj_act() == nullptr; + + lv_group_focus_obj(screen._content); + lv_group_set_editing(group, true); + screen._selected_focus = -1; + dispatch_key(LV_KEY_DOWN); + render(); + lv_area_t content; + lv_obj_get_content_coords(screen._content, &content); + const auto observation = screen._table_layout; + const int tx = content.x1 + observation.x; + const int ty = content.y1 + static_cast(observation.y - screen.logical_scroll()); + const auto border = UI::LXMF::Theme::border(); + const auto surface = UI::LXMF::Theme::surface(); + table_pixels = count_color(tx, ty, tx + observation.width - 1, ty + observation.height - 1, + border) > 10 && + count_color(tx + 2, ty + 2, tx + observation.width - 3, + ty + observation.height - 3, UI::LXMF::Theme::textPrimary()) > 0; + background_pixels = same(framebuffer[static_cast(content.y2 - 2) * 320 + content.x2 - 2], + surface); + for (const auto& fragment : screen._page_layout) { + const int x1 = content.x1 + fragment.x; + const int y1 = content.y1 + screen._layout_window_top + fragment.y - screen.logical_scroll(); + if (fragment.field_index >= 0 && fragment.field_index == screen._selected_field) { + form_pixels = count_color(x1 + 2, y1 + 2, x1 + fragment.width - 3, + y1 + fragment.height - 3, UI::LXMF::Theme::surfaceInput()) > 4; + focus_pixels = count_color(x1, y1, x1 + fragment.width - 1, + y1 + fragment.height - 1, UI::LXMF::Theme::primary()) > 4; + } + if (fragment.divider) { + const std::size_t area = static_cast(fragment.width) * fragment.height; + glyph_pixels = area > count_color(x1, y1, x1 + fragment.width - 1, + y1 + fragment.height - 1, surface) + 2; + } + } + + bool back_called = false; + screen.set_back_callback([&] { back_called = true; screen.hide(); }); + lv_event_send(screen._back_button, LV_EVENT_CLICKED, nullptr); + teardown = back_called && lv_obj_has_flag(screen._screen, LV_OBJ_FLAG_HIDDEN) && + !group_contains(screen._content) && !group_contains(screen._field_editor) && + lv_group_get_focused(group) == nullptr && lv_indev_get_obj_act() == nullptr; + } + pump(); + teardown = teardown && lv_obj_get_child_cnt(lv_scr_act()) == baseline && + lv_group_get_focused(group) == nullptr; + { + UI::LXMF::NomadNetScreen screen; + screen.show(); + assert(screen.set_page(parser.parse("`\n"))); + bool home_called = false; + screen.set_home_callback([&] { home_called = true; screen.hide(); }); + lv_event_send(screen._home_button, LV_EVENT_CLICKED, nullptr); + teardown = teardown && home_called && lv_group_get_focused(group) == nullptr && + lv_indev_get_obj_act() == nullptr && !group_contains(screen._content); + } + pump(); + const uint32_t remaining = lv_obj_get_child_cnt(lv_scr_act()) - baseline; + teardown = teardown && remaining == 0 && lv_group_get_focused(group) == nullptr; + + lv_indev_delete(keyboard); + + lv_group_del(group); + std::printf( + "LVGL ACCEPT 320x240 fit_tier=%d fit_columns=%d reflow_tier=%d reflow_cards=%d " + "stacked_tier=%d stacked_cards=%d stacked_pixels=%d stacked_scroll=%d stacked_objects=%d " + "focus_events=%d edge_scroll=%d ready=%d cancel=%d enter=%d escape=%d focus_restore=%d " + "teardown=%d stale_group=%d background_pixels=%d table_pixels=%d form_pixels=%d " + "focus_pixels=%d glyph_pixels=%d exact_fonts=1 objects=%u\n", + fit_tier, fit_columns, reflow_tier, reflow_cards, stacked_tier, stacked_cards, + stacked_pixels, stacked_scroll, stacked_objects, focus_events, edge_scroll, + ready, cancel, enter, escape, focus_restore, teardown, 0, background_pixels, + table_pixels, form_pixels, focus_pixels, glyph_pixels, remaining); + return fit_tier && fit_columns && reflow_tier && reflow_cards && stacked_tier && + stacked_cards && stacked_pixels && stacked_scroll && stacked_objects && + focus_events && edge_scroll && + ready && cancel && enter && escape && focus_restore && teardown && background_pixels && + table_pixels && form_pixels && focus_pixels && glyph_pixels && remaining == 0 ? 0 : 1; +} diff --git a/tests/native/nomadnet_lvgl_acceptance/lv_conf.h b/tests/native/nomadnet_lvgl_acceptance/lv_conf.h new file mode 100644 index 00000000..d5b42e0d --- /dev/null +++ b/tests/native/nomadnet_lvgl_acceptance/lv_conf.h @@ -0,0 +1,14 @@ +#ifndef LV_CONF_H +#define LV_CONF_H +#define LV_COLOR_DEPTH 16 +#define LV_MEM_CUSTOM 0 +#define LV_USE_LOG 0 +#define LV_USE_ASSERT_NULL 1 +#define LV_USE_ASSERT_MALLOC 1 +#define LV_USE_FONT_COMPRESSED 1 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_USE_PERF_MONITOR 0 +#define LV_USE_MEM_MONITOR 0 +#endif diff --git a/tests/native/nomadnet_lvgl_acceptance/stubs/Arduino.h b/tests/native/nomadnet_lvgl_acceptance/stubs/Arduino.h new file mode 100644 index 00000000..5f2d8f4d --- /dev/null +++ b/tests/native/nomadnet_lvgl_acceptance/stubs/Arduino.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include +using SemaphoreHandle_t = void*; +using TaskHandle_t = void*; +class String { +public: + String() = default; + String(const char* value) : value_(value ? value : "") {} + std::size_t length() const { return value_.size(); } + const char* c_str() const { return value_.c_str(); } +private: + std::string value_; +}; diff --git a/tests/native/nomadnet_lvgl_acceptance/stubs/esp32-hal-psram.h b/tests/native/nomadnet_lvgl_acceptance/stubs/esp32-hal-psram.h new file mode 100644 index 00000000..0cce3888 --- /dev/null +++ b/tests/native/nomadnet_lvgl_acceptance/stubs/esp32-hal-psram.h @@ -0,0 +1,3 @@ +#pragma once +#include +inline void* ps_malloc(std::size_t bytes) { return std::malloc(bytes); } diff --git a/tests/native/nomadnet_x86_flow/BuildManifest.h.in b/tests/native/nomadnet_x86_flow/BuildManifest.h.in new file mode 100644 index 00000000..889ce920 --- /dev/null +++ b/tests/native/nomadnet_x86_flow/BuildManifest.h.in @@ -0,0 +1,5 @@ +#pragma once +#define PYXIS_MANIFEST_BASE "@PYXIS_MANIFEST_BASE@" +#define PYXIS_MANIFEST_BRANCH "@PYXIS_MANIFEST_BRANCH@" +#define PYXIS_MANIFEST_MICRORETICULUM "@PYXIS_MANIFEST_MICRORETICULUM@" +#define PYXIS_MANIFEST_SOURCES_JSON R"json(@PYXIS_MANIFEST_SOURCES_JSON@)json" diff --git a/tests/native/nomadnet_x86_flow/CMakeLists.txt b/tests/native/nomadnet_x86_flow/CMakeLists.txt index 7d7a6a58..90e2ccb5 100644 --- a/tests/native/nomadnet_x86_flow/CMakeLists.txt +++ b/tests/native/nomadnet_x86_flow/CMakeLists.txt @@ -27,8 +27,49 @@ target_compile_definitions(udp_interface PRIVATE set(PYXIS_NOMADNET_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../lib/tdeck_ui/UI/LXMF") set(PYXIS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../..") +set(PYXIS_MANIFEST_BASE "3658c54cc76bdc16e7a514ff008f2cb70c69e192") +set(PYXIS_MANIFEST_BRANCH "feat/nomadnet-limits-observability") +set(PYXIS_MANIFEST_MICRORETICULUM "cd0338e7fc07d3a7785a450656ba766491cbf6e8") +execute_process(COMMAND git rev-parse HEAD WORKING_DIRECTORY "${RNS_SOURCE}" + OUTPUT_VARIABLE _rns_commit OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _rns_git_result) +if(NOT _rns_git_result EQUAL 0 OR NOT _rns_commit STREQUAL PYXIS_MANIFEST_MICRORETICULUM) + message(FATAL_ERROR "RNS_SOURCE is not the attested microReticulum commit") +endif() +set(PYXIS_MANIFEST_FILES + "tests/native/nomadnet_x86_flow/CMakeLists.txt" + "tests/native/nomadnet_x86_flow/BuildManifest.h.in" + "tests/native/nomadnet_x86_flow/client.cpp" + "src/TCPClientInterface.cpp" + "src/TCPClientInterface.h" + "lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetOwner.h" + "lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h" + "lib/tdeck_ui/UI/LXMF/NomadNetHistory.h" + "lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetDocument.h" + "lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h" + "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.h" + "lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetForm.h" + "lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h" + "lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetUrl.h") +set(PYXIS_MANIFEST_SOURCES_JSON "{") +set(_manifest_separator "") +foreach(_relative IN LISTS PYXIS_MANIFEST_FILES) + file(SHA256 "${PYXIS_ROOT}/${_relative}" _digest) + string(APPEND PYXIS_MANIFEST_SOURCES_JSON "${_manifest_separator}\"${_relative}\":\"${_digest}\"") + set(_manifest_separator ",") +endforeach() +string(APPEND PYXIS_MANIFEST_SOURCES_JSON "}") +configure_file(BuildManifest.h.in "${CMAKE_CURRENT_BINARY_DIR}/BuildManifest.h" @ONLY) add_executable(pyxis_nomadnet_x86_flow client.cpp + "${PYXIS_NOMADNET_DIR}/NomadNetOwner.cpp" "${PYXIS_ROOT}/src/TCPClientInterface.cpp" "${PYXIS_NOMADNET_DIR}/NomadNetDocument.cpp" "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp" @@ -38,6 +79,7 @@ add_executable(pyxis_nomadnet_x86_flow "${PYXIS_NOMADNET_DIR}/NomadNetUrl.cpp" ) target_include_directories(pyxis_nomadnet_x86_flow PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}" "${PYXIS_NOMADNET_DIR}" "${PYXIS_ROOT}/src" ) diff --git a/tests/native/nomadnet_x86_flow/client.cpp b/tests/native/nomadnet_x86_flow/client.cpp index e404cb01..0123b2d9 100644 --- a/tests/native/nomadnet_x86_flow/client.cpp +++ b/tests/native/nomadnet_x86_flow/client.cpp @@ -8,10 +8,13 @@ #include "NomadNetDocument.h" #include "NomadNetCompactPage.h" #include "NomadNetForm.h" +#include "NomadNetHistory.h" #include "NomadNetLibrary.h" #include "NomadNetMailbox.h" +#include "NomadNetOwner.h" #include "NomadNetProtocol.h" #include "NomadNetUrl.h" +#include "BuildManifest.h" #include #include @@ -51,6 +54,42 @@ static bool pending_empty = false; static bool link_closed = false; static int link_callbacks = 0; static int reuse_requests = 0; +static NN::PageHistory owner_history; +static NN::PageHistory::PendingOpen owner_pending_history; +static int owner_requests = 0; +static bool owner_submit = false; +static bool owner_history_bytes = false; +static bool owner_retained_link = false; +static bool owner_back_restored = false; +static bool owner_reload_reused = false; +static NN::ExternalVector owner_first_request; +static NN::OwnerController owner; + +class ScreenSubmissionSource final : public NN::OwnerSubmissionSource { +public: + explicit ScreenSubmissionSource(const char* value) : _value(value) {} + bool prepare_submission(uint16_t, uint32_t, std::string& target, + NN::ExternalVector& output, + NN::FormEncodeResult& result) override { + NN::DocumentParser parser; + const auto document = parser.parse( + "` ` " + "` ` " + "`[:/page/form.mu`name|password|color|fixed=yes`Submit]"); + NN::CompactPage page; + NN::FormState state; + if (!page.assign(document) || !state.assign(page) || + !state.set_value(0, _value) || !state.set_value(1, "example-pass")) { + result = NN::FormEncodeResult::ALLOCATION_FAILED; + return false; + } + result = state.encode("name|password|color|fixed=yes", output); + target = destination_hex + ":/page/form.mu"; + return result == NN::FormEncodeResult::OK; + } +private: + const char* _value; +}; static bool prepare_form_request(NN::ExternalVector& output) { NN::DocumentParser parser; @@ -89,7 +128,8 @@ static bool validate_page(const RNS::Bytes& response, bool expect_large) { fail("Micron parse"); return false; } - const bool form_scenario = scenario == "form-anonymous" || scenario == "form-identified"; + const bool form_scenario = scenario == "form-anonymous" || scenario == "form-identified" || + scenario == "owner-form-history"; const char* marker = form_scenario ? "Form response" : (expect_large ? "Resource-backed page" : "Immediate page"); std::string lan_heading; @@ -147,6 +187,7 @@ static bool validate_page(const RNS::Bytes& response, bool expect_large) { // Exercise the Save -> Back ordering used by the owner loop. Save remains // durable work, but the terminal action is guaranteed to follow it. + if (scenario == "owner-form-history") return true; if (!actions.publish(NN::UserActionKind::SAVE, url) || !actions.publish(NN::UserActionKind::BACK, "")) { fail("Save/Back publication"); @@ -217,7 +258,42 @@ static void on_link_closed(RNS::Link& closed_link) { } } +static bool owner_request_data(const NN::ExternalVector& request_data, + RNS::Link* established = nullptr) { + RNS::Link& request_link = established ? *established : active_link; + receipt = request_link.request(RNS::Bytes("/page/form.mu"), + RNS::Bytes(request_data.data(), request_data.size()), on_response, on_failed, + on_progress, 8.0, NN::AsyncMailbox::MAX_WIRE_BYTES); + if (!receipt) return false; + mailbox.expect_request(bytes_vector(receipt.get_request_id())); + request_started = true; + request_started_at = RNS::Utilities::OS::time(); + ++owner_requests; + return true; +} + +static bool owner_request_current(RNS::Link* established = nullptr) { + return owner_request_data(owner_history.current_request_data(), established); +} + +static bool service_submit_through_owner(const char* name, RNS::Link* established = nullptr) { + NN::UserAction action; + if (!actions.pop(action) || action.kind != NN::UserActionKind::SUBMIT) return false; + ScreenSubmissionSource screen(name); + auto command = owner.service(action, owner_history, screen, 0); + if (command.result != NN::OwnerResult::REQUEST) return false; + owner_submit = true; + if (owner_requests == 0) owner_first_request.assign( + command.request_data.begin(), command.request_data.end()); + owner_pending_history = std::move(command.pending_history); + return owner_pending_history.ready() && owner_request_data(command.request_data, established); +} + static void on_link_established(RNS::Link& established_link) { + // Link construction invokes this callback before the assigning expression + // completes; take the established production handle before owner requests. + active_link = established_link; + link_established = true; ++link_callbacks; mailbox.begin(bytes_vector(established_link.link_id())); @@ -231,14 +307,21 @@ static void on_link_established(RNS::Link& established_link) { else if (scenario == "near-limit") path = "/page/near-limit.mu"; else if (scenario == "oversized") path = "/page/oversized.mu"; else if (scenario == "cancel") path = "/page/cancel.mu"; - else if (scenario == "form-anonymous" || scenario == "form-identified") + else if (scenario == "form-anonymous" || scenario == "form-identified" || + scenario == "owner-form-history") path = "/page/form.mu"; else { path = "/page/missing.mu"; timeout = 1.5; } NN::ExternalVector request_data; - if (scenario == "form-anonymous" || scenario == "form-identified") { + if (scenario == "owner-form-history") { + if (!actions.publish_submit(0, 1) || + !service_submit_through_owner("Example User", &established_link)) { + fail("production-owner initial Submit"); + } + return; + } else if (scenario == "form-anonymous" || scenario == "form-identified") { if (!prepare_form_request(request_data)) { fail("form request encoding"); return; @@ -310,6 +393,67 @@ static void consume_event() { } passed = validate_page(response, expected_resource); if (!passed) return; + if (scenario == "owner-form-history") { + if (owner_pending_history.ready()) { + if (!owner_history.commit(std::move(owner_pending_history))) { + fail("production-owner response history commit"); + return; + } + owner_history_bytes = owner_history.current_has_request_data(); + } + const bool retained_owner = NN::OwnerController::retain_active_link( + destination_hex, destination_hex, + active_link && active_link.status() == RNS::Type::Link::ACTIVE); + if (owner_requests < 3 && + (!retained_owner || !active_link.pending_requests().empty())) { + fail("owner response did not retain active Link"); + return; + } + if (owner_requests < 3) owner_retained_link = true; + if (owner_requests == 1) { + mailbox.prepare(); + if (!actions.publish_submit(0, 2) || + !service_submit_through_owner("Changed User")) { + fail("production-owner changed Submit"); + } + break; + } + if (owner_requests == 2) { + NN::UserAction back_action; + back_action.kind = NN::UserActionKind::BACK; + ScreenSubmissionSource unused("unused"); + auto back_command = owner.service(back_action, owner_history, unused, 0); + if (back_command.result != NN::OwnerResult::REQUEST || + !back_command.pending_history.ready()) { + fail("production-owner Back history restore"); + return; + } + if (back_command.request_data != owner_first_request) { + fail("production-owner Back exact request bytes"); + return; + } + if (!owner_history.commit(std::move(back_command.pending_history)) || + owner_history.current_request_data() != owner_first_request) { + fail("production-owner Back publication commit"); + return; + } + owner_back_restored = true; + NN::UserAction reload_action; + reload_action.kind = NN::UserActionKind::RELOAD; + auto reload_command = owner.service(reload_action, owner_history, unused, 0); + mailbox.prepare(); + owner_reload_reused = reload_command.result == NN::OwnerResult::REQUEST && + reload_command.request_data == owner_first_request && owner_request_current(); + if (!owner_reload_reused) fail("production-owner Reload request"); + break; + } + if (owner_requests != 3 || link_callbacks != 1) { + fail("production-owner retained-Link Reload invariants"); + return; + } + completed = true; + break; + } if (scenario == "reuse" && reuse_requests == 1) { if (!active_link || active_link.status() != RNS::Type::Link::ACTIVE || !active_link.pending_requests().empty()) { @@ -398,15 +542,23 @@ static bool cleanup_complete() { } int main(int argc, char** argv) { + if (argc == 2 && std::strcmp(argv[1], "--manifest") == 0) { + std::printf("{\"schema\":1,\"base\":\"%s\",\"branch\":\"%s\"," + "\"microreticulum\":\"%s\",\"sources\":%s}\n", + PYXIS_MANIFEST_BASE, PYXIS_MANIFEST_BRANCH, + PYXIS_MANIFEST_MICRORETICULUM, PYXIS_MANIFEST_SOURCES_JSON); + return 0; + } if (argc < 2) { - std::fprintf(stderr, "usage: %s immediate|resource|near-limit|oversized|timeout|cancel|reuse|form-anonymous|form-identified | lan host port destination\n", argv[0]); + std::fprintf(stderr, "usage: %s immediate|resource|near-limit|oversized|timeout|cancel|reuse|form-anonymous|form-identified|owner-form-history | lan host port destination\n", argv[0]); return 2; } scenario = argv[1]; if (scenario != "immediate" && scenario != "resource" && scenario != "near-limit" && scenario != "oversized" && scenario != "timeout" && scenario != "cancel" && scenario != "reuse" && - scenario != "form-anonymous" && scenario != "form-identified" && scenario != "lan") return 2; + scenario != "form-anonymous" && scenario != "form-identified" && + scenario != "owner-form-history" && scenario != "lan") return 2; if ((scenario == "lan" && argc != 5) || (scenario != "lan" && argc != 2)) return 2; microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem(".")}; @@ -473,7 +625,8 @@ int main(int argc, char** argv) { cleanup_complete(); std::printf("RESULT scenario=%s announce=%d path=%d link=%d request=%d progress=%d callbacks=%d " "cancel=%d deadline=%d resource_started=%d resource_progress=%d receipt_failed=%d " - "pending=%zu link_closed=%d stale_rejected=%d reuse_requests=%d link_callbacks=%d passed=%d\n", + "pending=%zu link_closed=%d stale_rejected=%d reuse_requests=%d link_callbacks=%d " + "owner_submit=%d history_bytes=%d retained_link=%d back_restored=%d reload_reused=%d passed=%d\n", scenario.c_str(), announce_seen ? 1 : 0, destination_hex.empty() ? 0 : 1, link_established ? 1 : 0, request_started ? 1 : 0, progress_seen ? 1 : 0, progress_callbacks, @@ -482,7 +635,9 @@ int main(int argc, char** argv) { receipt_failed ? 1 : 0, active_link ? active_link.pending_requests().size() : 0, link_closed ? 1 : 0, stale_callback_rejections, reuse_requests, link_callbacks, - passed ? 1 : 0); + owner_submit ? 1 : 0, owner_history_bytes ? 1 : 0, + owner_retained_link ? 1 : 0, owner_back_restored ? 1 : 0, + owner_reload_reused ? 1 : 0, passed ? 1 : 0); if (scenario == "lan") { std::printf("LAN TRANSPORT rx=%zu rxbytes=%zu tx=%zu txbytes=%zu\n", network_interface.rx(), network_interface.rxbytes(), diff --git a/tests/native/nomadnet_x86_flow/run_flow.py b/tests/native/nomadnet_x86_flow/run_flow.py index f6e4d4d6..ae015f5f 100644 --- a/tests/native/nomadnet_x86_flow/run_flow.py +++ b/tests/native/nomadnet_x86_flow/run_flow.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import ast import hashlib +import json +import os import subprocess import sys import tempfile @@ -10,9 +12,17 @@ from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[3] SERVER = Path(__file__).with_name("server.py") -CLIENT = Path(sys.argv[1]) -PYTHON = Path(sys.argv[2]) -NOMADNET_SOURCE = Path(sys.argv[3]) +VERIFY_REFERENCE_ONLY = len(sys.argv) == 3 and sys.argv[1] == "--verify-reference" +if VERIFY_REFERENCE_ONLY: + CLIENT = PYTHON = Path("/dev/null") + NOMADNET_SOURCE = Path(sys.argv[2]) +elif len(sys.argv) == 4: + CLIENT = Path(sys.argv[1]) + PYTHON = Path(sys.argv[2]) + NOMADNET_SOURCE = Path(sys.argv[3]) +else: + raise SystemExit( + "usage: run_flow.py CLIENT RNS_PYTHON NOMADNET_SOURCE | --verify-reference SOURCE") NOMADNET_COMMIT = "89e3eea10c60d8fe597d36d2e091d5aab86bdfb8" NOMADNET_VERSION = "1.2.8" REFERENCE_FILES = { @@ -22,9 +32,70 @@ REFERENCE_FILES = { "c4b40918fe813a7cfbb696f33df8a08451fd0156a6919a185b75225f52402ffb", "nomadnet/ui/textui/Browser.py": "b7bc37e0fd4e72261703a037ab1967ea4cc43b837dc1cd74f92a835bacab40a1", + "nomadnet/Node.py": + "2461a592b731cb1469bebb5ccc5f523892127881cb7a7e8ed586ac62a8c0c23a", } SCENARIOS = ("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", - "form-anonymous", "form-identified") + "form-anonymous", "form-identified", "owner-form-history") +if os.environ.get("PYXIS_FLOW_SCENARIOS"): + requested = tuple(item.strip() for item in os.environ["PYXIS_FLOW_SCENARIOS"].split(",") if item.strip()) + if not requested or any(item not in SCENARIOS for item in requested): + raise SystemExit("invalid PYXIS_FLOW_SCENARIOS") + SCENARIOS = requested + +MANIFEST_BASE = "3658c54cc76bdc16e7a514ff008f2cb70c69e192" +MANIFEST_BRANCH = "feat/nomadnet-limits-observability" +MANIFEST_MICRORETICULUM = "cd0338e7fc07d3a7785a450656ba766491cbf6e8" +MANIFEST_FILES = ( + "tests/native/nomadnet_x86_flow/CMakeLists.txt", + "tests/native/nomadnet_x86_flow/BuildManifest.h.in", + "tests/native/nomadnet_x86_flow/client.cpp", + "src/TCPClientInterface.cpp", + "src/TCPClientInterface.h", + "lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetOwner.h", + "lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h", + "lib/tdeck_ui/UI/LXMF/NomadNetHistory.h", + "lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetDocument.h", + "lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h", + "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.h", + "lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetForm.h", + "lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h", + "lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetUrl.h", +) + + +def verify_client_manifest() -> None: + try: + result = subprocess.run([str(CLIENT), "--manifest"], capture_output=True, + text=True, timeout=10, check=True) + manifest = json.loads(result.stdout) + except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + raise SystemExit(f"client manifest unavailable: {error}") from error + expected_sources = { + relative: hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() + for relative in MANIFEST_FILES + } + expected = { + "schema": 1, + "base": MANIFEST_BASE, + "branch": MANIFEST_BRANCH, + "microreticulum": MANIFEST_MICRORETICULUM, + "sources": expected_sources, + } + if manifest != expected: + raise SystemExit("client manifest mismatch: stale or wrong external binary") + print(f"CLIENT MANIFEST base={MANIFEST_BASE} microReticulum={MANIFEST_MICRORETICULUM}: PASS") + + +if not VERIFY_REFERENCE_ONLY: + verify_client_manifest() for relative, expected_hash in REFERENCE_FILES.items(): path = NOMADNET_SOURCE / relative @@ -42,7 +113,16 @@ version = next( ) if version != NOMADNET_VERSION: raise SystemExit(f"wrong NomadNet reference version: {version}") -print(f"REFERENCE NomadNet {NOMADNET_COMMIT}") +if (NOMADNET_SOURCE / ".git").exists(): + reference_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=NOMADNET_SOURCE, + capture_output=True, text=True, check=True, + ).stdout.strip() + if reference_commit != NOMADNET_COMMIT: + raise SystemExit(f"wrong NomadNet reference commit: {reference_commit}") + print(f"REFERENCE NomadNet Git {NOMADNET_COMMIT} hash-pinned") +else: + print(f"REFERENCE NomadNet package {NOMADNET_VERSION} hash-pinned") def run_browser_oracle(source: Path) -> None: @@ -128,6 +208,10 @@ def run_browser_oracle(source: Path) -> None: run_browser_oracle(NOMADNET_SOURCE / "nomadnet/ui/textui/Browser.py") +if VERIFY_REFERENCE_ONLY: + print("REFERENCE PROVENANCE: PASS") + raise SystemExit(0) + failed = False for scenario in SCENARIOS: run_dir = Path(tempfile.mkdtemp(prefix=f"pyxis-nomadnet-flow-{scenario}-")) @@ -161,7 +245,7 @@ for scenario in SCENARIOS: ok &= "anonymous=True" in server_text if scenario == "form-identified": ok &= "anonymous=False" in server_text - if scenario in ("form-anonymous", "form-identified"): + if scenario in ("form-anonymous", "form-identified", "owner-form-history"): ok &= "SERVER PASS exact form request data" in server_text if scenario == "timeout": ok &= all(marker in client_text for marker in ( @@ -180,6 +264,12 @@ for scenario in SCENARIOS: )) ok &= server_text.count("SERVER request count=") == 2 ok &= "SERVER PASS reused one Link for two anonymous requests" in server_text + if scenario == "owner-form-history": + ok &= all(marker in client_text for marker in ( + "callbacks=3", "pending=0", "link_callbacks=1", "owner_submit=1", + "history_bytes=1", "retained_link=1", "back_restored=1", "reload_reused=1", + )) + ok &= server_text.count("SERVER request count=") == 3 print(f"SCENARIO {scenario}: {'PASS' if ok else 'FAIL'} server={server_rc} client={client_rc}") failed |= not ok diff --git a/tests/native/nomadnet_x86_flow/server.py b/tests/native/nomadnet_x86_flow/server.py index 1290e033..90efdf3e 100644 --- a/tests/native/nomadnet_x86_flow/server.py +++ b/tests/native/nomadnet_x86_flow/server.py @@ -36,6 +36,7 @@ state = { "link": None, "link_closed": False, "form_valid": False, + "form_sequence": [], } @@ -75,6 +76,7 @@ def page_handler(path, data, request_id, link_id, remote_identity, requested_at) state["anonymous"] = remote_identity is None if path == "/page/form.mu": state["form_valid"] = data == EXPECTED_FORM_DATA + state["form_sequence"].append(data) print(f"SERVER request count={state['request_count']} path={path} bytes={len(PAGES[path])} anonymous={state['anonymous']}", flush=True) return PAGES[path] @@ -115,7 +117,7 @@ def write_config(config_dir: str): def main(): parser = argparse.ArgumentParser() - parser.add_argument("scenario", choices=("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", "form-anonymous", "form-identified")) + parser.add_argument("scenario", choices=("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", "form-anonymous", "form-identified", "owner-form-history")) parser.add_argument("--timeout", type=float, default=20.0) args = parser.parse_args() @@ -134,7 +136,8 @@ def main(): allow=RNS.Destination.ALLOW_ALL, auto_compress=False) elif args.scenario != "timeout": - path = "/page/form.mu" if args.scenario.startswith("form-") else f"/page/{args.scenario}.mu" + path = ("/page/form.mu" if args.scenario.startswith("form-") or + args.scenario == "owner-form-history" else f"/page/{args.scenario}.mu") destination.register_request_handler(path, page_handler, allow=RNS.Destination.ALLOW_ALL, auto_compress=False) @@ -143,6 +146,7 @@ def main(): started = time.time() last_announce = started + owner_response_deadline = None while time.time() - started < args.timeout: now = time.time() if now - last_announce >= 2.0 and not state["request_seen"]: @@ -163,6 +167,24 @@ def main(): return 0 print("SERVER FAIL form data or identified identity mismatch", flush=True) return 1 + if args.scenario == "owner-form-history" and state["request_count"] == 3: + expected_changed = dict(EXPECTED_FORM_DATA) + expected_changed["field_name"] = "Changed User" + if (state["anonymous"] and state["form_sequence"] == + [EXPECTED_FORM_DATA, expected_changed, EXPECTED_FORM_DATA]): + if owner_response_deadline is None: + owner_response_deadline = now + 2.0 + print("SERVER owner response 3 queued; awaiting client receipt", flush=True) + if state["link_closed"]: + print("SERVER PASS exact form request data owner-history=True delivery-settled=True", flush=True) + return 0 + if now >= owner_response_deadline: + print("SERVER FAIL owner response delivery did not settle", flush=True) + return 1 + time.sleep(0.02) + continue + print("SERVER FAIL owner form/history request sequence", flush=True) + return 1 if args.scenario in ("immediate", "resource", "near-limit", "oversized") and state["request_seen"]: time.sleep(1.0) print("SERVER PASS", flush=True) diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index b8811916..8bb6e072 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -11,6 +11,8 @@ #include "NomadNetDocument.h" #include "NomadNetDisplay.h" #include "NomadNetHistory.h" +#include "NomadNetOwner.h" +#include "NomadNetPageApplication.h" #include "NomadNetGlyphs.h" #include "NomadNetLibrary.h" #include "NomadNetActionMailbox.h" @@ -121,6 +123,127 @@ int main(int argc, char** argv) { for (std::size_t i = 0; i < PageHistory::MAX_DEPTH + 4; ++i) history.open(std::to_string(i)); check("browser history is bounded", history.depth() == PageHistory::MAX_DEPTH); + class TestSubmission final : public UI::LXMF::NomadNet::OwnerSubmissionSource { + public: + bool prepare_submission(uint16_t, uint32_t, std::string& target, + UI::LXMF::NomadNet::ExternalVector& bytes, + FormEncodeResult& result) override { + target = "form"; + const uint8_t submitted[] = {0x81, 0xa1, 'x', 0xa1, 'y'}; + bytes.assign(submitted, submitted + sizeof(submitted)); + result = FormEncodeResult::OK; + return true; + } + } submission; + UI::LXMF::NomadNet::OwnerController owner; + PageHistory staged_history; + staged_history.open("old"); + UserAction submit_action; + submit_action.kind = UserActionKind::SUBMIT; + auto submit_command = owner.service(submit_action, staged_history, submission, 19); + check("owner Submit stages history without publishing it", + submit_command.result == UI::LXMF::NomadNet::OwnerResult::REQUEST && + staged_history.current() == "old" && staged_history.depth() == 0 && + submit_command.pending_history.ready()); + staged_history.commit(std::move(submit_command.pending_history)); + check("staged Submit history publishes exactly once", + staged_history.current() == "form" && staged_history.depth() == 1 && + staged_history.current_has_request_data()); + const auto committed_depth = staged_history.depth(); + staged_history.commit(std::move(submit_command.pending_history)); + check("consumed history transaction cannot publish twice", + staged_history.depth() == committed_depth && staged_history.current() == "form"); + staged_history.open("next", true, 77); + UserAction back_action; + back_action.kind = UserActionKind::BACK; + auto back_command = owner.service(back_action, staged_history, submission, 0); + check("owner Back is staged until page publication", + back_command.result == UI::LXMF::NomadNet::OwnerResult::REQUEST && + back_command.target == "form" && staged_history.current() == "next" && + back_command.pending_history.ready()); + staged_history.commit(std::move(back_command.pending_history)); + check("staged Back restores exact request bytes after publication", + staged_history.current() == "form" && staged_history.current_scroll() == 77 && + staged_history.current_request_data().size() == 5); + + // Exercise the exact OwnerController -> UIManager local-publication seam. + // The first local anchor transition is an ordinary OPEN; Back must retain + // the OwnerController's staged BACK until the visible state is published. + PageHistory local_history; + local_history.open("node:/page/a.mu"); + std::string visible_address = "node:/page/a.mu"; + int32_t visible_scroll = 137; + PageHistory::PendingOpen local_pending; + bool history_unchanged_during_publication = false; + const auto anchor_result = UI::LXMF::NomadNet::apply_local_navigation_transaction( + "node:/page/a.mu#details", true, visible_scroll, -1, false, + local_history, local_pending, + [] { return true; }, + [&](const std::string& canonical, int32_t) { + history_unchanged_during_publication = + local_history.current() == "node:/page/a.mu" && local_history.depth() == 0; + visible_address = canonical; + visible_scroll = 420; + return true; + }); + check("ordinary local anchor navigation stages and commits OPEN after publication", + anchor_result == UI::LXMF::NomadNet::LocalNavigationResult::APPLIED && + history_unchanged_during_publication && + visible_address == "node:/page/a.mu#details" && visible_scroll == 420 && + local_history.current() == visible_address && local_history.depth() == 1); + + auto local_back = owner.service(back_action, local_history, submission, visible_scroll); + history_unchanged_during_publication = false; + const auto back_result = UI::LXMF::NomadNet::apply_local_navigation_transaction( + local_back.target, false, visible_scroll, local_back.restore_scroll, true, + local_history, local_back.pending_history, + [] { return true; }, + [&](const std::string& canonical, int32_t restore_scroll) { + history_unchanged_during_publication = + local_history.current() == "node:/page/a.mu#details" && + local_history.depth() == 1 && local_back.pending_history.ready(); + visible_address = canonical; + visible_scroll = restore_scroll; + return true; + }); + check("same-resource Back publishes before committing the staged BACK exactly once", + back_result == UI::LXMF::NomadNet::LocalNavigationResult::APPLIED && + history_unchanged_during_publication && + visible_address == "node:/page/a.mu" && visible_scroll == 137 && + local_history.current() == visible_address && local_history.depth() == 0 && + !local_back.pending_history.ready()); + auto second_local_back = owner.service(back_action, local_history, submission, visible_scroll); + check("second Back cannot repeat the consumed same-resource history entry", + second_local_back.result == UI::LXMF::NomadNet::OwnerResult::BACK_EMPTY && + local_history.current() == "node:/page/a.mu" && local_history.depth() == 0); + + PageHistory failed_local_history; + failed_local_history.open("node:/page/a.mu"); + failed_local_history.open("node:/page/a.mu#details", true, 137); + auto supersede_back = owner.service(back_action, failed_local_history, submission, 420); + const std::string failed_visible_address = "node:/page/a.mu#details"; + const auto supersede_result = UI::LXMF::NomadNet::apply_local_navigation_transaction( + supersede_back.target, false, 420, supersede_back.restore_scroll, true, + failed_local_history, supersede_back.pending_history, + [] { return false; }, + [](const std::string&, int32_t) { return true; }); + check("same-resource supersede failure preserves page/history and clears staged BACK", + supersede_result == UI::LXMF::NomadNet::LocalNavigationResult::PREPARATION_FAILED && + failed_visible_address == "node:/page/a.mu#details" && + failed_local_history.current() == failed_visible_address && + failed_local_history.depth() == 1 && !supersede_back.pending_history.ready()); + + auto publication_back = owner.service(back_action, failed_local_history, submission, 420); + const auto publication_result = UI::LXMF::NomadNet::apply_local_navigation_transaction( + publication_back.target, false, 420, publication_back.restore_scroll, true, + failed_local_history, publication_back.pending_history, + [] { return true; }, + [](const std::string&, int32_t) { return false; }); + check("same-resource publication failure preserves page/history and clears staged BACK", + publication_result == UI::LXMF::NomadNet::LocalNavigationResult::PUBLICATION_FAILED && + failed_local_history.current() == failed_visible_address && + failed_local_history.depth() == 1 && !publication_back.pending_history.ready()); + AsyncMailbox mailbox; const std::vector old_link{1}, new_link{2}; mailbox.begin(new_link); @@ -1296,7 +1419,8 @@ int main(int argc, char** argv) { std::string fixture((std::istreambuf_iterator(input)), std::istreambuf_iterator()); auto real = parser.parse(fixture); check("authoritative Aleph fixture is readable", input.good() || input.eof()); - check("authoritative fixture yields headings", !real.blocks.empty() && real.blocks[0].type == BlockType::HEADING); + check("authoritative fixture yields headings", std::any_of(real.blocks.begin(), real.blocks.end(), + [](const auto& block) { return block.type == BlockType::HEADING; })); check("authoritative fixture yields links", !real.links.empty()); bool retained_fixture_fields = false; CompactPage real_page; @@ -1372,14 +1496,14 @@ int main(int argc, char** argv) { check("divider has layout content without a text run", UI::LXMF::NomadNet::block_has_layout_content(BlockType::DIVIDER, 0)); check("tables keep their columns when the structural minimum fits the device", - UI::LXMF::NomadNet::choose_table_layout(52, 304) == + UI::LXMF::NomadNet::choose_table_layout(52, 52, 304) == UI::LXMF::NomadNet::TableLayoutTier::FIT); - check("natural or authored widths do not turn a structurally fitting table into cards", - UI::LXMF::NomadNet::choose_table_layout(78, 304) == - UI::LXMF::NomadNet::TableLayoutTier::FIT); - check("only tables whose structural minimum exceeds the content area use reflow", - UI::LXMF::NomadNet::choose_table_layout(305, 304) == + check("oversized natural widths reflow while the structural minimum still fits", + UI::LXMF::NomadNet::choose_table_layout(52, 400, 304) == UI::LXMF::NomadNet::TableLayoutTier::REFLOW); + check("tables whose structural minimum exceeds the content area use cards", + UI::LXMF::NomadNet::choose_table_layout(305, 400, 304) == + UI::LXMF::NomadNet::TableLayoutTier::STACKED); int16_t fitted_columns[2] = {40, 400}; const int16_t fitted_width = UI::LXMF::NomadNet::fit_table_columns( fitted_columns, 2, 26, 180); diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py index a3b5c667..4666d79a 100644 --- a/tests/native/test_app_launcher_nomadnet.py +++ b/tests/native/test_app_launcher_nomadnet.py @@ -6,8 +6,24 @@ from pathlib import Path import pytest +import hashlib + HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent + + +def test_nomadnet_feature_fixture_has_honest_pinned_provenance(): + fixture = HERE / "fixtures/nomadnet_feature_matrix.mu" + data = fixture.read_bytes() + assert hashlib.sha256(data).hexdigest() == ( + "1988c090a9c84ac0ff9c6d9f798e892710aea2c834639402fcc2e9089b23835f" + ) + text = data.decode("utf-8") + assert "nomadnet/examples/various/input_fields.py c72e87170a3d833fc18b9c9d12299a4a056d8f46c4f8a43e3f872fc0b7b69dc9" in text + authoritative = text.split("# BEGIN AUTHORITATIVE\n", 1)[1].split("# END AUTHORITATIVE", 1)[0] + assert "transcribed" not in authoritative + assert "# source-range:" in authoritative + assert "# BEGIN ADAPTED" in text and "# BEGIN SYNTHETIC" in text SOURCE = HERE / "test_app_launcher_nomadnet.cpp" INCLUDE = ROOT / "lib" / "tdeck_ui" / "UI" / "LXMF" FIXTURES = { @@ -15,8 +31,50 @@ FIXTURES = { "e47de4e19e7216e48a8d441abbca1650b77ab6a2b47bedbd2144301f44172728", HERE / "fixtures" / "columba_releases_live.mu": "1ea4eea2b06d6988c53c19df51d38f78abd27125b4613f7a3fa5715f7461b10a", + HERE / "fixtures" / "nomadnet_feature_matrix.mu": + "1988c090a9c84ac0ff9c6d9f798e892710aea2c834639402fcc2e9089b23835f", } +FEATURE_MATRIX = HERE / "fixtures" / "nomadnet_feature_matrix.mu" +FEATURE_MATRIX_SHA256 = "1988c090a9c84ac0ff9c6d9f798e892710aea2c834639402fcc2e9089b23835f" +CANONICAL_NOMADNET_COMMIT = "89e3eea10c60d8fe597d36d2e091d5aab86bdfb8" +CANONICAL_SOURCE_SHA256 = { + "nomadnet/ui/textui/MicronParser.py": + "c4b40918fe813a7cfbb696f33df8a08451fd0156a6919a185b75225f52402ffb", + "nomadnet/ui/textui/Browser.py": + "b7bc37e0fd4e72261703a037ab1967ea4cc43b837dc1cd74f92a835bacab40a1", + "nomadnet/Node.py": + "2461a592b731cb1469bebb5ccc5f523892127881cb7a7e8ed586ac62a8c0c23a", +} + + +def test_nomadnet_feature_matrix_is_byte_exact_and_classifies_every_milestone_feature(): + data = FEATURE_MATRIX.read_bytes() + assert hashlib.sha256(data).hexdigest() == FEATURE_MATRIX_SHA256 + text = data.decode("utf-8") + assert f"# canonical-commit: {CANONICAL_NOMADNET_COMMIT}" in text + for relative, digest in CANONICAL_SOURCE_SHA256.items(): + assert f"# canonical-source-sha256: {relative} {digest}" in text + assert text.count("# BEGIN AUTHORITATIVE") == 1 + assert text.count("# END AUTHORITATIVE") == 1 + assert text.count("# BEGIN SYNTHETIC") == 1 + assert text.count("# END SYNTHETIC") == 1 + required = { + "default-divider", "custom-divider", "rgb-color", "grayscale-color", + "unknown-modifier", "anchor", "heading", "table-delimiters", + "field-text", "field-password", "field-checkbox", "field-radio", + "submit-named", "submit-wildcard", "submit-preconfigured", "partial-descriptor", + "canonical-submit-variants", "canonical-input-and-submit", "canonical-checkbox-radio", + } + observed = set(re.findall(r"^# feature: ([a-z0-9-]+) status: (supported|degraded|future)$", + text, flags=re.MULTILINE)) + assert {name for name, _ in observed} == required + statuses = dict(observed) + assert statuses["partial-descriptor"] == "future" + assert statuses["table-delimiters"] == "supported" + assert statuses["field-password"] == "supported" + assert "# feature: partial-descriptor status: supported" not in text + def _cxx(): for name in ("clang++", "g++"): @@ -41,6 +99,7 @@ def test_app_launcher_nomadnet_native(tmp_path): str(INCLUDE / "NomadNetForm.cpp"), str(INCLUDE / "NomadNetGlyphs.cpp"), str(INCLUDE / "NomadNetLibrary.cpp"), + str(INCLUDE / "NomadNetOwner.cpp"), str(INCLUDE / "NomadNetUrl.cpp"), "-o", str(binary), ] @@ -162,8 +221,8 @@ def test_nomadnet_forms_are_bounded_virtualized_and_owner_submitted(): assert "if(pwd_resized == NULL) return;\\n ta->pwd_tmp = pwd_resized;" in lvgl_patch assert "if(pwd_bulk_resized == NULL) return;\\n ta->pwd_tmp = pwd_bulk_resized;" in lvgl_patch assert "Reserve password storage before mutating the label" in lvgl_patch - assert 'env_libdeps_dir(env, "lvgl", "src", "core", "lv_obj_class.c")' in lvgl_patch - assert 'env_libdeps_dir(env, "lvgl", "src", "widgets", "lv_label.c")' in lvgl_patch + assert 'lvgl_path("src", "core", "lv_obj_class.c")' in lvgl_patch + assert 'lvgl_path("src", "widgets", "lv_label.c")' in lvgl_patch assert "if(parent->spec_attr == NULL)" in lvgl_patch assert "lv_obj_t ** children_resized" in lvgl_patch assert "char * label_resized = lv_mem_realloc" in lvgl_patch @@ -171,8 +230,8 @@ def test_nomadnet_forms_are_bounded_virtualized_and_owner_submitted(): assert "volatile char * visible" in lvgl_patch assert "size_t visible_len = strlen(txt)" in lvgl_patch assert "size_t masked_len = strlen(visible)" in lvgl_patch - assert 'env_libdeps_dir(env, "lvgl", "src", "core", "lv_event.c")' in lvgl_patch - assert 'env_libdeps_dir(env, "lvgl", "src", "core", "lv_group.c")' in lvgl_patch + assert 'lvgl_path("src", "core", "lv_event.c")' in lvgl_patch + assert 'lvgl_path("src", "core", "lv_group.c")' in lvgl_patch assert "lv_event_dsc_t * event_dsc_resized" in lvgl_patch assert "uint32_t event_dsc_cnt_new" in lvgl_patch assert "if(lv_label_get_text(ta->label) == NULL)" in lvgl_patch @@ -292,6 +351,7 @@ def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints(): screen = (INCLUDE / "NomadNetScreen.cpp").read_text() manager_h = (INCLUDE / "UIManager.h").read_text() manager = (INCLUDE / "UIManager.cpp").read_text() + page_application = (INCLUDE / "NomadNetPageApplication.h").read_text() assert "MAX_ANCHORS = 128" in document_h assert "MAX_ANCHOR_NAME_BYTES = 64" in document_h @@ -309,15 +369,30 @@ def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints(): assert "scroll_to_logical(target,LV_ANIM_OFF)" in jump open_page = manager[manager.index("void UIManager::nomad_open("): - manager.index("void UIManager::nomad_reload()")] - assert "_nomad_url.path,_nomad_url.fields" in open_page + manager.index("void UIManager::nomad_start_link()")] + assert "_nomad_url.path, _nomad_url.fields" in open_page assert "NomadNet::should_jump_locally" in open_page local = open_page[open_page.index("NomadNet::should_jump_locally"): open_page.index("_nomad_pending_scroll=restore_logical_scroll")] assert "_nomadnet_screen->jump_to_anchor(parsed.fragment)" in local - assert "if(!resolved&&parsed.fragment.empty())return;" in local - assert "_nomad_history.current_request_data()" in local - assert "_nomad_history.open(parsed.str(), add_history, current_scroll," in local + assert "if (!resolved) return false;" in local + assert "NomadNet::apply_local_navigation_transaction" in local + assert "restore_logical_scroll, history_prepared" in local + assert "set_local_address(published_address)" in local + assert local.index("set_local_address(published_address)") < local.index( + "restore_logical_scroll(restore_scroll)" + ) + local_address = screen[screen.index("bool NomadNetScreen::set_local_address("): + screen.index("std::string NomadNetScreen::address()")] + assert "lv_textarea_set_text(_address,value.c_str())" in local_address + assert "std::strcmp(lv_textarea_get_text(_address),value.c_str())==0" in local_address + local_transaction = page_application[page_application.index( + "LocalNavigationResult apply_local_navigation_transaction"): + page_application.index("enum class PageApplyResult")] + assert "if (history_prepared)" in local_transaction + assert "history.prepare_open(canonical_address" in local_transaction + assert local_transaction.index("publish(canonical_address, restore_scroll)") < \ + local_transaction.index("history.commit(std::move(pending))") assert "nomad_send_request" not in local assert "begin_navigation" not in local @@ -328,17 +403,18 @@ def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints(): response = manager[manager.index("case NomadNet::AsyncMailbox::Kind::RESPONSE:"): manager.index("case NomadNet::AsyncMailbox::Kind::NONE:")] - apply = manager[manager.index("bool UIManager::nomad_apply_page_document("): + apply = manager[manager.index("NomadNet::PageApplyResult UIManager::nomad_apply_page_document("): manager.index("void UIManager::nomad_update()")] assert "nomad_apply_page_document(document, false)" in response assert "_nomadnet_screen->set_page(document)" in apply assert "_nomadnet_screen->jump_to_anchor(_nomad_url.fragment)" in apply assert "_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll)" in apply - assert "if (applied && _nomad_pending_scroll >= 0)" in apply - assert "else if (applied && _nomad_url.has_fragment)" in apply + assert "if (_nomad_pending_scroll >= 0)" in apply + assert "if (result != NomadNet::PageApplyResult::APPLIED)" in apply + assert "else if (_nomad_url.has_fragment)" in apply assert (apply.index("_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll)") < apply.index("_nomadnet_screen->jump_to_anchor(_nomad_url.fragment)")) - assert "Unknown anchor: #" in apply + assert "Unknown anchor: #" in page_application assert "int32_t _nomad_pending_scroll" in manager_h @@ -361,8 +437,9 @@ def test_nomadnet_page_body_uses_one_compact_custom_viewport(): assert "divider_height=16" not in screen assert "commit_line" in screen assert "NomadNet::truncation_notice(document)" in set_page - assert "if(!_page.append_notice(notice))" in set_page - assert "if(!layout_page())" in set_page + assert "if(!candidate_page.append_notice(notice))" in set_page + assert "try{laid_out=layout_page();}" in set_page + assert "if(!laid_out)" in set_page assert "catch(const std::bad_alloc&)" in set_page assert "Page is too large for available memory" in set_page assert "[Page truncated to device safety limits]" not in set_page @@ -413,7 +490,7 @@ def test_cache_lookup_preserves_page_and_response_uses_captured_request_class(): manager = (INCLUDE / "UIManager.cpp").read_text() header = (INCLUDE / "UIManager.h").read_text() open_page = manager[manager.index("void UIManager::nomad_open("): - manager.index("void UIManager::nomad_reload()")] + manager.index("void UIManager::nomad_start_link()")] lookup = open_page.index("_nomad_cache_flow.begin") assert "begin_navigation" not in open_page[:lookup] assert "_nomad_request_data_class" in header @@ -505,17 +582,17 @@ def test_nomadnet_validates_queued_addresses_before_navigation_teardown(): assert "begin_navigation(" not in actions open_page = manager[manager.index("void UIManager::nomad_open("): - manager.index("void UIManager::nomad_reload()")] + manager.index("void UIManager::nomad_start_link()")] parse = open_page.index("NomadNet::Url::parse") lookup = open_page.index("_nomad_cache_flow.begin") assert parse < lookup assert "begin_navigation" not in open_page[:lookup] live = manager[manager.index("void UIManager::nomad_begin_live_transport()"): - manager.index("void UIManager::nomad_reload()")] + manager.index("void UIManager::nomad_start_link()")] # A valid address must also preserve the current page until live transport # serialization succeeds; lock contention is retried by the owner loop. assert live.index("RouterLock router_lock;") < \ - live.index("_nomadnet_screen->begin_navigation(_nomad_url.str())") + live.index("_nomadnet_screen->set_address(_nomad_url.str())") def test_launcher_transition_has_one_final_focus_owner(): @@ -573,7 +650,7 @@ def test_nomadnet_cache_blocker_boundaries_are_closed(): # advancing generation, and old transport is reconciled before a hit publishes. assert "_nomad_navigation_generation" in header open_page = manager[manager.index("void UIManager::nomad_open("): - manager.index("void UIManager::nomad_reload()")] + manager.index("void UIManager::nomad_start_link()")] assert "nomad_supersede_transport" in open_page cache_hit = manager[manager.index("if (_nomad_state == NomadState::CACHE)"): manager.index("RouterLock router_lock", manager.index("if (_nomad_state == NomadState::CACHE)"))] @@ -609,8 +686,8 @@ def test_nomadnet_cache_blocker_boundaries_are_closed(): # Live admission is non-destructive until Router serialization succeeds. # Contention leaves an explicit owner-loop state that retries deterministically. live = manager[manager.index("void UIManager::nomad_begin_live_transport()"): - manager.index("void UIManager::nomad_reload()")] - assert live.index("RouterLock router_lock;") < live.index("begin_navigation") + manager.index("void UIManager::nomad_start_link()")] + assert live.index("RouterLock router_lock;") < live.index("set_address") assert "if (!router_lock.acquired())" in live assert "_nomad_state = NomadState::LIVE_PENDING;" in live update = manager[manager.index("void UIManager::nomad_update()"): @@ -760,7 +837,7 @@ def test_nomadnet_requests_are_anonymous_and_back_cleanup_is_serialized(): assert stop.index("_nomad_link.teardown()") < stop.index("nomad_release_request();") reopen = manager_cpp[manager_cpp.index("void UIManager::nomad_open("): - manager_cpp.index("void UIManager::nomad_reload()")] + manager_cpp.index("void UIManager::nomad_start_link()")] assert "RouterLock router_lock;" in reopen assert reopen.index("_nomad_link.teardown()") < reopen.index("nomad_release_request();") @@ -956,7 +1033,7 @@ def test_nomadnet_same_destination_navigation_reuses_active_link(): assert "bool set_page(const NomadNet::Document& document);" in screen_h reopen = manager_cpp[manager_cpp.index("void UIManager::nomad_open("): - manager_cpp.index("void UIManager::nomad_reload()")] + manager_cpp.index("void UIManager::nomad_start_link()")] assert "same_destination" in reopen assert "_nomad_link.status() == Type::Link::ACTIVE" in reopen assert reopen.index("if (same_destination") < reopen.index("_nomad_link.teardown()") @@ -987,6 +1064,22 @@ def test_nomadnet_same_destination_navigation_reuses_active_link(): assert "nomad_stop_transport();" in retained +def test_nomadnet_owner_routes_back_reload_and_table_observation_is_private(): + screen_h = (INCLUDE / "NomadNetScreen.h").read_text() + manager_h = (INCLUDE / "UIManager.h").read_text() + manager = (INCLUDE / "UIManager.cpp").read_text() + assert "table_layout_observation" not in screen_h + assert screen_h.index("private:") < screen_h.index("struct TableLayoutObservation") + assert "nomad_restore_history_submission" not in manager_h + manager + assert "void UIManager::nomad_reload()" not in manager + back = manager[manager.index("void UIManager::back()"): + manager.index("void UIManager::nomad_back_empty()")] + assert "UserActionKind::BACK" in back + actions = manager[manager.index("void UIManager::nomad_update_user_actions()"): + manager.index("void UIManager::service_nomad_terminal_action()")] + assert "UserActionKind::RELOAD" in actions and "_nomad_owner.service" in actions + + def test_nomadnet_physical_test_hooks_are_isolated_and_owner_queued(): main = (ROOT / "src/main.cpp").read_text() header = (INCLUDE / "UIManager.h").read_text() diff --git a/tests/native/test_lvgl_dependency.py b/tests/native/test_lvgl_dependency.py new file mode 100644 index 00000000..d1f2a95a --- /dev/null +++ b/tests/native/test_lvgl_dependency.py @@ -0,0 +1,141 @@ +import importlib.util +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +RESOLVER = ROOT / "tools" / "resolve_lvgl.py" +PATCHER = ROOT / "patch_lvgl_textarea.py" +LVGL = ROOT / ".pio" / "libdeps" / "tdeck" / "lvgl" +PLATFORMIO = Path( + os.environ.get("PYXIS_PLATFORMIO_BIN") + or shutil.which("pio") + or "/tmp/pyxis-platformio/bin/pio" +) +VANILLA_DIGEST = "9a6ca0d597f4a17792c04a19104482c65e5af20401414a3c393c8d8a6a175a47" +EXPECTED_DIGEST = "be9f52b7aa9ca3fe379569cdf59fe43ebd9e676bc775b72b86cde91c59808ec2" + + +def _resolver_module(): + spec = importlib.util.spec_from_file_location("pyxis_resolve_lvgl", RESOLVER) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def run_resolver(root: Path, *, env=None, timeout=300): + return subprocess.run( + ["/usr/bin/python3", str(RESOLVER), "--root", str(root)], + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + + +def make_resolver_root(root: Path) -> Path: + root.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / "platformio.ini", root / "platformio.ini") + shutil.copy2(PATCHER, root / PATCHER.name) + return root + + +@pytest.fixture(scope="module") +def fresh_real_resolution(tmp_path_factory): + """Install vanilla LVGL with the CI pio, then patch it through the resolver.""" + if not PLATFORMIO.is_file() or not os.access(PLATFORMIO, os.X_OK): + pytest.fail(f"required CI PlatformIO executable is missing: {PLATFORMIO}") + root = make_resolver_root(tmp_path_factory.mktemp("fresh-lvgl-worktree")) + installed = subprocess.run( + [str(PLATFORMIO), "pkg", "install", "-e", "tdeck"], + cwd=root, + capture_output=True, + text=True, + timeout=600, + ) + assert installed.returncode == 0, installed.stdout + installed.stderr + lvgl = root / ".pio" / "libdeps" / "tdeck" / "lvgl" + resolver = _resolver_module() + assert resolver.source_tree_digest(lvgl) == VANILLA_DIGEST + + vanilla = root.parent / "vanilla-lvgl" + shutil.copytree(lvgl, vanilla) + env = os.environ.copy() + env["PYXIS_PLATFORMIO_BIN"] = str(PLATFORMIO) + first = run_resolver(root, env=env) + assert first.returncode == 0, first.stdout + first.stderr + assert f"LVGL 8.4.0 {EXPECTED_DIGEST}" in first.stdout + assert resolver.source_tree_digest(lvgl) == EXPECTED_DIGEST + + # A second resolver run must be a true no-op over the already patched tree. + second = run_resolver(root, env=env) + assert second.returncode == 0, second.stdout + second.stderr + assert f"LVGL 8.4.0 {EXPECTED_DIGEST}" in second.stdout + assert resolver.source_tree_digest(lvgl) == EXPECTED_DIGEST + return root, vanilla + + +def test_resolver_verifies_exact_version_and_stable_tree_digest(): + result = run_resolver(ROOT) + assert result.returncode == 0, result.stdout + result.stderr + assert f"LVGL 8.4.0 {EXPECTED_DIGEST}" in result.stdout + assert str(LVGL) in result.stdout + + +def test_resolver_rejects_altered_version(tmp_path): + root = make_resolver_root(tmp_path / "worktree") + candidate = root / ".pio" / "libdeps" / "tdeck" / "lvgl" + shutil.copytree(LVGL, candidate) + metadata = candidate / "library.json" + metadata.write_text(metadata.read_text().replace('"version": "8.4.0"', '"version": "8.4.1"')) + + result = run_resolver(root) + assert result.returncode != 0 + assert "expected LVGL version 8.4.0, found 8.4.1" in result.stderr + + +def test_resolver_rejects_mutated_patched_tree(tmp_path): + root = make_resolver_root(tmp_path / "worktree") + candidate = root / ".pio" / "libdeps" / "tdeck" / "lvgl" + shutil.copytree(LVGL, candidate) + source = candidate / "src" / "core" / "lv_obj.c" + source.write_bytes(source.read_bytes() + b"\n/* altered patched tree */\n") + + result = run_resolver(root) + assert result.returncode != 0 + assert "LVGL source digest mismatch before patch" in result.stderr + assert VANILLA_DIGEST in result.stderr + assert EXPECTED_DIGEST in result.stderr + + +def test_missing_dependency_uses_real_ci_platformio_and_is_idempotent(fresh_real_resolution): + root, _ = fresh_real_resolution + assert (root / ".pio" / "libdeps" / "tdeck" / "lvgl").is_dir() + + +def test_resolver_rejects_mutated_vanilla_tree_before_patching( + tmp_path, fresh_real_resolution): + _, vanilla = fresh_real_resolution + root = make_resolver_root(tmp_path / "mutated-vanilla-worktree") + candidate = root / ".pio" / "libdeps" / "tdeck" / "lvgl" + shutil.copytree(vanilla, candidate) + source = candidate / "src" / "core" / "lv_obj.c" + source.write_bytes(source.read_bytes() + b"\n/* altered vanilla tree */\n") + + result = run_resolver(root) + assert result.returncode != 0 + assert "LVGL source digest mismatch before patch" in result.stderr + assert VANILLA_DIGEST in result.stderr + assert EXPECTED_DIGEST in result.stderr + + +def test_platformio_dependency_is_exact_not_range_pinned(): + platformio = (ROOT / "platformio.ini").read_text() + assert "lvgl/lvgl@8.4.0" in platformio + assert "lvgl/lvgl@^8.3.11" not in platformio diff --git a/tests/native/test_nomadnet_lvgl_acceptance.py b/tests/native/test_nomadnet_lvgl_acceptance.py new file mode 100644 index 00000000..16ea3fd7 --- /dev/null +++ b/tests/native/test_nomadnet_lvgl_acceptance.py @@ -0,0 +1,47 @@ +import os +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +LVGL = ROOT / ".pio" / "libdeps" / "tdeck" / "lvgl" + + +def test_actual_nomadnet_screen_320x240_acceptance(tmp_path): + resolved = subprocess.run( + [sys.executable, str(ROOT / "tools" / "resolve_lvgl.py"), "--root", str(ROOT)], + capture_output=True, + text=True, + timeout=180, + ) + assert resolved.returncode == 0, resolved.stdout + resolved.stderr + assert LVGL.is_dir() + cmake = shutil.which("cmake") + assert cmake + build = tmp_path / "build" + configured = subprocess.run([ + cmake, "-S", str(HERE / "nomadnet_lvgl_acceptance"), "-B", str(build), + f"-DPYXIS_ROOT={ROOT}", f"-DLVGL_SOURCE={LVGL}", + ], capture_output=True, text=True) + assert configured.returncode == 0, configured.stdout + configured.stderr + compiled = subprocess.run([cmake, "--build", str(build), "-j2"], + capture_output=True, text=True, timeout=180) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + env = os.environ.copy() + env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1" + env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + result = subprocess.run([str(build / "nomadnet_lvgl_acceptance")], + capture_output=True, text=True, timeout=30, env=env) + assert result.returncode == 0, result.stdout + result.stderr + output = result.stdout.strip() + assert "fit_tier=1 fit_columns=1" in output + assert "reflow_tier=1 reflow_cards=1" in output + assert "stacked_tier=1 stacked_cards=1 stacked_pixels=1 stacked_scroll=1 stacked_objects=1" in output + assert "focus_events=1 edge_scroll=1" in output + assert "ready=1 cancel=1 enter=1 escape=1 focus_restore=1" in output + assert "teardown=1 stale_group=0" in output + assert "background_pixels=1 table_pixels=1 form_pixels=1 focus_pixels=1 glyph_pixels=1" in output + assert "exact_fonts=1" in output + assert output.endswith("objects=0") diff --git a/tests/native/test_nomadnet_parse_apply_alloc_failure.cpp b/tests/native/test_nomadnet_parse_apply_alloc_failure.cpp new file mode 100644 index 00000000..a6abbf41 --- /dev/null +++ b/tests/native/test_nomadnet_parse_apply_alloc_failure.cpp @@ -0,0 +1,233 @@ +#define private public +#include "NomadNetCompactPage.h" +#undef private +#include "NomadNetDocument.h" +#include "NomadNetForm.h" +#include "NomadNetLibrary.h" +#include "NomadNetPageApplication.h" +#include "NomadNetOwner.h" +#include "NomadNetUrl.h" + +#include + +#include +#include +#include + +namespace { +std::size_t allocation_index = 0; +std::size_t fail_at = static_cast(-1); +bool injection_enabled = false; +} + +void* operator new(std::size_t size) { + if (injection_enabled && allocation_index++ == fail_at) throw std::bad_alloc(); + if (void* memory = std::malloc(size)) return memory; + throw std::bad_alloc(); +} +void* operator new[](std::size_t size) { + if (injection_enabled && allocation_index++ == fail_at) throw std::bad_alloc(); + if (void* memory = std::malloc(size)) return memory; + throw std::bad_alloc(); +} +void* operator new(std::size_t size, const std::nothrow_t&) noexcept { + if (injection_enabled && allocation_index++ == fail_at) return nullptr; + return std::malloc(size); +} +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { + if (injection_enabled && allocation_index++ == fail_at) return nullptr; + return std::malloc(size); +} +void operator delete(void* memory) noexcept { std::free(memory); } +void operator delete[](void* memory) noexcept { std::free(memory); } +void operator delete(void* memory, std::size_t) noexcept { std::free(memory); } +void operator delete[](void* memory, std::size_t) noexcept { std::free(memory); } +void operator delete(void* memory, const std::nothrow_t&) noexcept { std::free(memory); } +void operator delete[](void* memory, const std::nothrow_t&) noexcept { std::free(memory); } + +using namespace UI::LXMF::NomadNet; + +static std::size_t retained_capacity(const CompactPage& page) { + return page._arena.capacity() * sizeof(char) + + page._blocks.capacity() * sizeof(CompactPage::BlockRecord) + + page._runs.capacity() * sizeof(CompactPage::RunRecord) + + page._links.capacity() * sizeof(CompactPage::LinkRecord) + + page._anchors.capacity() * sizeof(CompactPage::AnchorRecord) + + page._tables.capacity() * sizeof(CompactPage::TableRecord) + + page._table_cells.capacity() * sizeof(CompactPage::TableCellRecord) + + page._fields.capacity() * sizeof(CompactPage::FieldRecord); +} + +int main() { + std::string source = "`t\n"; + for (int row = 0; row < 20; ++row) { + source += "`!linked`[go`:/page/next.mu]|"; + source += std::string(180, static_cast('a' + row % 20)); + source += "|`|tail\n"; + if (row == 1) source += "---|---|---|---\n"; + } + source += "`t\nafter"; + + DocumentParser parser; + Document baseline; + if (parser.parse_into(source.data(), source.size(), baseline) != ParseStatus::OK || + baseline.tables.empty()) { + std::cerr << "baseline parse failed\n"; + return 1; + } + + bool saw_parse_failure = false; + bool saw_parse_success = false; + for (std::size_t point = 0; point < 3000; ++point) { + Document output = parser.parse("old page"); + allocation_index = 0; + fail_at = point; + injection_enabled = true; + const ParseStatus status = parser.parse_into(source.data(), source.size(), output); + injection_enabled = false; + if (status == ParseStatus::ALLOCATION_FAILED) { + saw_parse_failure = true; + if (!output.blocks.empty() || !output.tables.empty() || + output.blocks.capacity() != 0 || output.table_cells.capacity() != 0 || + !output.allocation_failed) { + std::cerr << "parse failure retained partial capacity/model\n"; + return 1; + } + } else if (status == ParseStatus::OK) { + saw_parse_success = true; + break; + } else { + std::cerr << "unexpected parse status\n"; + return 1; + } + } + if (!saw_parse_failure || !saw_parse_success) { + std::cerr << "parse allocation sweep did not cover failure and recovery\n"; + return 1; + } + + bool saw_compact_failure = false; + bool saw_compact_success = false; + for (std::size_t point = 0; point < 2000; ++point) { + CompactPage page; + if (!page.assign(parser.parse("old page"))) return 1; + allocation_index = 0; + fail_at = point; + injection_enabled = true; + const bool assigned = page.assign(baseline); + injection_enabled = false; + if (!assigned) { + saw_compact_failure = true; + if (!page.empty() || page.arena_bytes() != 0 || + retained_capacity(page) != 0) { + std::cerr << "compact failure retained partial capacity/model\n"; + return 1; + } + } else { + saw_compact_success = true; + break; + } + } + if (!saw_compact_failure || !saw_compact_success) { + std::cerr << "compact allocation sweep did not cover failure and recovery\n"; + return 1; + } + + bool saw_application_failure = false; + bool saw_application_success = false; + for (std::size_t point = 0; point < 3000; ++point) { + Library library; + const std::string old_url = "00000000000000000000000000000000:/page/old.mu"; + const std::string new_url = "11111111111111111111111111111111:/page/new.mu#anchor"; + Url current_url; + std::string url_error; + if (!Url::parse(new_url, current_url, url_error)) return 1; + if (!library.record_page(old_url, "Old", 1)) { + std::cerr << "application baseline library failed\n"; return 1; + } + CompactPage visible; + FormState visible_form; + if (!visible.assign(parser.parse("old visible")) || !visible_form.assign(visible)) { + std::cerr << "application baseline visible failed\n"; return 1; + } + int history_commits = 0; + allocation_index = 0; + fail_at = point; + injection_enabled = true; + const auto result = apply_page_transaction_for_url( + baseline, current_url, 2, + library, + [&](const PagePublication&) { + CompactPage page; + FormState form; + std::vector layout; + if (!page.assign(baseline) || !form.assign(page)) return false; + layout.assign(page.blocks().size() + page.runs().size() + + page.fields().size() + page.tables().size(), 1); + visible = std::move(page); + visible_form = std::move(form); + return true; + }, + [&]() noexcept { ++history_commits; }); + injection_enabled = false; + if (result != PageApplyResult::APPLIED) { + saw_application_failure = true; + if (history_commits != 0 || library.pages().size() != 1 || + library.pages()[0].url != old_url || visible.blocks().size() != 1) { + std::cerr << "production application failure mutated published owner state\n"; + return 1; + } + } else { + saw_application_success = true; + if (history_commits != 1 || library.pages().size() != 2 || visible.empty()) { + std::cerr << "production application success did not commit once\n"; return 1; + } + break; + } + } + if (!saw_application_failure || !saw_application_success) { + std::cerr << "production application allocation sweep incomplete\n"; + return 1; + } + + class UnusedSubmission final : public OwnerSubmissionSource { + public: + bool prepare_submission(uint16_t, uint32_t, std::string&, + ExternalVector&, FormEncodeResult&) override { return false; } + } unused; + bool saw_owner_failure = false; + bool saw_owner_success = false; + const uint8_t retained[] = {0x81, 0xa1, 'x', 0xa1, 'y'}; + for (std::size_t point = 0; point < 100; ++point) { + PageHistory history; + if (!history.open("old", true, 0, retained, sizeof(retained)) || + !history.open("current", true, 37)) return 1; + OwnerController owner; + UserAction back; + back.kind = UserActionKind::BACK; + allocation_index = 0; + fail_at = point; + injection_enabled = true; + auto command = owner.service(back, history, unused, 0); + injection_enabled = false; + if (command.result == OwnerResult::ALLOCATION_FAILED) { + saw_owner_failure = true; + if (history.current() != "current" || history.depth() != 1 || + command.pending_history.ready() || !command.request_data.empty()) { + std::cerr << "owner Back allocation failure mutated history\n"; + return 1; + } + } else if (command.result == OwnerResult::REQUEST) { + saw_owner_success = true; + if (history.current() != "current" || history.depth() != 1 || + command.request_data.size() != sizeof(retained) || + !command.pending_history.ready()) return 1; + break; + } else return 1; + } + if (!saw_owner_failure || !saw_owner_success) { + std::cerr << "owner Back allocation sweep incomplete\n"; + return 1; + } + return 0; +} diff --git a/tests/native/test_nomadnet_parse_apply_alloc_failure.py b/tests/native/test_nomadnet_parse_apply_alloc_failure.py new file mode 100644 index 00000000..395af1be --- /dev/null +++ b/tests/native/test_nomadnet_parse_apply_alloc_failure.py @@ -0,0 +1,38 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +INCLUDE = ROOT / "lib" / "tdeck_ui" / "UI" / "LXMF" + + +def _cxx(): + for name in ("clang++", "g++"): + if shutil.which(name): + return name + pytest.skip("no C++ compiler found") + + +def test_parse_and_compact_application_recover_from_every_allocation_failure(tmp_path): + compact_header = (INCLUDE / "NomadNetCompactPage.h").read_text() + assert "retained_capacity_bytes" not in compact_header + binary = tmp_path / "test_nomadnet_parse_apply_alloc_failure" + command = [ + _cxx(), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", + f"-I{INCLUDE}", str(HERE / "test_nomadnet_parse_apply_alloc_failure.cpp"), + str(INCLUDE / "NomadNetDocument.cpp"), + str(INCLUDE / "NomadNetCompactPage.cpp"), + str(INCLUDE / "NomadNetForm.cpp"), + str(INCLUDE / "NomadNetGlyphs.cpp"), + str(INCLUDE / "NomadNetLibrary.cpp"), + str(INCLUDE / "NomadNetOwner.cpp"), + str(INCLUDE / "NomadNetUrl.cpp"), "-o", str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/native/test_nomadnet_table_limits.cpp b/tests/native/test_nomadnet_table_limits.cpp new file mode 100644 index 00000000..082b792a --- /dev/null +++ b/tests/native/test_nomadnet_table_limits.cpp @@ -0,0 +1,80 @@ +#include "NomadNetDocument.h" + +#include +#include +#include +#include + +using UI::LXMF::NomadNet::DocumentParser; +using UI::LXMF::NomadNet::TruncationReason; + +namespace { +bool reject_large_allocations = false; +constexpr std::size_t MAX_ALLOWED_TEMPORARY = 5000; +} + +void* operator new(std::size_t size) { + if (reject_large_allocations && size > MAX_ALLOWED_TEMPORARY) throw std::bad_alloc(); + if (void* memory = std::malloc(size)) return memory; + throw std::bad_alloc(); +} +void* operator new[](std::size_t size) { + if (reject_large_allocations && size > MAX_ALLOWED_TEMPORARY) throw std::bad_alloc(); + if (void* memory = std::malloc(size)) return memory; + throw std::bad_alloc(); +} +void operator delete(void* memory) noexcept { std::free(memory); } +void operator delete[](void* memory) noexcept { std::free(memory); } +void operator delete(void* memory, std::size_t) noexcept { std::free(memory); } +void operator delete[](void* memory, std::size_t) noexcept { std::free(memory); } + +int main() { + DocumentParser parser; + std::string separator_heavy = "`t\n"; + separator_heavy += "h0|h1|h2|h3|h4|h5|h6|h7"; + separator_heavy += std::string(1800, '|'); + separator_heavy += "\n---|---|---|---|---|---|---|---"; + separator_heavy += std::string(1800, '|'); + separator_heavy += "\nv0|v1|v2|v3|v4|v5|v6|v7"; + separator_heavy += std::string(300, '|'); + separator_heavy += "\n`t\nafter"; + + bool escaped = false; + UI::LXMF::NomadNet::Document document; + reject_large_allocations = true; + try { + document = parser.parse(separator_heavy); + } catch (const std::bad_alloc&) { + escaped = true; + } + reject_large_allocations = false; + if (escaped || document.tables.size() != 1 || + document.tables[0].column_count != DocumentParser::MAX_TABLE_COLUMNS || + !document.has_truncation(TruncationReason::TABLE_COLUMNS) || + document.table_cells.size() > DocumentParser::MAX_TABLE_CELLS || + document.blocks.empty()) { + std::cerr << "separator-heavy tokenization escaped its bounded allocation contract\n"; + return 1; + } + + std::string exact = "`t\n"; + for (std::size_t row = 0; row <= DocumentParser::MAX_TABLE_ROWS; ++row) { + for (std::size_t column = 0; column < DocumentParser::MAX_TABLE_COLUMNS; ++column) { + if (column) exact += '|'; + exact += row == 1 ? "---" : "x"; + } + exact += '\n'; + } + exact += "`t"; + const auto boundary = parser.parse(exact); + if (boundary.tables.size() != 1 || + boundary.tables[0].row_count != DocumentParser::MAX_TABLE_ROWS || + boundary.tables[0].column_count != DocumentParser::MAX_TABLE_COLUMNS || + boundary.table_cells.size() != DocumentParser::MAX_TABLE_CELLS || + boundary.has_truncation(TruncationReason::TABLE_ROWS) || + boundary.has_truncation(TruncationReason::TABLE_CELLS)) { + std::cerr << "exact row/cell boundary changed semantics\n"; + return 1; + } + return 0; +} diff --git a/tests/native/test_nomadnet_table_limits.py b/tests/native/test_nomadnet_table_limits.py new file mode 100644 index 00000000..ade322bf --- /dev/null +++ b/tests/native/test_nomadnet_table_limits.py @@ -0,0 +1,32 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +INCLUDE = ROOT / "lib" / "tdeck_ui" / "UI" / "LXMF" + + +def _cxx(): + for name in ("clang++", "g++"): + if shutil.which(name): + return name + pytest.skip("no C++ compiler found") + + +def test_table_tokenization_and_exact_boundaries_under_sanitizers(tmp_path): + binary = tmp_path / "test_nomadnet_table_limits" + command = [ + _cxx(), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", + f"-I{INCLUDE}", str(HERE / "test_nomadnet_table_limits.cpp"), + str(INCLUDE / "NomadNetDocument.cpp"), "-o", str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30, + env={"ASAN_OPTIONS": "detect_leaks=1:halt_on_error=1", + "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1"}) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/native/test_nomadnet_x86_flow.py b/tests/native/test_nomadnet_x86_flow.py index 37040ead..c6094405 100644 --- a/tests/native/test_nomadnet_x86_flow.py +++ b/tests/native/test_nomadnet_x86_flow.py @@ -1,5 +1,7 @@ import os +import re import subprocess +import shutil from pathlib import Path HERE = Path(__file__).resolve().parent @@ -33,8 +35,26 @@ def test_nomadnet_x86_real_peer_flow(): timeout=180, ) assert result.returncode == 0, result.stdout + result.stderr - assert result.stdout.count("SCENARIO ") == 9 - assert result.stdout.count(": PASS server=0 client=0") == 9 + scenario_records = re.findall( + r"^SCENARIO ([a-z-]+): (PASS|FAIL) server=(-?\d+) client=(-?\d+)$", + result.stdout, flags=re.MULTILINE) + assert len(scenario_records) == 10 + assert len({record[0] for record in scenario_records}) == 10 + assert all(record[1:] == ("PASS", "0", "0") for record in scenario_records) + result_records = re.findall(r"^RESULT ([^\n]+)$", result.stdout, flags=re.MULTILINE) + assert len(result_records) == 10 + parsed_results = {} + for record in result_records: + fields = record.split() + values = {} + for field in fields: + assert field.count("=") == 1, record + key, value = field.split("=", 1) + assert key not in values, record + values[key] = value + assert "scenario" in values and values["scenario"] not in parsed_results + assert values.get("passed") == "1" + parsed_results[values["scenario"]] = values assert "anonymous=True" in result.stdout assert "EVENT oversized transfer=" in result.stdout assert "SCENARIO reuse: PASS server=0 client=0" in result.stdout @@ -42,9 +62,16 @@ def test_nomadnet_x86_real_peer_flow(): assert "link_callbacks=1" in result.stdout assert "SCENARIO form-anonymous: PASS server=0 client=0" in result.stdout assert "SCENARIO form-identified: PASS server=0 client=0" in result.stdout + assert "SCENARIO owner-form-history: PASS server=0 client=0" in result.stdout + owner = parsed_results["owner-form-history"] + assert owner["owner_submit"] == "1" + assert owner["history_bytes"] == "1" + assert owner["retained_link"] == "1" + assert owner["back_restored"] == "1" + assert owner["reload_reused"] == "1" assert "SERVER PASS exact form request data anonymous=True" in result.stdout assert "SERVER PASS exact form request data anonymous=False" in result.stdout - assert "REFERENCE NomadNet 89e3eea10c60d8fe597d36d2e091d5aab86bdfb8" in result.stdout + assert "REFERENCE NomadNet Git 89e3eea10c60d8fe597d36d2e091d5aab86bdfb8 hash-pinned" in result.stdout assert "REFERENCE RNS 1.4.2" in result.stdout assert "REFERENCE RNS tree b5398e7bae0cdd47212e0c6bff3f3a51b21012db0c23cb20d43b5103612f6c5e" in result.stdout assert "REFERENCE Browser.handle_link oracle: PASS" in result.stdout @@ -84,6 +111,9 @@ def test_x86_flow_proves_exact_form_maps_for_anonymous_and_identified_links(): assert '"form-anonymous"' in runner and '"form-identified"' in runner assert '89e3eea10c60d8fe597d36d2e091d5aab86bdfb8' in runner assert 'nomadnet/ui/textui/Browser.py' in runner + assert '"nomadnet/Node.py"' in runner + assert 'git", "rev-parse", "HEAD"' in runner + assert 'NOMADNET_COMMIT' in runner assert 'RNS_VERSION = "1.4.2"' in server assert '"/page/form.mu"' in server assert '"var_fixed": "yes"' in server @@ -96,6 +126,100 @@ def test_x86_flow_proves_exact_form_maps_for_anonymous_and_identified_links(): assert '"${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp"' in cmake +def test_owner_real_peer_scenario_uses_production_owner_seams(): + runner = (ROOT / "tests/native/nomadnet_x86_flow/run_flow.py").read_text() + client = (ROOT / "tests/native/nomadnet_x86_flow/client.cpp").read_text() + owner = (ROOT / "lib/tdeck_ui/UI/LXMF/NomadNetOwner.h").read_text() + cmake = (ROOT / "tests/native/nomadnet_x86_flow/CMakeLists.txt").read_text() + assert '"owner-form-history"' in runner + assert "NomadNetOwner.h" in client + assert "OwnerController" in client + assert "owner.service" in client + assert "prepare_owner_form_request" not in client + assert "owner_service_submit" not in client + assert "NomadNetOwner.cpp" in cmake + assert "OwnerCommand service(" in owner + assert "static bool retain_active_link(" in owner + + +def test_x86_runner_rejects_unattested_external_client(tmp_path): + fake = tmp_path / "fake-client" + fake.write_text("#!/bin/sh\nexit 0\n") + fake.chmod(0o755) + result = subprocess.run( + ["python3", str(RUNNER), str(fake), "/bin/false", str(tmp_path)], + cwd=ROOT, capture_output=True, text=True, + ) + assert result.returncode != 0 + assert "client manifest" in result.stdout + result.stderr + + +def _configured_reference(): + value = os.environ.get("PYXIS_NOMADNET_REFERENCE_SOURCE") + if not value: + import pytest + pytest.skip("configured NomadNet reference is required for executable provenance matrix") + return Path(value) + + +def test_nomadnet_package_tree_provenance_accepts_exact_hashes_without_git(tmp_path): + source = _configured_reference() + package = tmp_path / "package" + shutil.copytree(source / "nomadnet", package / "nomadnet") + result = subprocess.run( + ["python3", str(RUNNER), "--verify-reference", str(package)], + cwd=ROOT, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + assert "REFERENCE NomadNet package 1.2.8 hash-pinned" in result.stdout + assert "REFERENCE PROVENANCE: PASS" in result.stdout + + +def test_nomadnet_package_tree_provenance_rejects_wrong_hash(tmp_path): + source = _configured_reference() + package = tmp_path / "package" + shutil.copytree(source / "nomadnet", package / "nomadnet") + with (package / "nomadnet/Node.py").open("a") as stream: + stream.write("\n# stale\n") + result = subprocess.run( + ["python3", str(RUNNER), "--verify-reference", str(package)], + cwd=ROOT, capture_output=True, text=True) + assert result.returncode != 0 + assert "wrong NomadNet reference file hash for nomadnet/Node.py" in result.stdout + result.stderr + + +def test_nomadnet_git_tree_provenance_rejects_wrong_commit(tmp_path): + source = _configured_reference() + checkout = tmp_path / "checkout" + shutil.copytree(source / "nomadnet", checkout / "nomadnet") + subprocess.run(["git", "init", "-q"], cwd=checkout, check=True) + subprocess.run(["git", "add", "nomadnet"], cwd=checkout, check=True) + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "wrong provenance"], cwd=checkout, check=True) + result = subprocess.run( + ["python3", str(RUNNER), "--verify-reference", str(checkout)], + cwd=ROOT, capture_output=True, text=True) + assert result.returncode != 0 + assert "wrong NomadNet reference commit" in result.stdout + result.stderr + + +def test_x86_runner_rejects_stale_attested_client_when_configured(tmp_path): + client_text = os.environ.get("PYXIS_NOMADNET_X86_CLIENT") + if not client_text: + import pytest + pytest.skip("configured x86 client is required for stale attestation test") + stale_root = tmp_path / "root" + shutil.copytree(ROOT, stale_root, ignore=shutil.ignore_patterns(".git", ".pio")) + with (stale_root / "lib/tdeck_ui/UI/LXMF/NomadNetOwner.cpp").open("a") as stream: + stream.write("\n// stale source\n") + stale_runner = stale_root / "tests/native/nomadnet_x86_flow/run_flow.py" + result = subprocess.run( + ["python3", str(stale_runner), client_text, "/bin/false", str(tmp_path)], + cwd=stale_root, capture_output=True, text=True) + assert result.returncode != 0 + assert "client manifest mismatch" in result.stdout + result.stderr + + def test_physical_lan_flow_captures_both_link_wire_boundaries_and_crypto_vector(): server = (ROOT / "tests/native/nomadnet_x86_flow/lan_server.py").read_text() harness = (ROOT / "tests/hardware/nomadnet_tdeck_harness.py").read_text() diff --git a/tools/resolve_lvgl.py b/tools/resolve_lvgl.py new file mode 100755 index 00000000..b4b198d3 --- /dev/null +++ b/tools/resolve_lvgl.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Resolve and attest the exact LVGL tree used by the native acceptance test. + +The digest input is the lexicographically sorted POSIX-relative path set made of +all ``src/**/*.c`` and ``src/**/*.h`` files (every source/header compiled or +included by LVGL's CMake target), plus the root build/public headers and library +metadata listed in ``ROOT_FILES``. Each record is ``relative_path + NUL + +file_bytes`` with no extra delimiter. Generated build files, examples, demos, +documentation and VCS metadata are deliberately excluded from this stable set. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +LVGL_VERSION = "8.4.0" +LVGL_VANILLA_TREE_SHA256 = "9a6ca0d597f4a17792c04a19104482c65e5af20401414a3c393c8d8a6a175a47" +LVGL_TREE_SHA256 = "be9f52b7aa9ca3fe379569cdf59fe43ebd9e676bc775b72b86cde91c59808ec2" +DEFAULT_PLATFORMIO = Path("/tmp/pyxis-platformio/bin/pio") +ROOT_FILES = ( + "CMakeLists.txt", + "library.json", + "library.properties", + "lv_conf_template.h", + "lvgl.h", +) + + +class ResolutionError(RuntimeError): + pass + + +def stable_source_files(lvgl: Path) -> list[Path]: + files = [lvgl / relative for relative in ROOT_FILES] + src = lvgl / "src" + files.extend(path for path in src.rglob("*") if path.is_file() and path.suffix in {".c", ".h"}) + missing = [path for path in files if not path.is_file()] + if missing: + raise ResolutionError(f"LVGL attested source set is incomplete: {missing[0]}") + return sorted(files, key=lambda path: path.relative_to(lvgl).as_posix()) + + +def source_tree_digest(lvgl: Path) -> str: + digest = hashlib.sha256() + for path in stable_source_files(lvgl): + relative = path.relative_to(lvgl).as_posix().encode("utf-8") + digest.update(relative) + digest.update(b"\0") + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def verify_version(lvgl: Path) -> None: + metadata_path = lvgl / "library.json" + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ResolutionError(f"cannot read LVGL library metadata: {error}") from error + version = metadata.get("version") + if version != LVGL_VERSION: + raise ResolutionError(f"expected LVGL version {LVGL_VERSION}, found {version}") + + +def apply_repository_patch(root: Path, lvgl: Path) -> None: + patch_script = root / "patch_lvgl_textarea.py" + if not patch_script.is_file(): + raise ResolutionError(f"repository LVGL patch script is missing: {patch_script}") + completed = subprocess.run( + [sys.executable, str(patch_script), "--lvgl-root", str(lvgl)], + cwd=root, check=False, + ) + if completed.returncode != 0: + raise ResolutionError( + f"repository LVGL patch failed with exit {completed.returncode}" + ) + + +def resolve(root: Path) -> tuple[Path, str]: + lvgl = root / ".pio" / "libdeps" / "tdeck" / "lvgl" + if not lvgl.is_dir(): + pio = Path(os.environ.get("PYXIS_PLATFORMIO_BIN", str(DEFAULT_PLATFORMIO))) + if not pio.is_file() or not os.access(pio, os.X_OK): + raise ResolutionError( + f"LVGL is missing and PlatformIO is not executable at {pio}; " + "set PYXIS_PLATFORMIO_BIN to the CI PlatformIO executable" + ) + completed = subprocess.run( + [str(pio), "pkg", "install", "-e", "tdeck"], cwd=root, check=False + ) + if completed.returncode != 0: + raise ResolutionError( + f"PlatformIO dependency resolution failed with exit {completed.returncode}" + ) + if not lvgl.is_dir(): + raise ResolutionError(f"PlatformIO completed but did not create {lvgl}") + verify_version(lvgl) + before = source_tree_digest(lvgl) + if before not in {LVGL_VANILLA_TREE_SHA256, LVGL_TREE_SHA256}: + raise ResolutionError( + "LVGL source digest mismatch before patch: expected vanilla " + f"{LVGL_VANILLA_TREE_SHA256} or patched {LVGL_TREE_SHA256}, found {before}" + ) + # The only executable used after package resolution is the repository's + # narrowly-scoped LVGL patcher in standalone mode. PlatformIO build hooks + # and unrelated dependency patches are never invoked by this resolver. + apply_repository_patch(root, lvgl) + actual = source_tree_digest(lvgl) + if actual != LVGL_TREE_SHA256: + raise ResolutionError( + f"LVGL source digest mismatch after patch: expected {LVGL_TREE_SHA256}, found {actual}" + ) + return lvgl, actual + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + try: + lvgl, digest = resolve(args.root.resolve()) + except ResolutionError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(f"LVGL {LVGL_VERSION} {digest} {lvgl}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())