From aad27d0718112b6394c152ce411d7a06dfeee386 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:10:02 +0300 Subject: [PATCH 01/48] Report the real port status, and fix the desktop-port defects it was hiding The Port Status table showed almost every column as partial, skipped or stale. Most of that was the reporting pipeline, not the ports. Reporting - scripts/website/sync_port_status_reports.sh re-implemented the publish rule as a jq expression that demanded a measured duration for all ten performance workloads. iOS, tvOS and watchOS legitimately skip the three GC-footprint workloads on the simulator, so every fresh Apple report was rejected, the site served the checked-in fallback, and after fourteen days those four columns rendered as stale. The rule now lives once in port_status.py (publishable_report_problems + the "accept" subcommand) next to the normalizer whose own tests already covered skipped workloads. Contract drift keeps the fallback with a warning; a malformed report fails the website build instead of quietly degrading. - port-status-publish.yml only publishes when a workflow_run event reaches it, and those events never arrive for the Linux and Windows suites: the data branch has never held a linux or windows-x64 report. The nightly now runs backfill_port_status.sh, which publishes from the newest master run of every producing workflow and fails when a port has no report inside the contract's staleness window. - Only the Android pipeline failed on a failing compliance test. iOS, macOS and JavaScript now do too; all three are at zero failures, so this is a ratchet rather than a new red. - A skip the errata account for by name renders as a pass with a marked note instead of a partial, and the page validator refuses a noted cell whose test the errata do not cover. A run that stopped early no longer withdraws the result of a feature whose every mapped test reported back. - The checked-in reports are refreshed, including the real (failing) Linux and Windows results, so the fallback states what those ports actually do. Desktop ports - java.time asked the host for the rules of a fixed offset by handing "GMT-05:00" to the platform time zone database. POSIX inverts the sign of a TZ offset and the Windows CRT cannot parse the form at all, so every OffsetDateTime formatted through a pattern came out shifted by twice its offset. Custom GMT/UTC IDs are now resolved in Java, and a ZoneOffset never reaches the host database. - Offsets now come from TimeZone.getOffset rather than from Calendar, which reconstructs local time from a raw offset plus a flat one-hour daylight guess. - Character.getType threw UnsupportedOperationException, which meant isLetter, isLetterOrDigit and isJavaIdentifierStart/Part threw for every input on these ports. ASCII now has a category table and the rest answers from the primitives this runtime implements. - openInputStream returned a stream wrapping a null file handle for a missing path, so callers could not tell a missing file from an empty one (issue #1502 on both desktop ports); openOutputStream and the storage streams silently discarded writes the same way. - The Linux port carried the Windows port's backslash path join, so its staged-resource fallback never resolved. - CameraApiTest treated the native Linux port as having a headless camera. It drives real V4L2 devices through GStreamer, which a hosted runner does not have, so it now skips with a stated reason like the other native ports. - The Linux capture harness accepts CN1_REQUIRE_SUITE, so it can demand the suite's own completion marker rather than stopping when screenshots go quiet while trailing tests are still queued. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/port-status-nightly.yml | 22 +- .github/workflows/scripts-ios.yml | 20 ++ .github/workflows/scripts-javascript.yml | 5 + .github/workflows/scripts-mac-native.yml | 5 + .../impl/linux/LinuxImplementation.java | 52 ++++- .../impl/windows/WindowsImplementation.java | 42 +++- .../assets/css/extended/cn1-port-status.css | 11 + .../website/data/port_status_environment.json | 8 +- .../data/port_status_reports/android.json | 151 +++++++------- .../data/port_status_reports/ios-gl.json | 143 ++++++------- .../data/port_status_reports/ios-metal.json | 143 ++++++------- .../data/port_status_reports/javascript.json | 158 +++++++------- .../data/port_status_reports/linux-arm64.json | 190 +++++++++-------- .../data/port_status_reports/linux-x64.json | 190 +++++++++-------- .../data/port_status_reports/mac-native.json | 151 +++++++------- .../data/port_status_reports/tvos.json | 143 ++++++------- .../data/port_status_reports/watchos.json | 96 ++++++--- .../data/port_status_reports/windows-x64.json | 195 ++++++++++-------- .../website/layouts/_default/port-status.html | 5 +- .../partials/port-status-feature-status.html | 39 +++- .../hellocodenameone/tests/CameraApiTest.java | 16 +- .../conformance/backfill_port_status.sh | 159 ++++++++++++++ .../conformance/port_status.py | 117 +++++++++++ .../conformance/test_port_status.py | 97 +++++++++ scripts/website/sync_port_status_reports.sh | 46 +++-- scripts/website/validate_port_status.mjs | 17 ++ vm/JavaAPI/src/java/lang/Character.java | 78 +++++-- vm/JavaAPI/src/java/time/DateTimeSupport.java | 27 ++- vm/JavaAPI/src/java/util/TimeZone.java | 80 ++++++- .../CleanTargetLinuxIntegrationTest.java | 20 +- 30 files changed, 1607 insertions(+), 819 deletions(-) create mode 100755 scripts/hellocodenameone/conformance/backfill_port_status.sh diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml index 3d22b52cb5d..f377bede1b3 100644 --- a/.github/workflows/port-status-nightly.yml +++ b/.github/workflows/port-status-nightly.yml @@ -11,6 +11,24 @@ permissions: contents: write jobs: + # port-status-publish.yml only publishes a report when a workflow_run event + # reaches it, and those events never arrive for some producers -- the Linux + # and Windows suites had not landed a single report, so the public table + # served their checked-in fallback until it aged out and the columns rendered + # as unknown. This sweep publishes from the newest master run of every + # producing workflow and fails when a port has no report inside the + # contract's staleness window. + publish-latest-port-reports: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - name: Publish the newest master report for every port + env: + GH_TOKEN: ${{ github.token }} + run: scripts/hellocodenameone/conformance/backfill_port_status.sh + build-javascript-app: runs-on: ubuntu-latest timeout-minutes: 60 @@ -81,8 +99,10 @@ jobs: if-no-files-found: error publish-browser-evidence: + # Ordered after the report sweep (and tolerant of it failing) so the site + # rebuild at the end of this job picks up everything published tonight. if: always() && needs.build-javascript-app.result == 'success' - needs: [build-javascript-app, browser-lifecycle] + needs: [build-javascript-app, browser-lifecycle, publish-latest-port-reports] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index fee02d9c421..cacc624a7ab 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -104,6 +104,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-ios-app.sh writes it as a bundled resource so # the GoogleWebMap screenshot test renders a live Google map; absent (e.g. @@ -297,6 +302,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-ios-app.sh writes it as a bundled resource so # the GoogleWebMap screenshot test renders a live Google map; absent (e.g. @@ -578,6 +588,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-ios-app.sh writes it as a bundled resource so # the GoogleWebMap screenshot test renders a live Google map; absent (e.g. @@ -745,6 +760,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} steps: diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index 333d71cabd1..ee008b610fb 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -77,6 +77,11 @@ jobs: runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/javascript-ui-tests # CN1_JS_TIMEOUT_SECONDS guards the per-suite SUITE:FINISHED wait. diff --git a/.github/workflows/scripts-mac-native.yml b/.github/workflows/scripts-mac-native.yml index 9ad0939de6d..709a368e114 100644 --- a/.github/workflows/scripts-mac-native.yml +++ b/.github/workflows/scripts-mac-native.yml @@ -93,6 +93,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-mac-native-app.sh writes it as a bundled # resource so the GoogleWebMap screenshot test renders a live Google map; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 431d1775b57..1d82f0e51b9 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -39,6 +39,7 @@ import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -2363,8 +2364,8 @@ public int getContentLength(Object connection) { @Override public OutputStream openOutputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = LinuxNative.fileOpenWrite(stripFileUrl((String) connection), false); - return new LinuxOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new LinuxOutputStream(openForWrite(path, false), false); } return new LinuxOutputStream(((LinuxHttpConnection) connection).peer, true); } @@ -2373,23 +2374,44 @@ public OutputStream openOutputStream(Object connection) throws IOException { public OutputStream openOutputStream(Object connection, int offset) throws IOException { // offset-based writing maps to opening the file for append/seek; the // first cut appends, which covers the common resume-write case. - long h = LinuxNative.fileOpenWrite(stripFileUrl((String) connection), true); - return new LinuxOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new LinuxOutputStream(openForWrite(path, true), false); } @Override public InputStream openInputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = LinuxNative.fileOpenRead(stripFileUrl((String) connection)); + String path = stripFileUrl((String) connection); + long h = LinuxNative.fileOpenRead(path); + if (h == 0) { + // fopen() returns NULL for a missing (or unreadable) path. + // Wrapping that handle produced a stream that read as a + // legitimately empty file, so callers could not tell a missing + // file from an empty one -- the exact defect issue #1502 + // reported against iOS. + throw new FileNotFoundException("No such file: " + path); + } return new LinuxInputStream(h, false); } return new LinuxInputStream(((LinuxHttpConnection) connection).peer, true); } + /// Opens `path` for writing, failing loudly when the platform cannot. A + /// null handle otherwise yields a stream that discards every write and + /// closes cleanly, which turns an unwritable path into a file that simply + /// never appears. + private long openForWrite(String path, boolean append) throws IOException { + long h = LinuxNative.fileOpenWrite(path, append); + if (h == 0) { + throw new IOException("Unable to open " + path + " for writing"); + } + return h; + } + /** * Resolves a classpath-style resource (e.g. {@code /theme.res}). The ParparVM - * linux target embeds the app's classpath resources into the executable's PE - * resource section, so they are served straight from the exe -- a single + * linux target embeds the app's classpath resources into the executable's + * data section, so they are served straight from the ELF -- a single * self-contained binary, the Linux analog of the iOS .app bundle. Falls back * to a file shipped next to the executable (a dev/debug convenience for * resources that were staged rather than embedded). Returns null when absent. @@ -2407,8 +2429,11 @@ public InputStream getResourceAsStream(Class cls, String resource) { if (dir == null) { return null; } + // Classpath resources are already '/'-separated, which is what the + // filesystem wants here; this port was carrying the Windows port's + // backslash join, so the staged-resource fallback never resolved. String name = resource.startsWith("/") ? resource.substring(1) : resource; - String path = dir + "\\" + name.replace('/', '\\'); + String path = dir + "/" + name; long h = LinuxNative.fileOpenRead(path); if (h == 0) { return null; @@ -2584,13 +2609,18 @@ public void deleteStorageFile(String name) { @Override public OutputStream createStorageOutputStream(String name) throws IOException { - long h = LinuxNative.fileOpenWrite(storagePath(name), false); - return new LinuxOutputStream(h, false); + // Same reason as openOutputStream: a discarded write that reports + // success loses the entry instead of reporting that it cannot be saved. + return new LinuxOutputStream(openForWrite(storagePath(name), false), false); } @Override public InputStream createStorageInputStream(String name) throws IOException { - long h = LinuxNative.fileOpenRead(storagePath(name)); + String path = storagePath(name); + long h = LinuxNative.fileOpenRead(path); + if (h == 0) { + throw new FileNotFoundException("No such storage entry: " + name); + } return new LinuxInputStream(h, false); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 41d720777da..da0c0ba313a 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -39,6 +39,7 @@ import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -2377,8 +2378,8 @@ public int getContentLength(Object connection) { @Override public OutputStream openOutputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = WindowsNative.fileOpenWrite(stripFileUrl((String) connection), false); - return new WindowsOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new WindowsOutputStream(openForWrite(path, false), false); } return new WindowsOutputStream(((WindowsHttpConnection) connection).peer, true); } @@ -2387,19 +2388,39 @@ public OutputStream openOutputStream(Object connection) throws IOException { public OutputStream openOutputStream(Object connection, int offset) throws IOException { // offset-based writing maps to opening the file for append/seek; the // first cut appends, which covers the common resume-write case. - long h = WindowsNative.fileOpenWrite(stripFileUrl((String) connection), true); - return new WindowsOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new WindowsOutputStream(openForWrite(path, true), false); } @Override public InputStream openInputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = WindowsNative.fileOpenRead(stripFileUrl((String) connection)); + String path = stripFileUrl((String) connection); + long h = WindowsNative.fileOpenRead(path); + if (h == 0) { + // A null handle otherwise produced a stream that read as a + // legitimately empty file, so callers could not tell a missing + // file from an empty one -- the exact defect issue #1502 + // reported against iOS. + throw new FileNotFoundException("No such file: " + path); + } return new WindowsInputStream(h, false); } return new WindowsInputStream(((WindowsHttpConnection) connection).peer, true); } + /// Opens `path` for writing, failing loudly when the platform cannot. A + /// null handle otherwise yields a stream that discards every write and + /// closes cleanly, which turns an unwritable path into a file that simply + /// never appears. + private long openForWrite(String path, boolean append) throws IOException { + long h = WindowsNative.fileOpenWrite(path, append); + if (h == 0) { + throw new IOException("Unable to open " + path + " for writing"); + } + return h; + } + /** * Resolves a classpath-style resource (e.g. {@code /theme.res}). The ParparVM * windows target embeds the app's classpath resources into the executable's PE @@ -2598,13 +2619,18 @@ public void deleteStorageFile(String name) { @Override public OutputStream createStorageOutputStream(String name) throws IOException { - long h = WindowsNative.fileOpenWrite(storagePath(name), false); - return new WindowsOutputStream(h, false); + // Same reason as openOutputStream: a discarded write that reports + // success loses the entry instead of reporting that it cannot be saved. + return new WindowsOutputStream(openForWrite(storagePath(name), false), false); } @Override public InputStream createStorageInputStream(String name) throws IOException { - long h = WindowsNative.fileOpenRead(storagePath(name)); + String path = storagePath(name); + long h = WindowsNative.fileOpenRead(path); + if (h == 0) { + throw new FileNotFoundException("No such storage entry: " + name); + } return new WindowsInputStream(h, false); } diff --git a/docs/website/assets/css/extended/cn1-port-status.css b/docs/website/assets/css/extended/cn1-port-status.css index 16c4e78741b..20e7179f398 100644 --- a/docs/website/assets/css/extended/cn1-port-status.css +++ b/docs/website/assets/css/extended/cn1-port-status.css @@ -271,6 +271,17 @@ .cn1-port-status__mark.is-fallback { color: #3976c5; } .cn1-port-status__mark.is-unavailable { color: #7d8791; } +/* Marks a pass whose only non-passing test is a skip the errata explain, so + the cell reads as green without hiding that something was not executed. */ +.cn1-port-status__note { + color: #27955b; + font-size: .8rem; + font-weight: 800; + line-height: 1; + margin-left: .1rem; + vertical-align: super; +} + .cn1-port-status__ports { display: grid; gap: .75rem; diff --git a/docs/website/data/port_status_environment.json b/docs/website/data/port_status_environment.json index 8ab8ebc7aa2..65ad2826aa8 100644 --- a/docs/website/data/port_status_environment.json +++ b/docs/website/data/port_status_environment.json @@ -1,19 +1,19 @@ { "schema_version": 1, - "generated_at": "2026-07-16T15:45:26Z", - "commit": "9df6f7a1ea0e10a19c84b8994725d9c9d9dd9899", + "generated_at": "2026-07-30T05:51:23Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", "browsers": [ { "id": "chromium", "name": "Chromium", - "engine_version": "149.0.7827.55", + "engine_version": "151.0.7922.34", "status": "pass", "coverage": "Full compliance suite plus nightly lifecycle validation" }, { "id": "firefox", "name": "Firefox", - "engine_version": "151.0", + "engine_version": "153.0", "status": "pass", "coverage": "Nightly lifecycle validation" }, diff --git a/docs/website/data/port_status_reports/android.json b/docs/website/data/port_status_reports/android.json index fb044632f8f..e0662657dc1 100644 --- a/docs/website/data/port_status_reports/android.json +++ b/docs/website/data/port_status_reports/android.json @@ -1,14 +1,64 @@ { - "commit": "b51436a94d6451eae6b2673e1ff48eb88003c142", - "generated_at": "2026-07-16T15:09:30Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:16:00Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 257653628 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 35267214 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 327725521 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 203997199 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 141909697 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 4377145955 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 152307139 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 172021740 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 763524967 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 295697914 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "android", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29508571507", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977096", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 164, + "not-run": 0, + "pass": 169, "skip": 1 }, "tests": { @@ -72,6 +122,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +221,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +393,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +409,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +517,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -675,76 +741,9 @@ "feature": "video-round-trip", "status": "pass" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 270971236 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 33987228 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 306089084 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 239946517 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 174849906 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 5031760674 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 145574439 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 151462539 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 743204825 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 307991556 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/ios-gl.json b/docs/website/data/port_status_reports/ios-gl.json index 56dceeb7689..894d5f63a56 100644 --- a/docs/website/data/port_status_reports/ios-gl.json +++ b/docs/website/data/port_status_reports/ios-gl.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:54:52Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T09:24:29Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 334687000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 21787000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 122655000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 74123000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 322920000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 134806000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 175512000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "ios-gl", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 260829000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 20455000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 39588000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 106287000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 65812000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 288774000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 218533000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 126690000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 164612000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 47528000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/ios-metal.json b/docs/website/data/port_status_reports/ios-metal.json index 41e6d8bb21c..0af514c1187 100644 --- a/docs/website/data/port_status_reports/ios-metal.json +++ b/docs/website/data/port_status_reports/ios-metal.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:06:47Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T10:12:12Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 346696000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 24678000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 119904000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 73599000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 388630000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 126606000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 178679000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "ios-metal", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 440011000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 26479000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 47714000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 135175000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 76514000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 410277000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 367596000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 129907000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 246493000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 86101000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/javascript.json b/docs/website/data/port_status_reports/javascript.json index a31881d039b..b0152b54e94 100644 --- a/docs/website/data/port_status_reports/javascript.json +++ b/docs/website/data/port_status_reports/javascript.json @@ -1,15 +1,65 @@ { - "commit": "4a3f5807e0850131483b20b45d3802c9c42f7fd7", - "generated_at": "2026-07-16T14:48:44Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:30:48Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 1486700000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 851400000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 14321000000 + }, + "intArithmetic": { + "checksum": "1313580095284", + "duration_ns": 705300000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 3920500000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 586000000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 2741500000 + }, + "quicksort": { + "checksum": "786886890168670967", + "duration_ns": 576800000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 1123899999 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 1223900000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "javascript", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29505603266", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977438", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 164, - "skip": 1 + "not-run": 0, + "pass": 170, + "skip": 0 }, "tests": { "ARApiTest": { @@ -72,16 +122,17 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "reasons": [ - "needs-runtime-permission-on-HTML5" - ], - "status": "skip" + "status": "pass" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -167,11 +218,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +390,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +406,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +514,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -675,76 +738,9 @@ "feature": "video-round-trip", "status": "pass" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 1454299999 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 956000000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 15788000000 - }, - "intArithmetic": { - "checksum": "1313580095284", - "duration_ns": 721800001 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 3997400000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 300100000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 3617099999 - }, - "quicksort": { - "checksum": "786886890168670967", - "duration_ns": 577900001 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 1071299999 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 1333500000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/linux-arm64.json b/docs/website/data/port_status_reports/linux-arm64.json index e2858afe2d2..d0b356331c1 100644 --- a/docs/website/data/port_status_reports/linux-arm64.json +++ b/docs/website/data/port_status_reports/linux-arm64.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:38:03Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:22:38Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 170452739 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 26587685 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 35381384 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 62814185 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 39201326 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 270735594 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 4175763773 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 94506846 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 132546281 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 44943794 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "linux-arm64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496888305", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977091", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, + "fail": 7, + "not-run": 2, + "pass": 161, "skip": 0 }, "tests": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -62,7 +115,10 @@ }, "BrowserComponentScreenshotTest": { "feature": "embedded-web-content", - "status": "pass" + "reasons": [ + "failed due to timeout waiting for DONE stage=show-completed" + ], + "status": "fail" }, "ButtonThemeScreenshotTest": { "feature": "native-theme-controls", @@ -72,13 +128,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "failed: Camera.getCameras() returned no cameras" + ], + "status": "fail" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +227,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +253,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +332,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.linux.LinuxInputStream@FF4236CFD0F0) for a missing path /home/runner/.local/share/codenameone/this-file-must-not-exist-1502-1785438836814.bin instead of throwing. Platform=linux" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +405,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -348,6 +421,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -452,6 +529,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +639,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -610,7 +694,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T11:30:00-05:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -666,82 +753,15 @@ }, "VideoIODecodedFramesScreenshotTest": { "feature": "video-decoding", - "status": "pass" + "status": "not-run" }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 167647092 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 26067574 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 29352127 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 62775877 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 39325844 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 273404082 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 4638409365 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 91416037 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 137627018 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 36440861 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/linux-x64.json b/docs/website/data/port_status_reports/linux-x64.json index 91949b47702..765104c021c 100644 --- a/docs/website/data/port_status_reports/linux-x64.json +++ b/docs/website/data/port_status_reports/linux-x64.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:37:58Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:22:35Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 264299278 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 30422427 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 47879836 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 79764388 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 60672355 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 264162804 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 3044145335 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 136000546 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 181468088 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 37993300 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "linux-x64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496888305", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977091", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, + "fail": 7, + "not-run": 2, + "pass": 161, "skip": 0 }, "tests": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -62,7 +115,10 @@ }, "BrowserComponentScreenshotTest": { "feature": "embedded-web-content", - "status": "pass" + "reasons": [ + "failed due to timeout waiting for DONE stage=show-completed" + ], + "status": "fail" }, "ButtonThemeScreenshotTest": { "feature": "native-theme-controls", @@ -72,13 +128,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "failed: Camera.getCameras() returned no cameras" + ], + "status": "fail" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +227,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +253,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +332,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.linux.LinuxInputStream@7FF238EAE6B0) for a missing path /home/runner/.local/share/codenameone/this-file-must-not-exist-1502-1785438759604.bin instead of throwing. Platform=linux" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +405,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -348,6 +421,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -452,6 +529,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +639,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -610,7 +694,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T11:30:00-05:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -666,82 +753,15 @@ }, "VideoIODecodedFramesScreenshotTest": { "feature": "video-decoding", - "status": "pass" + "status": "not-run" }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 253856480 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 28233661 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 27566288 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 82153005 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 53960096 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 184573752 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 5754321719 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 148385092 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 183272160 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 33385972 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/mac-native.json b/docs/website/data/port_status_reports/mac-native.json index d7792f7aa4b..3c9527cf45b 100644 --- a/docs/website/data/port_status_reports/mac-native.json +++ b/docs/website/data/port_status_reports/mac-native.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:13:15Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T20:36:03Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 249161000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 20743000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 58460000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 106862000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 67221000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 281716000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 261799000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 126419000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 171015000 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 60775000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "mac-native", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889418", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977464", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +122,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +221,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +393,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +409,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +517,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +744,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 284063000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 20505000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 40363000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 107821000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 65412000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 286132000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 328540000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 111802000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 167855000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 24303000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/tvos.json b/docs/website/data/port_status_reports/tvos.json index d9c49c0ec83..6eafc87c9ea 100644 --- a/docs/website/data/port_status_reports/tvos.json +++ b/docs/website/data/port_status_reports/tvos.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:10:57Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T10:23:02Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 323661000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 148797000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 168944000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 102844000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 400868000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 324035000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 680592000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "tvos", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 558066000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 209285000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 183568000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 181322000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 109270000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 727584000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 266922000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 331650000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 718026000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 154040000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/watchos.json b/docs/website/data/port_status_reports/watchos.json index b2bdbfad26b..e95b838515d 100644 --- a/docs/website/data/port_status_reports/watchos.json +++ b/docs/website/data/port_status_reports/watchos.json @@ -1,14 +1,56 @@ { - "commit": "dec3d172f6fe327798cc083f2bab03a98cf9a8ac", - "generated_at": "2026-07-15T01:51:30Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T09:36:29Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 426870000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 193328000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 200087000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 114148000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 488586000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 319774000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 710894000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "watchos", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29380730117", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,27 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success" + } } diff --git a/docs/website/data/port_status_reports/windows-x64.json b/docs/website/data/port_status_reports/windows-x64.json index 1530b2cc8e0..af3bceabef5 100644 --- a/docs/website/data/port_status_reports/windows-x64.json +++ b/docs/website/data/port_status_reports/windows-x64.json @@ -1,15 +1,65 @@ { - "commit": "d17e18eb583d0fa7f433e3ad3e293264475b49ad", - "generated_at": "2026-07-16T17:21:16Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:12:27Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 206041000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 21366000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 49883000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 69671000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 53309000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 137248000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 2387343000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 121121000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 166653000 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 45653000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "windows-x64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29515915384", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977214", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, - "skip": 0 + "fail": 7, + "not-run": 2, + "pass": 160, + "skip": 1 }, "tests": { "ARApiTest": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -72,13 +125,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "needs-runtime-permission-on-win" + ], + "status": "skip" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +224,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +250,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +329,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.windows.WindowsInputStream@27714760B90) for a missing path C:\\null\\this-file-must-not-exist-1502-1785438531532.bin instead of throwing. Platform=win" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +402,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -346,6 +416,14 @@ }, "KotlinUiTest": { "feature": "application-bootstrap", + "reasons": [ + "failed=java.lang.NullPointerException", + "failed: java.lang.NullPointerException" + ], + "status": "fail" + }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", "status": "pass" }, "LargeStrokeDirtyClipTest": { @@ -452,6 +530,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +640,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -578,7 +663,11 @@ }, "SwitchThemeScreenshotTest": { "feature": "native-theme-controls", - "status": "pass" + "reasons": [ + "failed=java.lang.NullPointerException", + "failed due to timeout waiting for DONE stage=created" + ], + "status": "fail" }, "TabsAnimatedIndicatorScreenshotTest": { "feature": "tabs-animation", @@ -610,7 +699,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T06:30:00+01:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -670,78 +762,11 @@ }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 290140000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 24989000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 61276000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 78850000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 60657000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 175326000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 4421996000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 135399000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 242775000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 32223000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html index 8b6c9514160..3c736ae1642 100644 --- a/docs/website/layouts/_default/port-status.html +++ b/docs/website/layouts/_default/port-status.html @@ -86,7 +86,8 @@

{{ .name }}

Passed - Partial or skipped + Passed, with a skip the errata account for + Incomplete run or an unexplained skip Failed No current report
@@ -143,7 +144,7 @@

{{ .name }}

{{- range $contract.ports }} {{- $report := index $reports .id -}} - {{- partial "port-status-feature-status.html" (dict "feature" $feature "port" . "report" $report "contract" $contract "now" $snapshotTime) -}} + {{- partial "port-status-feature-status.html" (dict "feature" $feature "port" . "report" $report "contract" $contract "supplement" $supplement "now" $snapshotTime) -}} {{- end }} {{- end }} diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 5d1572ef68e..e33765bb811 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -2,16 +2,19 @@ {{- $port := .port -}} {{- $report := .report -}} {{- $contract := .contract -}} +{{- $supplement := .supplement -}} {{- $now := .now -}} {{- $state := "unknown" -}} {{- $mark := "?" -}} {{- $label := "No current report" -}} +{{- $documentedSkips := slice -}} {{- if $report -}} {{- $passed := 0 -}} {{- $failed := 0 -}} {{- $skipped := 0 -}} {{- $notRun := 0 -}} {{- $failedTests := slice -}} + {{- $skippedTests := slice -}} {{- range $feature.tests -}} {{- $result := index $report.tests . -}} {{- $status := "not-run" -}} @@ -23,26 +26,52 @@ {{- $failedTests = $failedTests | append . -}} {{- else if eq $status "skip" -}} {{- $skipped = add $skipped 1 -}} + {{- $skippedTests = $skippedTests | append . -}} {{- else -}} {{- $notRun = add $notRun 1 -}} {{- end -}} {{- end -}} + {{- /* A skip only reads as green when the errata below account for it by + name. An undocumented skip stays a partial result. */ -}} + {{- $documented := gt (len $skippedTests) 0 -}} + {{- range $skippedTests -}} + {{- $test := . -}} + {{- $found := false -}} + {{- range $supplement.skip_reasons -}} + {{- if eq .test $test -}}{{- $found = true -}}{{- end -}} + {{- end -}} + {{- if not $found -}}{{- $documented = false -}}{{- end -}} + {{- end -}} {{- $bootstrapComplete := and (eq $report.bootstrap_source "successful-master-workflow") (eq $report.workflow_conclusion "success") -}} {{- $complete := or $report.suite_finished $bootstrapComplete -}} {{- $total := len $feature.tests -}} {{- $state = "partial" -}} {{- $mark = "−" -}} {{- $label = printf "%d passed, %d skipped, %d not run" $passed $skipped $notRun -}} + {{- /* Evidence is per feature. A run that stopped early leaves its own + unreached tests as "not run" below, and the port card reports the + incomplete run; that is not a reason to withdraw the result of a + feature whose every mapped test reported back. */ -}} + {{- $incomplete := cond $complete "" " (the suite run stopped early)" -}} {{- if gt $failed 0 -}} {{- $state = "fail" -}} {{- $mark = "×" -}} {{- $label = printf "%d failed: %s" $failed (delimit $failedTests ", ") -}} - {{- else if not $complete -}} - {{- $label = "Suite did not finish" -}} {{- else if eq $passed $total -}} {{- $state = "pass" -}} {{- $mark = "✓" -}} - {{- $label = printf "All %d mapped test%s passed" $total (cond (eq $total 1) "" "s") -}} + {{- $label = printf "All %d mapped test%s passed%s" $total (cond (eq $total 1) "" "s") $incomplete -}} + {{- else if not $complete -}} + {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}} + {{- else if and $documented (eq (add $passed $skipped) $total) -}} + {{- $state = "pass" -}} + {{- $mark = "✓" -}} + {{- $documentedSkips = $skippedTests -}} + {{- if eq $passed 0 -}} + {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata" (delimit $skippedTests ", ") -}} + {{- else -}} + {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata" $passed $total (delimit $skippedTests ", ") -}} + {{- end -}} {{- else if eq $skipped $total -}} {{- $label = "All mapped tests skipped" -}} {{- end -}} @@ -53,12 +82,14 @@ {{- if ne $state "fail" -}} {{- $state = "stale" -}} {{- $mark = "!" -}} + {{- $documentedSkips = slice -}} {{- end -}} {{- $label = printf "Stale report. %s" $label -}} {{- end -}} {{- end -}} {{- $label = printf "%s: %s" $port.name $label -}} - + + {{- if $documentedSkips }}{{ end }} {{ $label }} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java index 0deb9eb3548..36ab771359f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java @@ -70,14 +70,22 @@ public boolean runTest() { // JavaSE uses its synthetic CameraImpl. The JavaScript Playwright runner // supplies Chromium's fake media device, which still exercises the real // HTML5 getUserMedia/video/canvas/JPEG path. Native mobile ports need an - // OS permission dialog, and Windows does not implement host webcam - // capture yet, so those remain outside this cross-port headless test. + // OS permission dialog, Windows does not implement host webcam capture + // yet, and the native Linux port drives real V4L2 devices through + // GStreamer -- a hosted runner has no camera to enumerate, so the + // assertion chain below would only ever report the absent hardware. + // Those ports stay outside this cross-port headless test and are + // covered by the camera erratum on the port status page. boolean isHeadlessCameraSupported = "HTML5".equals(platform) || (!"ios".equals(platform) && !"and".equals(platform) - && !"win".equals(platform)); + && !"win".equals(platform) + && !"linux".equals(platform)); if (!isHeadlessCameraSupported) { - System.out.println("CN1SS:INFO:test=CameraApiTest status=SKIPPED reason=needs-runtime-permission-on-" + platform); + String reason = "win".equals(platform) ? "no-host-webcam-capture-on-win" + : ("linux".equals(platform) ? "no-camera-device-on-headless-runner" + : "needs-runtime-permission-on-" + platform); + System.out.println("CN1SS:INFO:test=CameraApiTest status=SKIPPED reason=" + reason); done(); return true; } diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh new file mode 100755 index 00000000000..0ccfea5526f --- /dev/null +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# This code is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 only, as +# published by the Free Software Foundation. Codename One designates this +# particular file as subject to the "Classpath" exception as provided +# by Oracle in the LICENSE file that accompanied this code. +# +# This code is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# version 2 for more details (a copy is included in the LICENSE file that +# accompanied this code). +# +# You should have received a copy of the GNU General Public License version +# 2 along with this work; if not, write to the Free Software Foundation, +# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Please contact Codename One through http://www.codenameone.com/ if you +# need additional information or have any questions. + +set -euo pipefail + +# Publish the newest master report for every port in the compliance contract. +# +# port-status-publish.yml reacts to workflow_run events from the producing +# workflows. Those events are not delivered reliably for every producer: the +# Linux and Windows suites have never landed a single report that way, so the +# public table served a checked-in fallback for them until it aged past the +# staleness threshold and the whole column rendered as unknown. This sweep does +# not depend on an event arriving. It reads the newest completed master run of +# each producing workflow, takes the normalized report it uploaded, and +# publishes it when it is newer than the copy on the data branch. +# +# It finishes by asserting that every port has a report that is inside the +# contract's staleness window, so a producer that stops emitting reports fails +# here instead of quietly rotting on the website. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +MANIFEST="${REPO_ROOT}/docs/website/data/port_status.json" +DATA_BRANCH="port-status-data" + +for tool in gh jq python3; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "backfill-port-status: ${tool} is required." >&2 + exit 2 + fi +done + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "${tmp_dir}" +} +trap cleanup EXIT + +published=0 +skipped=0 + +# One producing workflow can own several ports (the iOS suite emits four), so +# sweep per workflow and let the report itself name the port it belongs to. +while IFS= read -r workflow; do + # Newest first, and a failed run counts: a suite that fails still uploads the + # normalized report, and a report that records real failures is the result + # the table is supposed to show. + candidates="$(gh run list --workflow "${workflow}" --branch master --limit 40 \ + --json databaseId,event,conclusion,updatedAt \ + --jq '[.[] | select((.event == "push" or .event == "schedule") and + (.conclusion == "success" or .conclusion == "failure"))] + | sort_by(.updatedAt) | reverse | .[0:5] | .[].databaseId')" + if [ -z "${candidates}" ]; then + echo "No completed master run for ${workflow}; nothing to publish." >&2 + continue + fi + + run_id="" + download_dir="${tmp_dir}/${workflow}" + mkdir -p "${download_dir}" + # A run that died before the suite reported uploads no artifact at all, and + # artifacts expire; walk back until one of the recent runs still has reports. + for candidate in ${candidates}; do + if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}" >/dev/null 2>&1; then + run_id="${candidate}" + break + fi + done + if [ -z "${run_id}" ]; then + echo "No recent ${workflow} run has a port status artifact." >&2 + continue + fi + + while IFS= read -r report; do + port="$(jq -r '.port // empty' "${report}")" + if [ -z "${port}" ]; then + echo "Ignoring ${report}: it names no port." >&2 + continue + fi + generated="$(jq -r '.generated_at // empty' "${report}")" + current="" + if gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ + --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/current.json" 2>/dev/null; then + current="$(jq -r '.generated_at // empty' "${tmp_dir}/current.json" 2>/dev/null || true)" + fi + if [ -n "${current}" ] && [[ ! "${generated}" > "${current}" ]]; then + skipped=$((skipped + 1)) + continue + fi + echo "Publishing ${port} from run ${run_id} of ${workflow} (${generated})." + PORT_STATUS_PUBLISH=1 "${SCRIPT_DIR}/publish_port_status.sh" "${report}" + published=$((published + 1)) + done < <(find "${download_dir}" -type f -name 'port-status-*.json' | sort) +done < <(jq -r '[.ports[].workflow] | unique | .[]' "${MANIFEST}") + +echo "Port status sweep: published ${published} report(s), ${skipped} already current." + +# Assert the outcome rather than trusting it: a port whose newest published +# report is outside the staleness window renders as unknown on the public +# table, which is exactly the failure this sweep exists to prevent. +stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")" +problems=() +while IFS= read -r port; do + if ! gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ + --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/check.json" 2>/dev/null; then + problems+=("${port}: no published report") + continue + fi + generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" + age_days="$(python3 - "${generated}" <<'PY' +import sys +from datetime import datetime, timezone + +raw = sys.argv[1] +try: + stamp = datetime.fromisoformat(raw.replace("Z", "+00:00")) +except ValueError: + print(-1) +else: + print(int((datetime.now(timezone.utc) - stamp).total_seconds() // 86400)) +PY +)" + if [ "${age_days}" -lt 0 ]; then + problems+=("${port}: unreadable generated_at ${generated:-}") + elif [ "${age_days}" -gt "${stale_days}" ]; then + problems+=("${port}: last report is ${age_days} days old (limit ${stale_days})") + fi +done < <(jq -r '.ports[].id' "${MANIFEST}") + +if [ ${#problems[@]} -gt 0 ]; then + echo "Ports without a current compliance report:" >&2 + printf ' %s\n' "${problems[@]}" >&2 + echo "Fix the producing workflow; the public table cannot report a port it never hears from." >&2 + exit 1 +fi + +echo "Every port in the contract has a report inside the ${stale_days}-day window." diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index e4e11980c26..70e6d382c15 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -25,6 +25,10 @@ ) COMMON_SOURCES = REPO_ROOT / "scripts/hellocodenameone/common/src/main" STRICT_GATE_FAILED = 10 +# "accept" exit codes: the caller keeps the checked-in fallback for both, but +# only an unusable report is a defect worth failing the website build over. +ACCEPT_CONTRACT_DRIFT = 11 +ACCEPT_UNUSABLE = 12 START_RE = re.compile(r"suite starting test=([A-Za-z0-9_]+)") FINISH_RE = re.compile(r"suite finished test=([A-Za-z0-9_]+)") @@ -581,6 +585,98 @@ def strict_report_errors(report: dict) -> list[str]: return errors +def publishable_report_problems( + manifest: dict, port_id: str, report: dict +) -> tuple[list[str], list[str]]: + """Decide whether a persisted report may replace the checked-in fallback. + + Returns (drift, malformed). Drift means the report is well formed but was + produced against a different revision of the test contract, which happens + for every port between the commit that registers a test and that port's + next master run; the caller keeps the checked-in report and waits. Anything + in malformed is a defect in the report or in the producer and must be loud: + silently falling back for those is what lets a whole column of the public + table rot into "stale" while the port itself is healthy. + """ + drift: list[str] = [] + malformed: list[str] = [] + + if report.get("schema_version") != manifest.get("schema_version"): + malformed.append( + f"schema version {report.get('schema_version')!r} is not " + f"{manifest.get('schema_version')!r}" + ) + if report.get("port") != port_id: + malformed.append(f"report identifies port {report.get('port')!r}") + generated_at = report.get("generated_at") + if not isinstance(generated_at, str) or not generated_at: + malformed.append("report has no generated_at timestamp") + + mapped = test_to_feature(manifest) + tests = report.get("tests") + if not isinstance(tests, dict): + malformed.append("report has no test result map") + tests = {} + else: + missing = sorted(set(mapped) - set(tests)) + unknown = sorted(set(tests) - set(mapped)) + if missing: + drift.append("report predates tests: " + ", ".join(missing)) + if unknown: + drift.append("report carries retired tests: " + ", ".join(unknown)) + + statuses = Counter() + for test, result in tests.items(): + if not isinstance(result, dict) or result.get("status") not in { + "pass", "fail", "skip", "not-run" + }: + malformed.append(f"invalid result for {test}") + continue + statuses[result["status"]] += 1 + expected_summary = { + key: statuses.get(key, 0) for key in ("pass", "fail", "skip", "not-run") + } + if report.get("summary") != expected_summary and not drift: + malformed.append("summary does not match the test results") + + expected_benchmarks = manifest.get("performance_benchmarks", []) + performance = report.get("performance") + if not isinstance(performance, dict): + malformed.append("report has no performance section") + return drift, malformed + if performance.get("status") != "complete": + malformed.append(f"performance run is {performance.get('status')!r}") + if performance.get("missing"): + malformed.append( + "performance workloads never reported: " + + ", ".join(performance["missing"]) + ) + + benchmarks = performance.get("benchmarks") + skipped = performance.get("skipped") or {} + if not isinstance(benchmarks, dict) or not isinstance(skipped, dict): + malformed.append("performance results are not objects") + return drift, malformed + + # A port may legitimately skip a workload (the iOS simulator skips the + # GC-footprint workloads); measured plus skipped has to cover the contract. + accounted = sorted(set(benchmarks) | set(skipped)) + if accounted != sorted(expected_benchmarks): + malformed.append( + "performance workloads do not match the contract: " + + ", ".join(accounted) + ) + for name, measurement in benchmarks.items(): + duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None + if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0: + malformed.append(f"{name} has no measured duration") + for name, reason in skipped.items(): + if not isinstance(reason, str) or not reason: + malformed.append(f"skipped workload {name} has no reason") + + return drift, malformed + + def utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -592,6 +688,13 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("validate", help="validate feature and screenshot coverage") + accept_parser = subparsers.add_parser( + "accept", + help="decide whether a persisted report may replace the checked-in fallback", + ) + accept_parser.add_argument("--port", required=True) + accept_parser.add_argument("--report", required=True, type=Path) + normalize_parser = subparsers.add_parser("normalize", help="write a normalized port report") normalize_parser.add_argument("--port", required=True) normalize_parser.add_argument("--log", action="append", type=Path, default=[]) @@ -621,6 +724,20 @@ def main() -> int: f"{counts['ports']} ports, {counts['goldens']} golden names." ) return 0 + if args.command == "accept": + drift, malformed = publishable_report_problems( + manifest, args.port, read_json(args.report) + ) + for problem in malformed: + print(f"port-status: {args.port} report is unusable: {problem}", file=sys.stderr) + for problem in drift: + print(f"port-status: {args.port} {problem}", file=sys.stderr) + if malformed: + return ACCEPT_UNUSABLE + if drift: + return ACCEPT_CONTRACT_DRIFT + print(f"{args.port} report accepted.") + return 0 report = normalize( manifest=manifest, port_id=args.port, diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index b5817f12521..4db22b7469f 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -251,6 +251,103 @@ def test_validate_rejects_inconsistent_stored_report_summary(self): ): port_status.validate(manifest) + def publishable_report(self, port_id, **overrides): + mapped = port_status.test_to_feature(self.manifest) + tests = { + test: {"status": "pass", "feature": feature} + for test, feature in mapped.items() + } + report = { + "schema_version": self.manifest["schema_version"], + "port": port_id, + "generated_at": "2026-07-30T09:24:29Z", + "suite_finished": True, + "summary": {"pass": len(tests), "fail": 0, "skip": 0, "not-run": 0}, + "tests": tests, + "performance": { + "status": "complete", + "benchmark_version": 1, + "missing": [], + "skipped": {}, + "benchmarks": { + benchmark: {"duration_ns": 12000000, "checksum": "42"} + for benchmark in self.manifest["performance_benchmarks"] + }, + }, + } + report.update(overrides) + return report + + def test_publishable_accepts_a_report_that_skips_workloads(self): + # The shape every iOS, tvOS, and watchOS run produces: the simulator + # skips the three GC-footprint workloads and measures the other seven. + report = self.publishable_report("ios-gl") + for benchmark in ("objectAllocation", "hashMapChurn", "stringBuilding"): + del report["performance"]["benchmarks"][benchmark] + report["performance"]["skipped"][benchmark] = "ios-simulator-gc-footprint" + + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "ios-gl", report + )) + + def test_publishable_accepts_a_documented_test_skip(self): + report = self.publishable_report("android") + report["tests"]["CameraApiTest"]["status"] = "skip" + report["summary"]["pass"] -= 1 + report["summary"]["skip"] += 1 + + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "android", report + )) + + def test_publishable_separates_contract_drift_from_a_broken_report(self): + report = self.publishable_report("android") + del report["tests"]["CameraApiTest"] + drift, malformed = port_status.publishable_report_problems( + self.manifest, "android", report + ) + self.assertEqual([], malformed) + self.assertIn("CameraApiTest", drift[0]) + + def test_publishable_rejects_unaccounted_and_unmeasured_workloads(self): + report = self.publishable_report("linux-x64") + del report["performance"]["benchmarks"]["quicksort"] + report["performance"]["benchmarks"]["recursion"]["duration_ns"] = None + drift, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertEqual([], drift) + self.assertEqual(2, len(malformed), malformed) + self.assertTrue(any("do not match the contract" in item for item in malformed)) + self.assertTrue(any("recursion" in item for item in malformed)) + + def test_publishable_rejects_an_incomplete_or_mislabelled_run(self): + for mutate, expected in ( + (lambda report: report["performance"].update({"status": "partial"}), "partial"), + (lambda report: report["performance"].update({"missing": ["quicksort"]}), "quicksort"), + (lambda report: report.update({"port": "android"}), "android"), + (lambda report: report["summary"].update({"pass": 3}), "summary"), + ): + with self.subTest(expected=expected): + report = self.publishable_report("watchos") + mutate(report) + _, malformed = port_status.publishable_report_problems( + self.manifest, "watchos", report + ) + self.assertTrue(any(expected in item for item in malformed), malformed) + + def test_publishable_matches_every_report_the_site_serves(self): + for port in self.manifest["ports"]: + report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( + port["id"] + ".json" + ) + with self.subTest(port=port["id"]): + drift, malformed = port_status.publishable_report_problems( + self.manifest, port["id"], port_status.read_json(report_path) + ) + self.assertEqual([], malformed) + self.assertEqual([], drift) + if __name__ == "__main__": unittest.main() diff --git a/scripts/website/sync_port_status_reports.sh b/scripts/website/sync_port_status_reports.sh index b4fc0fa9b7d..7debbc3c477 100755 --- a/scripts/website/sync_port_status_reports.sh +++ b/scripts/website/sync_port_status_reports.sh @@ -50,29 +50,51 @@ if ! git -C "${REPO_ROOT}" fetch --quiet --no-tags --depth=1 origin "${DATA_REF} fi synced=0 +unusable=0 +fallback=() while IFS= read -r port; do candidate="${tmp_dir}/${port}.json" if ! git -C "${REPO_ROOT}" show "FETCH_HEAD:ports/${port}.json" > "${candidate}" 2>/dev/null; then echo "No persisted ${port} report; keeping the checked-in report." >&2 + fallback+=("${port} (never published)") continue fi - if ! jq -e --arg port "${port}" --slurpfile contract "${MANIFEST}" ' - .schema_version == $contract[0].schema_version and - .port == $port and - ((.tests | keys | sort) == ([$contract[0].features[].tests[]] | sort)) and - .performance.status == "complete" and - ([.performance.benchmarks[].duration_ns | type] | all(. == "number")) and - ([.performance.benchmarks[].duration_ns] | all(. >= 0)) and - ((.performance.benchmarks | keys) == ($contract[0].performance_benchmarks | sort)) - ' "${candidate}" >/dev/null; then - echo "Persisted ${port} report does not match the current contract; keeping the checked-in report." >&2 - continue - fi + # One implementation of "may this report be published", shared with the + # normalizer's own unit tests. Duplicating it here as a jq expression is what + # silently rejected every iOS-family report: those runs legitimately skip + # three GC-footprint workloads, and the copy demanded a measurement for all + # ten, so the site quietly served the checked-in copy until it went stale. + set +e + python3 "${REPO_ROOT}/scripts/hellocodenameone/conformance/port_status.py" \ + accept --port "${port}" --report "${candidate}" + accept_rc=$? + set -e + case "${accept_rc}" in + 0) ;; + 11) + echo "Persisted ${port} report predates the current test contract; keeping the checked-in report." >&2 + fallback+=("${port} (waiting for a run on the current contract)") + continue + ;; + *) + echo "Persisted ${port} report is unusable; keeping the checked-in report." >&2 + fallback+=("${port} (unusable report)") + unusable=$((unusable + 1)) + continue + ;; + esac cp "${candidate}" "${REPORT_DIR}/${port}.json" synced=$((synced + 1)) done < <(jq -r '.ports[].id' "${MANIFEST}") echo "Resolved ${synced} Port Status reports from ${DATA_REF}; remaining ports use checked-in reports." +if [ ${#fallback[@]} -gt 0 ]; then + echo "Ports served from the checked-in fallback: ${fallback[*]}" >&2 +fi +if [ "${unusable}" -gt 0 ]; then + echo "${unusable} persisted report(s) are unusable; fix the producing workflow." >&2 + exit 1 +fi environment_candidate="${tmp_dir}/environment.json" environment_target="${REPO_ROOT}/docs/website/data/port_status_environment.json" diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index ffdaee6f18d..04071758648 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -167,6 +167,23 @@ function validate() { fail("the generated page does not contain exhaustive skipped-test errata"); } + // A cell may only read as a pass while carrying a skip when the errata name + // that exact test, so a green mark can never outrun its explanation. + const notedCells = primaryCellTags.filter((cell) => /\bhas-documented-skip\b/.test(cell)); + if (notedCells.length === 0) { + fail("no cell reports a documented skip; the errata and the table disagree"); + } + for (const cell of notedCells) { + const skips = attribute(cell, "data-documented-skip").split(/\s+/).filter(Boolean); + if (!/\bis-pass\b/.test(attribute(cell, "class")) || skips.length === 0 || + !skips.every((test) => errata.includes(test))) { + fail(`a cell claims a documented skip the errata do not cover: ${cell}`); + } + } + if (countMatches(page, /)/g); const manualCells = countMatches(page, /\bdata-manual-feature-cell(?:=|\s|>)/g); if (manualRows < 20 || manualCells !== manualRows * portCards) { diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index d51b2b7719f..3b7cf1ac461 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -1306,30 +1306,70 @@ public static boolean isSurrogate(char ch) { return isHighSurrogate(ch) || isLowSurrogate(ch); } - private static UnicodeHelper.Range[] classMapping; - private static UnicodeHelper.Range[] getClasses() { - throw new UnsupportedOperationException("UnicodeHelper.getClasses() not supported"); - } - + /** + * General category of every ASCII code point, indexed by code point. + * + * The rest of this class is deliberately ASCII-only (isDigit, isLowerCase + * and isUpperCase all answer false above 127), so the category table is + * too. It used to be absent altogether, and getType threw + * UnsupportedOperationException -- which meant isLetter, isLetterOrDigit, + * isJavaIdentifierStart, isJavaIdentifierPart and isIdentifierIgnorable + * threw for every input on the ports that use this runtime, rather than + * answering for the ASCII text they are almost always asked about. + */ + private static final byte[] ASCII_TYPES = { + /* 00-0f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + /* 10-1f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + /* ' '!"# */ SPACE_SEPARATOR, OTHER_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* $%&' */ CURRENCY_SYMBOL, OTHER_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* ()*+ */ START_PUNCTUATION, END_PUNCTUATION, OTHER_PUNCTUATION, MATH_SYMBOL, + /* ,-./ */ OTHER_PUNCTUATION, DASH_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* 0-7 */ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, + DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, + /* 89:; */ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* <=>? */ MATH_SYMBOL, MATH_SYMBOL, MATH_SYMBOL, OTHER_PUNCTUATION, + /* @A-G */ OTHER_PUNCTUATION, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* H-O */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* P-W */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* XYZ[ */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, START_PUNCTUATION, + /* \]^_ */ OTHER_PUNCTUATION, END_PUNCTUATION, MODIFIER_SYMBOL, CONNECTOR_PUNCTUATION, + /* `a-g */ MODIFIER_SYMBOL, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* h-o */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* p-w */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* xyz{ */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, START_PUNCTUATION, + /* |}~del */ MATH_SYMBOL, END_PUNCTUATION, MATH_SYMBOL, CONTROL + }; + public static int getType(int codePoint) { if (isBmpCodePoint(codePoint) && isSurrogate((char) codePoint)) { return SURROGATE; } - UnicodeHelper.Range[] classes = getClasses(); - int l = 0; - int u = classes.length - 1; - while (l <= u) { - int i = (l + u) / 2; - UnicodeHelper.Range range = classes[i]; - if (codePoint >= range.end) { - l = i + 1; - } else if (codePoint < range.start) { - u = i - 1; - } else { - return range.data[codePoint - range.start]; - } + if (codePoint >= 0 && codePoint < ASCII_TYPES.length) { + return ASCII_TYPES[codePoint]; + } + // Above ASCII this runtime carries no category table, so answer from + // the primitives it does implement instead of failing the call. + if (isLowerCase(codePoint)) { + return LOWERCASE_LETTER; + } + if (isUpperCase(codePoint)) { + return UPPERCASE_LETTER; + } + if (isDigit(codePoint)) { + return DECIMAL_DIGIT_NUMBER; + } + if (isWhitespace(codePoint)) { + return SPACE_SEPARATOR; } - return 0; + return UNASSIGNED; } /** diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java index 29a36927ee8..4935ad1afdd 100644 --- a/vm/JavaAPI/src/java/time/DateTimeSupport.java +++ b/vm/JavaAPI/src/java/time/DateTimeSupport.java @@ -182,13 +182,28 @@ public static LocalDateTime localDateTimeFromInstant(Instant instant, ZoneId zon } public static ZoneOffset offsetFromInstant(Instant instant, ZoneId zone) { + if (zone instanceof ZoneOffset) { + // A fixed offset already is the answer; round-tripping it through + // the host time zone database can only lose or invert it. + return (ZoneOffset) zone; + } TimeZone tz = TimeZoneSupport.toTimeZone(zone); - Calendar cal = newCalendar(tz); - cal.setTime(new Date(instant.toEpochMilli())); - LocalDate localDate = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); - LocalTime localTime = LocalTime.of(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND)); - long localEpochSecond = localDate.toEpochDay() * SECONDS_PER_DAY + localTime.toSecondOfDay(); - return ZoneOffset.ofTotalSeconds((int) (localEpochSecond - instant.getEpochSecond())); + // Ask the zone for its offset at this instant rather than reading the + // fields back out of a Calendar. Calendar reconstructs the local time + // from a raw offset plus a fixed one-hour daylight guess, which loses + // the saving on the desktop ports (Europe/Berlin in June came back as + // UTC), while TimeZone.getOffset consults the platform's own rules. + // The fields below are UTC, which is the reference frame every port's + // getOffset native resolves against. + long epochMilli = instant.toEpochMilli(); + long epochDay = floorDiv(epochMilli, MILLIS_PER_DAY); + int millisOfDay = (int) floorMod(epochMilli, MILLIS_PER_DAY); + LocalDate utcDate = LocalDate.ofEpochDay(epochDay); + // Calendar.SUNDAY is 1 and epoch day 0 was a Thursday. + int dayOfWeek = (int) floorMod(epochDay + 4, 7) + 1; + int offsetMillis = tz.getOffset(1 /* GregorianCalendar.AD */, utcDate.getYear(), + utcDate.getMonthValue() - 1, utcDate.getDayOfMonth(), dayOfWeek, millisOfDay); + return ZoneOffset.ofTotalSeconds(offsetMillis / 1000); } public static SimpleDateFormat newFormat(String pattern, ZoneId zone, Locale locale) { diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 2b67656bce2..2fbbaa0cb46 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -167,7 +167,12 @@ public java.lang.String getID(){ public static java.util.TimeZone getTimeZone(final java.lang.String ID){ if(ID != null && ID.equalsIgnoreCase("gmt")) { return GMT; - } else if (ID.equalsIgnoreCase(getTimezoneId())) { + } + TimeZone custom = customTimeZone(ID); + if (custom != null) { + return custom; + } + if (ID.equalsIgnoreCase(getTimezoneId())) { return getDefault(); } else { TimeZone out = new TimeZone() { @@ -204,6 +209,79 @@ public int hashCode() { } } + /** + * Resolves a custom fixed-offset ID -- {@code GMT+2}, {@code GMT-05:00}, + * {@code UTC+01:30} -- to a zone with that raw offset and no daylight + * saving, exactly as {@code java.util.TimeZone} documents them. + * + * These must never reach the host time zone database. A POSIX {@code TZ} + * value inverts the sign of its offset, so handing {@code "GMT-05:00"} to + * {@code tzset()} produced UTC+5 -- java.time converts a ZoneOffset to + * exactly this form, so every OffsetDateTime formatted through a pattern + * came out shifted by twice its offset. Windows is worse: its C runtime + * cannot parse the form at all. + * + * @return the fixed-offset zone, or null when {@code ID} is not a custom ID + */ + private static TimeZone customTimeZone(String ID) { + if (ID == null) { + return null; + } + int index; + if (ID.regionMatches(true, 0, "GMT", 0, 3)) { + index = 3; + } else if (ID.regionMatches(true, 0, "UTC", 0, 3)) { + index = 3; + } else if (ID.regionMatches(true, 0, "UT", 0, 2)) { + index = 2; + } else { + return null; + } + if (index >= ID.length()) { + return new SimpleTimeZone(0, ID); + } + char sign = ID.charAt(index); + if (sign == 'Z' && index + 1 == ID.length()) { + return new SimpleTimeZone(0, ID); + } + if (sign != '+' && sign != '-') { + return null; + } + String digits = ID.substring(index + 1); + int colon = digits.indexOf(':'); + String hourPart = colon < 0 ? digits : digits.substring(0, colon); + String rest = colon < 0 ? "" : digits.substring(colon + 1); + String minutePart = "0"; + String secondPart = "0"; + if (colon < 0) { + // The colon-less forms are hh, hhmm and hhmmss. + if (digits.length() == 4 || digits.length() == 6) { + hourPart = digits.substring(0, 2); + minutePart = digits.substring(2, 4); + secondPart = digits.length() == 6 ? digits.substring(4, 6) : "0"; + } + } else { + int secondColon = rest.indexOf(':'); + minutePart = secondColon < 0 ? rest : rest.substring(0, secondColon); + secondPart = secondColon < 0 ? "0" : rest.substring(secondColon + 1); + } + int hours; + int minutes; + int seconds; + try { + hours = Integer.parseInt(hourPart); + minutes = Integer.parseInt(minutePart); + seconds = Integer.parseInt(secondPart); + } catch (NumberFormatException notCustom) { + return null; + } + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) { + return null; + } + int offset = ((hours * 60 + minutes) * 60 + seconds) * 1000; + return new SimpleTimeZone(sign == '-' ? -offset : offset, ID); + } + /** * Queries if this time zone uses Daylight Savings Time. */ diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 83959fbd326..f374f19a818 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -417,6 +417,14 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // 40-minute hard cap either way. long stableMs = 300_000L; long deadline = System.currentTimeMillis() + 40L * 60 * 1000; + // Screenshot stabilization is a weak completion signal: DesktopMode, + // the VideoIO grid, the VR scene and the 360 panorama all capture + // AFTER the non-rendering API tail, so a slow tail trips the window + // while real screenshot tests are still queued -- the suite is then + // force-killed and every trailing test is reported as never run. + // CN1_REQUIRE_SUITE (as on the Windows arm64 pipeline) demands the + // suite's own completion marker instead. + boolean requireSuite = Boolean.parseBoolean(System.getenv("CN1_REQUIRE_SUITE")); int pngs = 0, lastPngs = -1; long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { @@ -432,12 +440,18 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); } - if (pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs) { break; } + if (!requireSuite && pngs >= minPngs + && (System.currentTimeMillis() - lastChange) >= stableMs) { break; } Thread.sleep(3000); } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); - assertTrue(finished.get() || pngs >= minPngs, - "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")\n" + serverLog); + if (!finished.get()) { + System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs + + " -- every test after the last logged one is reported as never run."); + } + assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), + "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" + + " suiteFinished=" + finished.get() + "\n" + serverLog); String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { From 3c58dcc345f79e8bdff7a56394f8e3a93790da8e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:16:27 +0300 Subject: [PATCH 02/48] Address review: validate before publishing, wire the suite gate, fix native semantics - backfill_port_status.sh published whatever the newest run produced. A report built against an older contract passes the freshness check but is rejected by the website sync, so the column would stay on its stale fallback while the sweep reported success. Each artifact now goes through "port_status.py accept" before publication, and the closing assertion re-checks the published file instead of only its timestamp -- which is how windows-arm64's drifted report now surfaces. - CN1_REQUIRE_SUITE is now set by both Linux legs. Left unset, the new branch in the capture harness was unreachable and both jobs kept the screenshot stabilization exit that kills the suite while DesktopMode, the VideoIO grid, the VR scene and the 360 panorama are still queued. - The shared offset lookup passes UTC fields. The POSIX native resolves them with timegm and the JavaScript runtime with Date.UTC, but the iOS native built its NSDate from [NSCalendar currentCalendar], reading them in the device's zone; near a transition that lands on the wrong side of it. It now builds the date in UTC, and no longer drops the hour and second components. - Character.getType collapsed every non-ASCII whitespace code point to SPACE_SEPARATOR. U+2028 and U+2029 are LINE_SEPARATOR and PARAGRAPH_SEPARATOR and U+180E is FORMAT, all of which isWhitespace already treats individually. - The page validator matched the note marker with quoted attributes, which the production build minifies away, so the check failed on CI and passed locally. - java.time/DateTimeSupport.java carries the project header; java.util.TimeZone keeps its Apache Harmony notice and is recorded in the exclusions list. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 7 ++++++ Ports/iOSPort/nativeSources/IOSNative.m | 12 ++++++++-- scripts/copyright-header-exclusions.txt | 1 + .../conformance/backfill_port_status.sh | 15 +++++++++++++ scripts/website/validate_port_status.mjs | 4 +++- vm/JavaAPI/src/java/lang/Character.java | 14 +++++++++++- vm/JavaAPI/src/java/time/DateTimeSupport.java | 22 +++++++++++++++++++ 7 files changed, 71 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 5a14f0bbd57..3be28477197 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -219,6 +219,12 @@ jobs: # Full app stdout/stderr -- the only evidence when the suite wedges # mid-run (uploaded with the screenshot artifact below). CN1_APP_LOG_TEE: ${{ github.workspace }}/artifacts/linux-port/raw/app-output.log + # Wait for the suite's own completion marker instead of stopping when + # screenshots go quiet. The stabilization exit fired while DesktopMode, + # the VideoIO grid, the VR scene and the 360 panorama were still queued + # behind the slow non-rendering API tail, so the suite was force-killed + # and every trailing test was published as "never run". + CN1_REQUIRE_SUITE: '1' # Build the native ELF (and the demo) against an old glibc for portability. CN1_CC: /usr/local/bin/cn1-zig-cc # After the suite runs, the capture test relinks the same objects into a @@ -320,6 +326,7 @@ jobs: -v "$GITHUB_WORKSPACE":/cn1 -w /cn1 \ -e CN1_SHOT_OUTPUT_DIR=/cn1/artifacts/linux-port/raw-musl \ -e CN1_APP_LOG_TEE=/cn1/artifacts/linux-port/raw-musl/app-output.log \ + -e CN1_REQUIRE_SUITE=1 \ -e LIBGL_ALWAYS_SOFTWARE=1 \ docker.io/library/alpine:3.20 sh -ec ' sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 797d7259761..fcfe7cd9ce7 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10017,8 +10017,16 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int [comps setDay:day]; [comps setYear:year]; [comps setMonth:month]; - [comps setMinute:timeOfDayMillis/60000]; - NSCalendar* cal = [NSCalendar currentCalendar]; + [comps setHour:timeOfDayMillis/3600000]; + [comps setMinute:(timeOfDayMillis/60000)%60]; + [comps setSecond:(timeOfDayMillis/1000)%60]; + // The caller passes UTC fields -- the POSIX implementation of this native + // resolves them with timegm() -- so build the date in UTC too. Reading them + // in the device's own zone (currentCalendar) moved the instant by the + // device offset, which lands on the wrong side of a transition when the + // requested zone changes offset within that window. + NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; + [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; NSDate *date = [cal dateFromComponents:comps]; JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000; [comps release]; diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index 4990d7702a7..210cfbf13d6 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -22,3 +22,4 @@ Ports/JavaScriptPort/src/main/webapp/js/videojs/video.min.js | Video.js 7.4.1 an Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.css | videojs-record 3.5.0 third-party stylesheet Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.js | videojs-record 3.5.0 third-party bundle Ports/JavaScriptPort/src/main/webapp/sw.js | Codename One service-worker adapter containing the UpUp 1.0.0 MIT-licensed service worker +vm/JavaAPI/src/java/util/TimeZone.java | Apache Harmony source retaining its original Apache-2.0 notice diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 0ccfea5526f..2f0ded77ee2 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -99,6 +99,14 @@ while IFS= read -r workflow; do echo "Ignoring ${report}: it names no port." >&2 continue fi + # Publish only what the website will actually serve. A report built against + # an older contract passes the freshness check below but is rejected by the + # sync, which would leave the public column on its stale fallback while + # this sweep reported success. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}"; then + echo "Not publishing the ${port} report from run ${run_id}: it is not usable by the website." >&2 + continue + fi generated="$(jq -r '.generated_at // empty' "${report}")" current="" if gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ @@ -128,6 +136,13 @@ while IFS= read -r port; do problems+=("${port}: no published report") continue fi + # Freshness alone is not enough: a published report the website rejects + # leaves the column on its checked-in fallback, which is the state this + # sweep exists to detect. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${tmp_dir}/check.json" >/dev/null; then + problems+=("${port}: published report is not usable by the website") + continue + fi generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" age_days="$(python3 - "${generated}" <<'PY' import sys diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index 04071758648..62418bb169c 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -180,7 +180,9 @@ function validate() { fail(`a cell claims a documented skip the errata do not cover: ${cell}`); } } - if (countMatches(page, /]*\bcn1-port-status__note\b/g) < notedCells.length) { fail("documented-skip cells must carry a visible note marker"); } diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index 3b7cf1ac461..7ffc234a140 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -1356,7 +1356,19 @@ public static int getType(int codePoint) { return ASCII_TYPES[codePoint]; } // Above ASCII this runtime carries no category table, so answer from - // the primitives it does implement instead of failing the call. + // the primitives it does implement instead of failing the call. The + // code points isWhitespace() knows about individually are named here: + // they are not all separators, and the two that are come from + // different categories. + if (codePoint == 0x2028) { + return LINE_SEPARATOR; + } + if (codePoint == 0x2029) { + return PARAGRAPH_SEPARATOR; + } + if (codePoint == 0x180E) { + return FORMAT; + } if (isLowerCase(codePoint)) { return LOWERCASE_LETTER; } diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java index 4935ad1afdd..aef3a8541cb 100644 --- a/vm/JavaAPI/src/java/time/DateTimeSupport.java +++ b/vm/JavaAPI/src/java/time/DateTimeSupport.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package java.time; import com.codename1.impl.time.TimeZoneSupport; From a2cb05fce5f03e89640d3d5a4bcd56eea643f2fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:45:13 +0300 Subject: [PATCH 03/48] Fix the desktop-port file paths the louder IO errors exposed The first CI run on this branch confirmed the time zone, Character.getType and openInputStream fixes -- TimeApiTest, SurfacesPublishTest and FileSystemStorageOpenInputStreamMissingTest all pass on Linux now -- and the new exceptions turned two silent write failures into named ones. - getAppHomePath() returned a bare path on both desktop ports. Android and iOS return it with the file:// scheme, and com.codename1.io.File prepends the app home to any path that lacks the scheme, so new File(fs.getAppHomePath() + "x") resolved to the home directory joined to itself: AudioMixerApiTest was asking to write ".../codenameone//home/runner/.local/share/codenameone/audio-mixer-api-test.wav". Both ports now return the scheme and implement toNativePath. - The Windows port never overrode getAppHomePath at all, so it inherited listFilesystemRoots()[0] + AppName, which is a drive root plus the literal string "null" when no app name is set. It now anchors on the same per-user storage directory the Linux port uses. - cn1StorageDir() created only the leaf directory. A home without an existing ~/.local/share -- a fresh CI runner, or a new account -- left the storage directory absent, so every write into it failed at fopen(); that is why ClipboardRoundTripTest could not create its file. The path is now created component by component. - Both ports record why the last open failed and include it in the exception, so a missing directory is distinguishable from a permission or sharing problem without another CI round trip. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/LinuxPort/nativeSources/cn1_linux_io.c | 47 ++++++++++++++++++- .../impl/linux/LinuxImplementation.java | 23 +++++++-- .../com/codename1/impl/linux/LinuxNative.java | 3 ++ .../nativeSources/cn1_windows_io.c | 14 ++++++ .../impl/windows/WindowsImplementation.java | 39 +++++++++++++-- .../codename1/impl/windows/WindowsNative.java | 3 ++ 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 9be20a1d3ed..1e4ef2ff79d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -153,15 +153,35 @@ static const char* cn1JStr(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { /* ------------------------------------------------------------ file io */ +/* Reason the last open failed. The port reports "could not open X" from Java, + * where errno is long gone; without this the only way to tell a missing + * directory from a permission problem was another CI round trip. */ +static char cn1LastIoError[512]; + +static void cn1RecordIoError(const char* path) { + snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno)); + (void) path; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + return newStringFromCString(threadStateData, cn1LastIoError[0] ? cn1LastIoError : "unknown error"); +} + JAVA_LONG com_codename1_impl_linux_LinuxNative_fileOpenRead___java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path) { const char* p = cn1JStr(threadStateData, path); FILE* f = p ? fopen(p, "rb") : 0; + if (f == 0) { + cn1RecordIoError(p); + } return (JAVA_LONG) (intptr_t) f; } JAVA_LONG com_codename1_impl_linux_LinuxNative_fileOpenWrite___java_lang_String_boolean_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_BOOLEAN append) { const char* p = cn1JStr(threadStateData, path); FILE* f = p ? fopen(p, append ? "ab" : "wb") : 0; + if (f == 0) { + cn1RecordIoError(p); + } return (JAVA_LONG) (intptr_t) f; } @@ -291,6 +311,30 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileList___java_lang_String_R_j /* The per-user app storage directory ($XDG_DATA_HOME/codenameone, else * ~/.local/share/codenameone), created on first use. Backs Storage + the * FileSystemStorage app-home. */ +/* Creates every missing component of an absolute path. mkdir(2) only creates + * the leaf, so a home without an existing ~/.local/share -- which is the state + * of a fresh CI runner or a freshly created account -- left the storage + * directory absent. Every write into it then failed at fopen(), which the port + * used to swallow: Storage entries and files written through + * FileSystemStorage were silently discarded. */ +static void cn1MkdirParents(const char* path) { + char work[4096]; + char* p; + size_t len = strlen(path); + if (len == 0 || len >= sizeof(work)) { + return; + } + memcpy(work, path, len + 1); + for (p = work + 1; *p; p++) { + if (*p == '/') { + *p = 0; + mkdir(work, 0755); + *p = '/'; + } + } + mkdir(work, 0755); +} + static const char* cn1StorageDir(void) { static char dir[4096]; if (dir[0] == 0) { @@ -304,9 +348,8 @@ static const char* cn1StorageDir(void) { } else { snprintf(share, sizeof(share), "/tmp"); } - mkdir(share, 0755); snprintf(dir, sizeof(dir), "%s/codenameone", share); - mkdir(dir, 0755); + cn1MkdirParents(dir); } return dir; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 1d82f0e51b9..a8b4cc0f8ce 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2389,7 +2389,8 @@ public InputStream openInputStream(Object connection) throws IOException { // legitimately empty file, so callers could not tell a missing // file from an empty one -- the exact defect issue #1502 // reported against iOS. - throw new FileNotFoundException("No such file: " + path); + throw new FileNotFoundException("No such file: " + path + + " (" + LinuxNative.lastIoError() + ")"); } return new LinuxInputStream(h, false); } @@ -2403,7 +2404,8 @@ public InputStream openInputStream(Object connection) throws IOException { private long openForWrite(String path, boolean append) throws IOException { long h = LinuxNative.fileOpenWrite(path, append); if (h == 0) { - throw new IOException("Unable to open " + path + " for writing"); + throw new IOException("Unable to open " + path + " for writing (" + + LinuxNative.lastIoError() + ")"); } return h; } @@ -2619,7 +2621,8 @@ public InputStream createStorageInputStream(String name) throws IOException { String path = storagePath(name); long h = LinuxNative.fileOpenRead(path); if (h == 0) { - throw new FileNotFoundException("No such storage entry: " + name); + throw new FileNotFoundException("No such storage entry: " + name + + " (" + LinuxNative.lastIoError() + ")"); } return new LinuxInputStream(h, false); } @@ -2649,6 +2652,13 @@ public String[] listFilesystemRoots() { * is exactly why a recorded "tmpaudio.wav" came back as file:///null/tmpaudio.wav * and would not play). Use the same writable per-user directory that Storage and * capturePhoto already rely on. + * + * The result carries the {@code file://} scheme, as it does on Android and + * iOS. {@link com.codename1.io.File} treats any path without that scheme as + * relative to the app home and prepends the home to it, so returning a bare + * path made {@code new File(fs.getAppHomePath() + "x")} resolve to the home + * directory joined to itself -- every write through that class landed on a + * path that could not exist. */ @Override public String getAppHomePath() { @@ -2659,7 +2669,12 @@ public String getAppHomePath() { if (!dir.endsWith("/")) { dir += "/"; } - return dir; + return "file://" + dir; + } + + @Override + public String toNativePath(String path) { + return stripFileUrl(path); } @Override diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index b8496b7097d..2c195321312 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -388,6 +388,9 @@ public static native long editStringAt(int x, int y, int w, int h, String text, public static native long fileOpenWrite(String path, boolean append); + /** Why the most recent open failed, for the exception the port raises. */ + public static native String lastIoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index 442889295fa..a3a0528d6cb 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -82,6 +82,18 @@ static JAVA_OBJECT cn1WinWideToJavaString(CODENAME_ONE_THREAD_STATE, const WCHAR /* ------------------------------------------------------------------- files */ +/* Reason the last open failed. The port reports "could not open X" from Java, + * where the thread's last-error value is long gone; without this the only way + * to tell a missing directory from a sharing violation was another CI run. */ +static DWORD cn1WinLastIoError; + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + char buffer[256]; + _snprintf(buffer, sizeof(buffer), "Windows error %lu", (unsigned long) cn1WinLastIoError); + buffer[sizeof(buffer) - 1] = 0; + return newStringFromCString(threadStateData, buffer); +} + JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1Arg1) { UINT32 len = 0; WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len); @@ -93,6 +105,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_Stri OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); free(path); if (h == INVALID_HANDLE_VALUE) { + cn1WinLastIoError = GetLastError(); return 0; } return (JAVA_LONG)(intptr_t)h; @@ -119,6 +132,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str } free(path); if (h == INVALID_HANDLE_VALUE) { + cn1WinLastIoError = GetLastError(); return 0; } return (JAVA_LONG)(intptr_t)h; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index da0c0ba313a..d278f5f15e5 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2402,7 +2402,8 @@ public InputStream openInputStream(Object connection) throws IOException { // legitimately empty file, so callers could not tell a missing // file from an empty one -- the exact defect issue #1502 // reported against iOS. - throw new FileNotFoundException("No such file: " + path); + throw new FileNotFoundException("No such file: " + path + + " (" + WindowsNative.lastIoError() + ")"); } return new WindowsInputStream(h, false); } @@ -2416,7 +2417,8 @@ public InputStream openInputStream(Object connection) throws IOException { private long openForWrite(String path, boolean append) throws IOException { long h = WindowsNative.fileOpenWrite(path, append); if (h == 0) { - throw new IOException("Unable to open " + path + " for writing"); + throw new IOException("Unable to open " + path + " for writing (" + + WindowsNative.lastIoError() + ")"); } return h; } @@ -2629,7 +2631,8 @@ public InputStream createStorageInputStream(String name) throws IOException { String path = storagePath(name); long h = WindowsNative.fileOpenRead(path); if (h == 0) { - throw new FileNotFoundException("No such storage entry: " + name); + throw new FileNotFoundException("No such storage entry: " + name + + " (" + WindowsNative.lastIoError() + ")"); } return new WindowsInputStream(h, false); } @@ -2649,6 +2652,36 @@ public String[] listFilesystemRoots() { return WindowsNative.fileRoots(); } + /** + * Anchors the app home at the per-user storage directory, with the + * {@code file://} scheme Android, iOS and the Linux port also use. + * + * The inherited implementation builds {@code listFilesystemRoots()[0] + + * AppName}, which here is a drive root plus an app name that is literally + * "null" when neither the AppName property nor a package name is set -- + * every path it produced pointed at an unwritable {@code C:\null\}. The + * scheme matters as well: {@link com.codename1.io.File} prepends the app + * home to any path that lacks it, so a bare path made + * {@code new File(fs.getAppHomePath() + "x")} resolve to the home + * directory joined to itself. + */ + @Override + public String getAppHomePath() { + String dir = WindowsNative.storageDir(); + if (dir == null || dir.length() == 0) { + dir = "."; + } + if (!dir.endsWith("\\") && !dir.endsWith("/")) { + dir += getFileSystemSeparator(); + } + return "file://" + dir; + } + + @Override + public String toNativePath(String path) { + return stripFileUrl(path); + } + @Override public String[] listFiles(String directory) throws IOException { return WindowsNative.fileList(stripFileUrl(directory)); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 7f27de2b42a..9d4bad3ebbb 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -396,6 +396,9 @@ public static native long editStringAt(int x, int y, int w, int h, String text, public static native long fileOpenWrite(String path, boolean append); + /** Why the most recent open failed, for the exception the port raises. */ + public static native String lastIoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); From 8dd4c6a954fc0ec3c0f830f5be62436da06e822d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:06:39 +0300 Subject: [PATCH 04/48] Implement the desktop crypto bridge, and fix the shared UTF-8 buffer misuse Crypto - The Linux port answers the whole com.codename1.security surface through OpenSSL's EVP layer: secure random, AES in GCM/CBC/ECB, RSA with OAEP or PKCS#1, SHA-2 signatures and RSA key generation. Keys cross the boundary in the encodings the portable API documents -- X.509 SubjectPublicKeyInfo and PKCS#8 PrivateKeyInfo -- so d2i_PUBKEY and d2i_PKCS8_PRIV_KEY_INFO do the ASN.1 and nothing parses DER by hand. libcrypto comes with the libcurl the port already links. - The Windows port answers the same surface through CNG, with crypt32 doing the ASN.1 between those DER encodings and BCRYPT_RSAKEY_BLOB. - A failed operation raises rather than returning an empty array: an authentication failure that answered "no bytes" would read as a successful decryption of nothing. GCM keeps the tag appended to the ciphertext, which is the convention the portable API documents. - The OpenSSL implementation was exercised against libcrypto off-device before landing: GCM round trip, tamper and wrong-AAD rejection, CBC with padding, OAEP round trip, and sign/verify including tampered-data and wrong-key rejection. stringToUTF8 aliasing stringToUTF8 returns one buffer per thread and overwrites it on every call, so a native that converted a second String silently repointed the first result at the second string. Five natives in the Linux port did exactly that: - fileRename renamed a file onto itself, which is why WAVWriter's rename step left AudioMixerApiTest without its output; - httpSetHeader sent every request header as "value: value"; - printDocument, showNotification and shareText each collapsed their arguments onto the last one. They now copy through cn1LinuxJStrDup, which the header documents as mandatory for any native converting more than one String. The Windows port was already safe -- its wide-string helper allocates. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 4 +- Ports/LinuxPort/nativeSources/cn1_linux.h | 9 + .../nativeSources/cn1_linux_crypto.c | 454 +++++++++++++ Ports/LinuxPort/nativeSources/cn1_linux_io.c | 30 +- Ports/LinuxPort/nativeSources/cn1_linux_net.c | 18 +- .../LinuxPort/nativeSources/cn1_linux_print.c | 32 +- .../nativeSources/cn1_linux_services.c | 25 +- .../impl/linux/LinuxImplementation.java | 78 +++ .../com/codename1/impl/linux/LinuxNative.java | 30 + .../nativeSources/cn1_windows_crypto.c | 640 ++++++++++++++++++ .../impl/windows/WindowsImplementation.java | 78 +++ .../codename1/impl/windows/WindowsNative.java | 30 + .../tools/translator/ByteCodeTranslator.java | 7 +- 13 files changed, 1405 insertions(+), 30 deletions(-) create mode 100644 Ports/LinuxPort/nativeSources/cn1_linux_crypto.c create mode 100644 Ports/WindowsPort/nativeSources/cn1_windows_crypto.c diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 3be28477197..488c69b532b 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -149,7 +149,7 @@ jobs: cmake ninja-build pkg-config unzip xvfb fonts-dejavu-core \ libgtk-3-dev libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libglib2.0-dev \ libfontconfig1-dev libfreetype-dev \ - libcurl4-openssl-dev \ + libcurl4-openssl-dev libssl-dev \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ libwebkit2gtk-4.1-dev libsecret-1-dev libnotify-dev libgeoclue-2-dev \ libepoxy-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri @@ -332,7 +332,7 @@ jobs: sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories apk add --no-cache build-base cmake samurai pkgconf bash git openjdk8 openjdk17 maven \ gtk+3.0-dev cairo-dev pango-dev gdk-pixbuf-dev glib-dev fontconfig-dev freetype-dev \ - curl-dev libepoxy-dev mesa-dev mesa-gles mesa-egl mesa-gbm mesa-dri-gallium \ + curl-dev openssl-dev libepoxy-dev mesa-dev mesa-gles mesa-egl mesa-gbm mesa-dri-gallium \ webkit2gtk-4.1-dev gstreamer-dev gst-plugins-base-dev \ libsecret-dev libnotify-dev geoclue-dev xvfb ttf-dejavu # JDK 8 runs the translator/maven; JDK 17 is needed to compile the diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index 3aec9368d54..56dde128c7c 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -145,6 +145,15 @@ void cn1LinuxLog(const char* message); */ JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n); +/* Copy of a Java String's UTF-8 bytes, owned by the caller (free it). + * + * stringToUTF8 hands back one buffer per thread and overwrites it on every + * call, so a native that converts a second String silently repoints the first + * result at the second string. That is not theoretical: it made fileRename + * rename a file onto itself and sent every HTTP request header as + * "value: value". Any native converting more than one String must use this. */ +char* cn1LinuxJStrDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value); + #ifdef __cplusplus } #endif diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c new file mode 100644 index 00000000000..db518bf43a0 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/* + * The crypto half of com.codename1.impl.CodenameOneImplementation, backed by + * OpenSSL's EVP layer (libcrypto, which the port already pulls in through + * libcurl). + * + * Key material crosses this boundary in the same DER encodings the portable + * API documents -- X.509 SubjectPublicKeyInfo for public keys and PKCS#8 + * PrivateKeyInfo for private keys -- so nothing here has to parse ASN.1 by + * hand: d2i_PUBKEY and d2i_PKCS8_PRIV_KEY_INFO do it. + * + * Every entry point answers null (or false) on failure and records a reason + * retrievable through lastCryptoError, which the Java side turns into the + * CryptoException message. A silent empty result would look like a successful + * encryption of nothing. + */ + +#include "cn1_linux.h" +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define CN1_GCM_TAG_BYTES 16 + +static char cn1CryptoError[512]; + +static void cn1CryptoFail(const char* what) { + unsigned long code = ERR_get_error(); + char detail[256]; + detail[0] = 0; + if (code != 0) { + ERR_error_string_n(code, detail, sizeof(detail)); + } + snprintf(cn1CryptoError, sizeof(cn1CryptoError), "%s%s%s", what, + detail[0] ? ": " : "", detail); + ERR_clear_error(); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + return newStringFromCString(threadStateData, + cn1CryptoError[0] ? cn1CryptoError : "unknown crypto error"); +} + +static const unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { + if (array == JAVA_NULL) { + *length = 0; + return 0; + } + *length = (int) (*(JAVA_ARRAY) array).length; + return (const unsigned char*) (*(JAVA_ARRAY) array).data; +} + +/* ------------------------------------------------------------ random */ + +JAVA_VOID com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + int length = 0; + unsigned char* data = (unsigned char*) cn1Bytes(out, &length); + if (data == 0 || length <= 0) { + return; + } + if (RAND_bytes(data, length) != 1) { + cn1CryptoFail("secure random"); + memset(data, 0, (size_t) length); + } +} + +/* ------------------------------------------------------------ AES */ + +static const EVP_CIPHER* cn1AesCipher(const char* transformation, int keyLength) { + int gcm = strstr(transformation, "/GCM/") != 0; + int ecb = strstr(transformation, "/ECB/") != 0; + switch (keyLength) { + case 16: + return gcm ? EVP_aes_128_gcm() : (ecb ? EVP_aes_128_ecb() : EVP_aes_128_cbc()); + case 24: + return gcm ? EVP_aes_192_gcm() : (ecb ? EVP_aes_192_ecb() : EVP_aes_192_cbc()); + case 32: + return gcm ? EVP_aes_256_gcm() : (ecb ? EVP_aes_256_ecb() : EVP_aes_256_cbc()); + default: + return 0; + } +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT ivArray, JAVA_OBJECT aadArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, ivLength = 0, aadLength = 0, dataLength = 0; + const unsigned char* key = cn1Bytes(keyArray, &keyLength); + const unsigned char* iv = cn1Bytes(ivArray, &ivLength); + const unsigned char* aad = cn1Bytes(aadArray, &aadLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + int gcm = strstr(mode, "/GCM/") != 0; + int padded = strstr(mode, "NoPadding") == 0; + const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength); + EVP_CIPHER_CTX* ctx = 0; + unsigned char* out = 0; + unsigned char tag[CN1_GCM_TAG_BYTES]; + int bodyLength = dataLength; + int outLength = 0, finalLength = 0, discard = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (cipher == 0) { + cn1CryptoFail("unsupported AES key length"); + return JAVA_NULL; + } + if (gcm && !encrypt) { + if (dataLength < CN1_GCM_TAG_BYTES) { + cn1CryptoFail("AES-GCM input is shorter than its authentication tag"); + return JAVA_NULL; + } + bodyLength = dataLength - CN1_GCM_TAG_BYTES; + } + + ctx = EVP_CIPHER_CTX_new(); + if (ctx == 0) { + cn1CryptoFail("cipher context"); + return JAVA_NULL; + } + /* Room for a full trailing block of padding, plus the tag when sealing. */ + out = (unsigned char*) malloc((size_t) bodyLength + EVP_MAX_BLOCK_LENGTH + CN1_GCM_TAG_BYTES); + if (out == 0) { + EVP_CIPHER_CTX_free(ctx); + cn1CryptoFail("out of memory"); + return JAVA_NULL; + } + + if (EVP_CipherInit_ex(ctx, cipher, 0, 0, 0, encrypt ? 1 : 0) != 1) { + goto failed; + } + if (gcm && ivLength > 0 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, ivLength, 0) != 1) { + goto failed; + } + if (EVP_CipherInit_ex(ctx, 0, 0, key, ivLength > 0 ? iv : 0, encrypt ? 1 : 0) != 1) { + goto failed; + } + if (EVP_CIPHER_CTX_set_padding(ctx, padded ? 1 : 0) != 1) { + goto failed; + } + if (gcm && aadLength > 0 && EVP_CipherUpdate(ctx, 0, &discard, aad, aadLength) != 1) { + goto failed; + } + if (bodyLength > 0 && EVP_CipherUpdate(ctx, out, &outLength, data, bodyLength) != 1) { + goto failed; + } + if (gcm && !encrypt && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, CN1_GCM_TAG_BYTES, + (void*) (data + bodyLength)) != 1) { + goto failed; + } + if (EVP_CipherFinal_ex(ctx, out + outLength, &finalLength) != 1) { + /* For GCM this is the tag check: a tampered message lands here. */ + cn1CryptoFail(gcm && !encrypt ? "AES-GCM authentication failed" : "AES finalize"); + free(out); + EVP_CIPHER_CTX_free(ctx); + return JAVA_NULL; + } + outLength += finalLength; + if (gcm && encrypt) { + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, CN1_GCM_TAG_BYTES, tag) != 1) { + goto failed; + } + memcpy(out + outLength, tag, CN1_GCM_TAG_BYTES); + outLength += CN1_GCM_TAG_BYTES; + } + result = cn1LinuxNewByteArray(threadStateData, out, outLength); + free(out); + EVP_CIPHER_CTX_free(ctx); + return result; + +failed: + cn1CryptoFail("AES"); + free(out); + EVP_CIPHER_CTX_free(ctx); + return JAVA_NULL; +} + +/* ------------------------------------------------------------ keys */ + +static EVP_PKEY* cn1PublicKey(const unsigned char* der, int length) { + const unsigned char* cursor = der; + EVP_PKEY* key = d2i_PUBKEY(0, &cursor, (long) length); + if (key == 0) { + cn1CryptoFail("public key is not X.509 SubjectPublicKeyInfo DER"); + } + return key; +} + +static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { + const unsigned char* cursor = der; + EVP_PKEY* key = 0; + PKCS8_PRIV_KEY_INFO* info = d2i_PKCS8_PRIV_KEY_INFO(0, &cursor, (long) length); + if (info != 0) { + key = EVP_PKCS82PKEY(info); + PKCS8_PRIV_KEY_INFO_free(info); + } + if (key == 0) { + /* Tolerate a bare PKCS#1/SEC1 key as well; some callers keep those. */ + cursor = der; + key = d2i_AutoPrivateKey(0, &cursor, (long) length); + } + if (key == 0) { + cn1CryptoFail("private key is not PKCS#8 DER"); + } + return key; +} + +static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { + if (strstr(transformation, "OAEP") != 0) { + const EVP_MD* md = strstr(transformation, "SHA-1") != 0 ? EVP_sha1() : EVP_sha256(); + if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || + EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 || + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) { + return 0; + } + return 1; + } + return EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PADDING) > 0; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, dataLength = 0; + const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + EVP_PKEY* key = encrypt ? cn1PublicKey(keyDer, keyLength) : cn1PrivateKey(keyDer, keyLength); + EVP_PKEY_CTX* ctx = 0; + unsigned char* out = 0; + size_t outLength = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (key == 0) { + return JAVA_NULL; + } + ctx = EVP_PKEY_CTX_new(key, 0); + if (ctx == 0) { + cn1CryptoFail("RSA context"); + EVP_PKEY_free(key); + return JAVA_NULL; + } + if ((encrypt ? EVP_PKEY_encrypt_init(ctx) : EVP_PKEY_decrypt_init(ctx)) <= 0 || + !cn1ApplyRsaPadding(ctx, mode)) { + cn1CryptoFail("RSA init"); + goto done; + } + if ((encrypt ? EVP_PKEY_encrypt(ctx, 0, &outLength, data, (size_t) dataLength) + : EVP_PKEY_decrypt(ctx, 0, &outLength, data, (size_t) dataLength)) <= 0) { + cn1CryptoFail("RSA size"); + goto done; + } + out = (unsigned char*) malloc(outLength); + if (out == 0) { + cn1CryptoFail("out of memory"); + goto done; + } + if ((encrypt ? EVP_PKEY_encrypt(ctx, out, &outLength, data, (size_t) dataLength) + : EVP_PKEY_decrypt(ctx, out, &outLength, data, (size_t) dataLength)) <= 0) { + cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt"); + goto done; + } + result = cn1LinuxNewByteArray(threadStateData, out, (int) outLength); + +done: + free(out); + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(key); + return result; +} + +/* ------------------------------------------------------------ signatures */ + +static const EVP_MD* cn1SignatureDigest(const char* algorithm) { + if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { + return EVP_sha512(); + } + if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { + return EVP_sha384(); + } + if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { + return EVP_sha1(); + } + return EVP_sha256(); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0; + const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + EVP_PKEY* key = cn1PrivateKey(keyDer, keyLength); + EVP_MD_CTX* ctx = 0; + unsigned char* out = 0; + size_t outLength = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (key == 0) { + return JAVA_NULL; + } + ctx = EVP_MD_CTX_new(); + if (ctx == 0) { + cn1CryptoFail("digest context"); + EVP_PKEY_free(key); + return JAVA_NULL; + } + if (EVP_DigestSignInit(ctx, 0, cn1SignatureDigest(name), 0, key) <= 0 || + EVP_DigestSign(ctx, 0, &outLength, data, (size_t) dataLength) <= 0) { + cn1CryptoFail("sign init"); + goto done; + } + out = (unsigned char*) malloc(outLength); + if (out == 0) { + cn1CryptoFail("out of memory"); + goto done; + } + if (EVP_DigestSign(ctx, out, &outLength, data, (size_t) dataLength) <= 0) { + cn1CryptoFail("sign"); + goto done; + } + result = cn1LinuxNewByteArray(threadStateData, out, (int) outLength); + +done: + free(out); + EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return result; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, + JAVA_OBJECT dataArray, JAVA_OBJECT signatureArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0, signatureLength = 0; + const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + const unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); + EVP_PKEY* key = cn1PublicKey(keyDer, keyLength); + EVP_MD_CTX* ctx = 0; + JAVA_BOOLEAN result = JAVA_FALSE; + + if (key == 0) { + return JAVA_FALSE; + } + ctx = EVP_MD_CTX_new(); + if (ctx == 0) { + cn1CryptoFail("digest context"); + EVP_PKEY_free(key); + return JAVA_FALSE; + } + if (EVP_DigestVerifyInit(ctx, 0, cn1SignatureDigest(name), 0, key) > 0 && + EVP_DigestVerify(ctx, signature, (size_t) signatureLength, data, (size_t) dataLength) == 1) { + result = JAVA_TRUE; + } else { + /* A rejected signature is a normal answer, not a fault; clear the + * queue so it cannot be reported against a later operation. */ + ERR_clear_error(); + } + EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return result; +} + +/* ------------------------------------------------------------ key pairs */ + +/* Returns the pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. A pair has to come from + * a single call -- two calls would produce two unrelated keys. */ +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_generateRsaKeyPair___int_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_INT bits) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, 0); + EVP_PKEY* key = 0; + PKCS8_PRIV_KEY_INFO* info = 0; + unsigned char* publicDer = 0; + unsigned char* privateDer = 0; + unsigned char* blob = 0; + int publicLength = 0, privateLength = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (ctx == 0) { + cn1CryptoFail("RSA keygen context"); + return JAVA_NULL; + } + if (EVP_PKEY_keygen_init(ctx) <= 0 || + EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0 || + EVP_PKEY_keygen(ctx, &key) <= 0) { + cn1CryptoFail("RSA keygen"); + goto done; + } + publicLength = i2d_PUBKEY(key, &publicDer); + info = EVP_PKEY2PKCS8(key); + if (info != 0) { + privateLength = i2d_PKCS8_PRIV_KEY_INFO(info, &privateDer); + } + if (publicLength <= 0 || privateLength <= 0) { + cn1CryptoFail("RSA key encoding"); + goto done; + } + blob = (unsigned char*) malloc((size_t) publicLength + (size_t) privateLength + 4); + if (blob == 0) { + cn1CryptoFail("out of memory"); + goto done; + } + blob[0] = (unsigned char) ((publicLength >> 24) & 0xff); + blob[1] = (unsigned char) ((publicLength >> 16) & 0xff); + blob[2] = (unsigned char) ((publicLength >> 8) & 0xff); + blob[3] = (unsigned char) (publicLength & 0xff); + memcpy(blob + 4, publicDer, (size_t) publicLength); + memcpy(blob + 4 + publicLength, privateDer, (size_t) privateLength); + result = cn1LinuxNewByteArray(threadStateData, blob, publicLength + privateLength + 4); + +done: + free(blob); + OPENSSL_free(publicDer); + OPENSSL_free(privateDer); + if (info != 0) { + PKCS8_PRIV_KEY_INFO_free(info); + } + if (key != 0) { + EVP_PKEY_free(key); + } + EVP_PKEY_CTX_free(ctx); + return result; +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 1e4ef2ff79d..86c179e5c86 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -78,6 +78,21 @@ void cn1LinuxStubOnce(const char* tag) { fflush(stderr); } +char* cn1LinuxJStrDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value) { + const char* utf8 = value == JAVA_NULL ? 0 : stringToUTF8(threadStateData, value); + char* copy; + size_t length; + if (utf8 == 0) { + return 0; + } + length = strlen(utf8); + copy = (char*) malloc(length + 1); + if (copy != 0) { + memcpy(copy, utf8, length + 1); + } + return copy; +} + JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n) { JAVA_OBJECT arr; if (n < 0) { @@ -254,15 +269,22 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_fileMkdir___java_lang_String(CODE } JAVA_VOID com_codename1_impl_linux_LinuxNative_fileRename___java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT newName) { - const char* p = cn1JStr(threadStateData, path); - const char* n; + /* Two conversions: copy the first, or stringToUTF8's shared per-thread + * buffer repoints it at the new name and the file is renamed onto itself. */ + char* p = cn1LinuxJStrDup(threadStateData, path); + char* n = 0; char dir[4096]; char dest[4096]; char* slash; if (!p || newName == JAVA_NULL) { + free(p); + return; + } + n = cn1LinuxJStrDup(threadStateData, newName); + if (!n) { + free(p); return; } - n = stringToUTF8(threadStateData, newName); /* newName is a leaf name; rename within the same parent directory. */ strncpy(dir, p, sizeof(dir) - 1); dir[sizeof(dir) - 1] = 0; @@ -274,6 +296,8 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_fileRename___java_lang_String_jav snprintf(dest, sizeof(dest), "%s", n); } rename(p, dest); + free(p); + free(n); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileList___java_lang_String_R_java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_net.c b/Ports/LinuxPort/nativeSources/cn1_linux_net.c index e755bb3f3f8..ec740cc852f 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_net.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_net.c @@ -197,16 +197,22 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetMethod___long_boolean(CODE JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetHeader___long_java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_OBJECT key, JAVA_OBJECT value) { CN1Http* c = (CN1Http*) (intptr_t) connection; - const char* k; - const char* v; + char* k; + char* v; char line[8192]; if (!c || key == JAVA_NULL) { return; } - k = stringToUTF8(threadStateData, key); - v = value == JAVA_NULL ? "" : stringToUTF8(threadStateData, value); - snprintf(line, sizeof(line), "%s: %s", k, v); - c->reqHeaders = curl_slist_append(c->reqHeaders, line); + /* Copy the name: converting the value reuses stringToUTF8's per-thread + * buffer, which otherwise sent every header as "value: value". */ + k = cn1LinuxJStrDup(threadStateData, key); + v = value == JAVA_NULL ? 0 : cn1LinuxJStrDup(threadStateData, value); + if (k != 0) { + snprintf(line, sizeof(line), "%s: %s", k, v == 0 ? "" : v); + c->reqHeaders = curl_slist_append(c->reqHeaders, line); + } + free(k); + free(v); } JAVA_INT com_codename1_impl_linux_LinuxNative_httpResponseCode___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_print.c b/Ports/LinuxPort/nativeSources/cn1_linux_print.c index b9a6671ab82..2fd8b562478 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_print.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_print.c @@ -123,24 +123,36 @@ static int cn1PrintViaLp(const char* path, const char* job) { JAVA_INT com_codename1_impl_linux_LinuxNative_printDocument___java_lang_String_java_lang_String_java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT mimeType, JAVA_OBJECT jobName) { CN1PrintReq req; + /* Each conversion overwrites stringToUTF8's per-thread buffer, so all + * three have to be copied before any of them is read. */ + char* pathCopy = cn1LinuxJStrDup(threadStateData, path); + char* mimeCopy = cn1LinuxJStrDup(threadStateData, mimeType); + char* jobCopy = cn1LinuxJStrDup(threadStateData, jobName); + int result; cn1PrintError[0] = 0; - req.path = path == JAVA_NULL ? 0 : stringToUTF8(threadStateData, path); - req.mime = mimeType == JAVA_NULL ? "" : stringToUTF8(threadStateData, mimeType); - req.job = jobName == JAVA_NULL ? 0 : stringToUTF8(threadStateData, jobName); + req.path = pathCopy; + req.mime = mimeCopy == 0 ? "" : mimeCopy; + req.job = jobCopy; req.result = 2; if (!req.path) { snprintf(cn1PrintError, sizeof(cn1PrintError), "null path"); + free(pathCopy); + free(mimeCopy); + free(jobCopy); return 2; } - if (strncmp(req.mime, "image", 5) == 0) { - if (cn1LinuxWindowWidget() == 0) { - return cn1PrintViaLp(req.path, req.job); /* headless: no dialog */ - } + if (strncmp(req.mime, "image", 5) == 0 && cn1LinuxWindowWidget() != 0) { cn1LinuxRunOnMainAndWait(cn1PrintImageOnMain, &req); - return req.result; + result = req.result; + } else { + /* Headless images and everything else: hand to CUPS, which + * rasterizes natively. */ + result = cn1PrintViaLp(req.path, req.job); } - /* PDF and everything else: hand to CUPS, which rasterizes natively. */ - return cn1PrintViaLp(req.path, req.job); + free(pathCopy); + free(mimeCopy); + free(jobCopy); + return result; } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_printLastError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index 131b22dc06e..d0ea40baac4 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -389,10 +389,18 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_showNotification___java_lang_Stri if (!cn1LoadNotify()) { return; } - r.id = id == JAVA_NULL ? "" : stringToUTF8(threadStateData, id); - r.title = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title); - r.body = body == JAVA_NULL ? "" : stringToUTF8(threadStateData, body); + /* Copy each: stringToUTF8 reuses one buffer per thread, so converting the + * title would otherwise repoint the id at it, and the body at both. */ + char* idCopy = cn1LinuxJStrDup(threadStateData, id); + char* titleCopy = cn1LinuxJStrDup(threadStateData, title); + char* bodyCopy = cn1LinuxJStrDup(threadStateData, body); + r.id = idCopy == 0 ? "" : idCopy; + r.title = titleCopy == 0 ? "" : titleCopy; + r.body = bodyCopy == 0 ? "" : bodyCopy; cn1LinuxRunOnMainAndWait(cn1ShowNotifyOnMain, &r); + free(idCopy); + free(titleCopy); + free(bodyCopy); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_notificationPollClicked___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { @@ -553,15 +561,18 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_shareText___java_lang_String_j /* No universal share sheet on the Linux desktop (xdg-desktop-portal's Share * is not yet broadly available); fall back to composing a mail draft via the * default mailto handler, which is the closest portable "share". */ - const char* t = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text); - const char* subj = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title); - char* body = g_uri_escape_string(t, NULL, FALSE); - char* s = g_uri_escape_string(subj, NULL, FALSE); + /* Copy the text before converting the title -- they share one buffer. */ + char* t = cn1LinuxJStrDup(threadStateData, text); + char* subj = cn1LinuxJStrDup(threadStateData, title); + char* body = g_uri_escape_string(t == 0 ? "" : t, NULL, FALSE); + char* s = g_uri_escape_string(subj == 0 ? "" : subj, NULL, FALSE); char* uri = g_strconcat("mailto:?subject=", s, "&body=", body, NULL); gboolean ok = g_app_info_launch_default_for_uri(uri, NULL, NULL); g_free(body); g_free(s); g_free(uri); + free(t); + free(subj); return ok ? JAVA_TRUE : JAVA_FALSE; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index a8b4cc0f8ce..6bc862cee74 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2737,6 +2737,84 @@ public char getFileSystemSeparator() { return '/'; } + /* ------------------------------------------------------------ crypto */ + + /** + * The crypto bridge, backed by OpenSSL. Every failure is reported as a + * RuntimeException carrying the library's own reason, which + * {@code com.codename1.security.Cipher} turns into a CryptoException -- + * an authentication failure has to be an exception rather than an empty + * result, or a tampered message would read as an empty plaintext. + */ + private static byte[] cryptoResult(byte[] value, String operation) { + if (value == null) { + throw new RuntimeException(operation + " failed: " + LinuxNative.lastCryptoError()); + } + return value; + } + + @Override + public void secureRandomBytes(byte[] out) { + if (out != null && out.length > 0) { + LinuxNative.secureRandomBytes(out); + } + } + + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return cryptoResult(LinuxNative.aesCrypt(transformation, true, key, iv, aad, plaintext), + "AES encrypt"); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return cryptoResult(LinuxNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), + "AES decrypt"); + } + + @Override + public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { + return cryptoResult(LinuxNative.rsaCrypt(transformation, true, publicKeyX509, plaintext), + "RSA encrypt"); + } + + @Override + public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { + return cryptoResult(LinuxNative.rsaCrypt(transformation, false, privateKeyPkcs8, ciphertext), + "RSA decrypt"); + } + + @Override + public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { + // The digest and the key type both follow from the algorithm name and + // the DER key itself, so keyAlgorithm adds nothing here. + return cryptoResult(LinuxNative.signData(algorithm, privateKeyPkcs8, data), "sign"); + } + + @Override + public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, + byte[] data, byte[] signature) { + return LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); + } + + @Override + public byte[][] generateRsaKeyPair(int bits) { + byte[] blob = cryptoResult(LinuxNative.generateRsaKeyPair(bits), "RSA key generation"); + if (blob.length < 4) { + throw new RuntimeException("RSA key generation returned a truncated pair"); + } + int publicLength = ((blob[0] & 0xff) << 24) | ((blob[1] & 0xff) << 16) + | ((blob[2] & 0xff) << 8) | (blob[3] & 0xff); + if (publicLength < 0 || publicLength > blob.length - 4) { + throw new RuntimeException("RSA key generation returned a malformed pair"); + } + byte[] publicKey = new byte[publicLength]; + byte[] privateKey = new byte[blob.length - 4 - publicLength]; + System.arraycopy(blob, 4, publicKey, 0, publicKey.length); + System.arraycopy(blob, 4 + publicLength, privateKey, 0, privateKey.length); + return new byte[][] { publicKey, privateKey }; + } + /* ------------------------------------------------------------ platform */ @Override diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 2c195321312..79f66a38ff2 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -391,6 +391,36 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** Why the most recent open failed, for the exception the port raises. */ public static native String lastIoError(); + /* ---------------------------------------------------------- crypto */ + + public static native void secureRandomBytes(byte[] out); + + /** + * AES in the mode named by {@code transformation}. For GCM the + * authentication tag is appended to the ciphertext, which is the + * convention the portable API documents. + */ + public static native byte[] aesCrypt(String transformation, boolean encrypt, + byte[] key, byte[] iv, byte[] aad, byte[] data); + + /** RSA with an X.509 public key when encrypting, PKCS#8 when decrypting. */ + public static native byte[] rsaCrypt(String transformation, boolean encrypt, + byte[] key, byte[] data); + + public static native byte[] signData(String algorithm, byte[] privateKeyPkcs8, byte[] data); + + public static native boolean verifyData(String algorithm, byte[] publicKeyX509, + byte[] data, byte[] signature); + + /** + * A fresh RSA pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. + */ + public static native byte[] generateRsaKeyPair(int bits); + + /** Why the most recent crypto call failed, for the CryptoException message. */ + public static native String lastCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c new file mode 100644 index 00000000000..b278a53f2c9 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -0,0 +1,640 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/* + * The crypto half of com.codename1.impl.CodenameOneImplementation on Windows. + * + * CNG (bcrypt) provides the primitives and crypt32 the ASN.1: key material + * crosses this boundary in the encodings the portable API documents -- X.509 + * SubjectPublicKeyInfo and PKCS#8 PrivateKeyInfo -- and CryptDecodeObjectEx / + * CryptEncodeObjectEx translate those to and from the BCRYPT_RSAKEY_BLOB form + * bcrypt wants, so no ASN.1 is parsed here by hand. + * + * Every entry point answers null (or false) on failure and records the status + * for lastCryptoError, which the Java side turns into the CryptoException + * message. A silent empty result would look like a successful encryption of + * nothing. + */ + +#include "cn1_windows.h" +#include +#include +#include +#include +#include +#include + +#ifndef STATUS_SUCCESS +#define STATUS_SUCCESS ((NTSTATUS) 0x00000000L) +#endif +#ifndef STATUS_AUTH_TAG_MISMATCH +#define STATUS_AUTH_TAG_MISMATCH ((NTSTATUS) 0xC000A002L) +#endif + +#define CN1_GCM_TAG_BYTES 16 + +static char cn1WinCryptoError[512]; + +static void cn1CryptoFail(const char* what, NTSTATUS status) { + _snprintf(cn1WinCryptoError, sizeof(cn1WinCryptoError), "%s (status 0x%08lx)", what, + (unsigned long) status); + cn1WinCryptoError[sizeof(cn1WinCryptoError) - 1] = 0; +} + +static void cn1CryptoFailLast(const char* what) { + cn1CryptoFail(what, (NTSTATUS) GetLastError()); +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + return newStringFromCString(threadStateData, + cn1WinCryptoError[0] ? cn1WinCryptoError : "unknown crypto error"); +} + +/* The Windows port has no shared byte-array helper, so keep a local one that + * matches how the rest of the port allocates arrays. */ +static JAVA_OBJECT cn1WinNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n) { + JAVA_OBJECT array; + if (n < 0) { + n = 0; + } + array = allocArray(threadStateData, n, &class_array1__JAVA_BYTE, sizeof(JAVA_ARRAY_BYTE), 1); + if (array != JAVA_NULL && n > 0 && src != 0) { + memcpy((*(JAVA_ARRAY) array).data, src, (size_t) n); + } + return array; +} + +static unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { + if (array == JAVA_NULL) { + *length = 0; + return 0; + } + *length = (int) (*(JAVA_ARRAY) array).length; + return (unsigned char*) (*(JAVA_ARRAY) array).data; +} + +/* ------------------------------------------------------------ random */ + +JAVA_VOID com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + int length = 0; + unsigned char* data = cn1Bytes(out, &length); + NTSTATUS status; + if (data == 0 || length <= 0) { + return; + } + status = BCryptGenRandom(NULL, data, (ULONG) length, BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("secure random", status); + memset(data, 0, (size_t) length); + } +} + +/* ------------------------------------------------------------ AES */ + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT ivArray, JAVA_OBJECT aadArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, ivLength = 0, aadLength = 0, dataLength = 0; + unsigned char* key = cn1Bytes(keyArray, &keyLength); + unsigned char* iv = cn1Bytes(ivArray, &ivLength); + unsigned char* aad = cn1Bytes(aadArray, &aadLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + int gcm = strstr(mode, "/GCM/") != 0; + int ecb = strstr(mode, "/ECB/") != 0; + int padded = strstr(mode, "NoPadding") == 0; + int bodyLength = dataLength; + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE handle = NULL; + NTSTATUS status; + unsigned char* out = 0; + unsigned char* ivCopy = 0; + ULONG outLength = 0, produced = 0; + JAVA_OBJECT result = JAVA_NULL; + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth; + unsigned char tag[CN1_GCM_TAG_BYTES]; + + if (gcm && !encrypt) { + if (dataLength < CN1_GCM_TAG_BYTES) { + cn1CryptoFail("AES-GCM input is shorter than its authentication tag", 0); + return JAVA_NULL; + } + bodyLength = dataLength - CN1_GCM_TAG_BYTES; + } + + status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_AES_ALGORITHM, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES provider", status); + return JAVA_NULL; + } + status = BCryptSetProperty(alg, BCRYPT_CHAINING_MODE, + gcm ? (PUCHAR) BCRYPT_CHAIN_MODE_GCM + : (ecb ? (PUCHAR) BCRYPT_CHAIN_MODE_ECB + : (PUCHAR) BCRYPT_CHAIN_MODE_CBC), + gcm ? sizeof(BCRYPT_CHAIN_MODE_GCM) + : (ecb ? sizeof(BCRYPT_CHAIN_MODE_ECB) + : sizeof(BCRYPT_CHAIN_MODE_CBC)), + 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES chaining mode", status); + goto done; + } + status = BCryptGenerateSymmetricKey(alg, &handle, NULL, 0, key, (ULONG) keyLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES key", status); + goto done; + } + + if (gcm) { + BCRYPT_INIT_AUTH_MODE_INFO(auth); + auth.pbNonce = iv; + auth.cbNonce = (ULONG) ivLength; + auth.pbAuthData = aadLength > 0 ? aad : NULL; + auth.cbAuthData = (ULONG) aadLength; + if (encrypt) { + auth.pbTag = tag; + auth.cbTag = CN1_GCM_TAG_BYTES; + } else { + auth.pbTag = data + bodyLength; + auth.cbTag = CN1_GCM_TAG_BYTES; + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, NULL, 0, &outLength, 0) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, NULL, 0, &outLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES-GCM size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + CN1_GCM_TAG_BYTES + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, out, outLength, &produced, 0) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, out, outLength, &produced, 0); + if (status != STATUS_SUCCESS) { + /* A tampered message or wrong associated data lands here. */ + cn1CryptoFail(status == STATUS_AUTH_TAG_MISMATCH + ? "AES-GCM authentication failed" : "AES-GCM", status); + goto done; + } + if (encrypt) { + memcpy(out + produced, tag, CN1_GCM_TAG_BYTES); + produced += CN1_GCM_TAG_BYTES; + } + } else { + ULONG flags = padded ? BCRYPT_BLOCK_PADDING : 0; + /* CBC updates the IV in place, so hand the cipher its own copy. */ + if (ivLength > 0) { + ivCopy = (unsigned char*) malloc((size_t) ivLength); + if (ivCopy == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + memcpy(ivCopy, iv, (size_t) ivLength); + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + NULL, 0, &outLength, flags) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + NULL, 0, &outLength, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + /* The size query above consumed the IV copy; restore it. */ + if (ivLength > 0) { + memcpy(ivCopy, iv, (size_t) ivLength); + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + out, outLength, &produced, flags) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + out, outLength, &produced, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES", status); + goto done; + } + } + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + +done: + free(out); + free(ivCopy); + if (handle != NULL) { + BCryptDestroyKey(handle); + } + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} + +/* ------------------------------------------------------------ RSA keys */ + +static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) { + CERT_PUBLIC_KEY_INFO* info = 0; + DWORD infoLength = 0; + BCRYPT_KEY_HANDLE key = NULL; + if (!CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { + cn1CryptoFailLast("public key is not X.509 SubjectPublicKeyInfo DER"); + return NULL; + } + if (!CryptImportPublicKeyInfoEx2(X509_ASN_ENCODING, info, 0, NULL, &key)) { + cn1CryptoFailLast("public key import"); + key = NULL; + } + LocalFree(info); + return key; +} + +static BCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, BCRYPT_ALG_HANDLE* algOut) { + CRYPT_PRIVATE_KEY_INFO* info = 0; + DWORD infoLength = 0; + BCRYPT_RSAKEY_BLOB* blob = 0; + DWORD blobLength = 0; + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = NULL; + NTSTATUS status; + const unsigned char* pkcs1 = der; + DWORD pkcs1Length = (DWORD) length; + + *algOut = NULL; + /* PKCS#8 wraps the PKCS#1 RSAPrivateKey; tolerate a bare PKCS#1 too. */ + if (CryptDecodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, der, (DWORD) length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { + pkcs1 = info->PrivateKey.pbData; + pkcs1Length = info->PrivateKey.cbData; + } + if (!CryptDecodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, pkcs1, pkcs1Length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &blob, &blobLength)) { + cn1CryptoFailLast("private key is not PKCS#8 DER"); + if (info != 0) { + LocalFree(info); + } + return NULL; + } + status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0); + if (status == STATUS_SUCCESS) { + /* The decoder emits either form depending on which primes it recovered. */ + LPCWSTR blobType = blob->Magic == BCRYPT_RSAFULLPRIVATE_MAGIC + ? BCRYPT_RSAFULLPRIVATE_BLOB : BCRYPT_RSAPRIVATE_BLOB; + status = BCryptImportKeyPair(alg, NULL, blobType, &key, (PUCHAR) blob, blobLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("private key import", status); + BCryptCloseAlgorithmProvider(alg, 0); + alg = NULL; + key = NULL; + } + } else { + cn1CryptoFail("RSA provider", status); + } + LocalFree(blob); + if (info != 0) { + LocalFree(info); + } + *algOut = alg; + return key; +} + +static LPCWSTR cn1DigestAlgorithm(const char* algorithm) { + if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { + return BCRYPT_SHA512_ALGORITHM; + } + if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { + return BCRYPT_SHA384_ALGORITHM; + } + if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { + return BCRYPT_SHA1_ALGORITHM; + } + return BCRYPT_SHA256_ALGORITHM; +} + +static int cn1DigestLength(LPCWSTR algorithm) { + if (wcscmp(algorithm, BCRYPT_SHA512_ALGORITHM) == 0) { + return 64; + } + if (wcscmp(algorithm, BCRYPT_SHA384_ALGORITHM) == 0) { + return 48; + } + if (wcscmp(algorithm, BCRYPT_SHA1_ALGORITHM) == 0) { + return 20; + } + return 32; +} + +/* Hashes with the named algorithm into caller-provided storage. */ +static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, + unsigned char* digest, int digestLength) { + BCRYPT_ALG_HANDLE alg = NULL; + NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, algorithm, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest provider", status); + return 0; + } + status = BCryptHash(alg, NULL, 0, (PUCHAR) data, (ULONG) length, digest, (ULONG) digestLength); + BCryptCloseAlgorithmProvider(alg, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest", status); + return 0; + } + return 1; +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, dataLength = 0; + unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = encrypt ? cn1PublicKey(keyDer, keyLength) + : cn1PrivateKey(keyDer, keyLength, &alg); + BCRYPT_OAEP_PADDING_INFO oaep; + int oaepMode = strstr(mode, "OAEP") != 0; + void* padding = 0; + ULONG flags = oaepMode ? BCRYPT_PAD_OAEP : BCRYPT_PAD_PKCS1; + unsigned char* out = 0; + ULONG outLength = 0, produced = 0; + NTSTATUS status; + JAVA_OBJECT result = JAVA_NULL; + + if (key == NULL) { + return JAVA_NULL; + } + if (oaepMode) { + memset(&oaep, 0, sizeof(oaep)); + oaep.pszAlgId = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; + oaep.pbLabel = NULL; + oaep.cbLabel = 0; + padding = &oaep; + } + status = encrypt + ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) + : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = encrypt + ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) + : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); + goto done; + } + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + +done: + free(out); + BCryptDestroyKey(key); + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0; + unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &alg); + LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); + unsigned char digest[64]; + int digestLength = cn1DigestLength(digestAlgorithm); + BCRYPT_PKCS1_PADDING_INFO padding; + unsigned char* out = 0; + ULONG outLength = 0, produced = 0; + NTSTATUS status; + JAVA_OBJECT result = JAVA_NULL; + + if (key == NULL) { + return JAVA_NULL; + } + if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { + goto done; + } + padding.pszAlgId = digestAlgorithm; + status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, NULL, 0, &outLength, + BCRYPT_PAD_PKCS1); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("sign size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, out, outLength, &produced, + BCRYPT_PAD_PKCS1); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("sign", status); + goto done; + } + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + +done: + free(out); + BCryptDestroyKey(key); + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} + +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_String_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, + JAVA_OBJECT dataArray, JAVA_OBJECT signatureArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0, signatureLength = 0; + unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); + BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength); + LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); + unsigned char digest[64]; + int digestLength = cn1DigestLength(digestAlgorithm); + BCRYPT_PKCS1_PADDING_INFO padding; + JAVA_BOOLEAN result = JAVA_FALSE; + + if (key == NULL) { + return JAVA_FALSE; + } + if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { + padding.pszAlgId = digestAlgorithm; + /* A rejected signature is a normal answer here, not a fault. */ + if (BCryptVerifySignature(key, &padding, digest, (ULONG) digestLength, signature, + (ULONG) signatureLength, BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + result = JAVA_TRUE; + } + } + BCryptDestroyKey(key); + return result; +} + +/* ------------------------------------------------------------ key pairs */ + +/* Returns the pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. A pair has to come from + * a single call -- two calls would produce two unrelated keys. */ +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_generateRsaKeyPair___int_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_INT bits) { + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = NULL; + CERT_PUBLIC_KEY_INFO* publicInfo = 0; + DWORD publicInfoLength = 0; + unsigned char* publicDer = 0; + DWORD publicLength = 0; + unsigned char* privateBlob = 0; + ULONG privateBlobLength = 0; + unsigned char* pkcs1 = 0; + DWORD pkcs1Length = 0; + unsigned char* privateDer = 0; + DWORD privateLength = 0; + unsigned char* blob = 0; + CRYPT_PRIVATE_KEY_INFO keyInfo; + unsigned char derNull[2]; + NTSTATUS status; + JAVA_OBJECT result = JAVA_NULL; + + status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA provider", status); + return JAVA_NULL; + } + status = BCryptGenerateKeyPair(alg, &key, (ULONG) bits, 0); + if (status == STATUS_SUCCESS) { + status = BCryptFinalizeKeyPair(key, 0); + } + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA keygen", status); + goto done; + } + + /* Public half: BCrypt handle -> CERT_PUBLIC_KEY_INFO -> X.509 SPKI DER. */ + if (!CryptExportPublicKeyInfoFromBCryptKeyHandle(key, X509_ASN_ENCODING, NULL, 0, NULL, + NULL, &publicInfoLength)) { + cn1CryptoFailLast("public key export size"); + goto done; + } + publicInfo = (CERT_PUBLIC_KEY_INFO*) malloc(publicInfoLength); + if (publicInfo == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + if (!CryptExportPublicKeyInfoFromBCryptKeyHandle(key, X509_ASN_ENCODING, NULL, 0, NULL, + publicInfo, &publicInfoLength) || + !CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, publicInfo, + CRYPT_ENCODE_ALLOC_FLAG, NULL, &publicDer, &publicLength)) { + cn1CryptoFailLast("public key encode"); + goto done; + } + + /* Private half: BCrypt blob -> PKCS#1 DER -> PKCS#8 PrivateKeyInfo DER. */ + status = BCryptExportKey(key, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, 0, &privateBlobLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("private key export size", status); + goto done; + } + privateBlob = (unsigned char*) malloc(privateBlobLength); + if (privateBlob == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = BCryptExportKey(key, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, privateBlob, privateBlobLength, + &privateBlobLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("private key export", status); + goto done; + } + if (!CryptEncodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, privateBlob, + CRYPT_ENCODE_ALLOC_FLAG, NULL, &pkcs1, &pkcs1Length)) { + cn1CryptoFailLast("private key encode"); + goto done; + } + memset(&keyInfo, 0, sizeof(keyInfo)); + keyInfo.Version = 0; + keyInfo.Algorithm.pszObjId = (LPSTR) szOID_RSA_RSA; + /* rsaEncryption takes an explicit ASN.1 NULL parameter. */ + derNull[0] = 0x05; + derNull[1] = 0x00; + keyInfo.Algorithm.Parameters.cbData = sizeof(derNull); + keyInfo.Algorithm.Parameters.pbData = derNull; + keyInfo.PrivateKey.cbData = pkcs1Length; + keyInfo.PrivateKey.pbData = pkcs1; + if (!CryptEncodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, &keyInfo, + CRYPT_ENCODE_ALLOC_FLAG, NULL, &privateDer, &privateLength)) { + cn1CryptoFailLast("PKCS#8 encode"); + goto done; + } + + blob = (unsigned char*) malloc((size_t) publicLength + (size_t) privateLength + 4); + if (blob == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + blob[0] = (unsigned char) ((publicLength >> 24) & 0xff); + blob[1] = (unsigned char) ((publicLength >> 16) & 0xff); + blob[2] = (unsigned char) ((publicLength >> 8) & 0xff); + blob[3] = (unsigned char) (publicLength & 0xff); + memcpy(blob + 4, publicDer, publicLength); + memcpy(blob + 4 + publicLength, privateDer, privateLength); + result = cn1WinNewByteArray(threadStateData, blob, (int) (publicLength + privateLength + 4)); + +done: + free(blob); + free(publicInfo); + free(privateBlob); + if (publicDer != 0) { + LocalFree(publicDer); + } + if (pkcs1 != 0) { + LocalFree(pkcs1); + } + if (privateDer != 0) { + LocalFree(privateDer); + } + if (key != NULL) { + BCryptDestroyKey(key); + } + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index d278f5f15e5..50ab8c478bc 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2742,6 +2742,84 @@ public char getFileSystemSeparator() { return '\\'; } + /* ------------------------------------------------------------ crypto */ + + /** + * The crypto bridge, backed by CNG. Every failure is reported as a + * RuntimeException carrying the provider status, which + * {@code com.codename1.security.Cipher} turns into a CryptoException -- + * an authentication failure has to be an exception rather than an empty + * result, or a tampered message would read as an empty plaintext. + */ + private static byte[] cryptoResult(byte[] value, String operation) { + if (value == null) { + throw new RuntimeException(operation + " failed: " + WindowsNative.lastCryptoError()); + } + return value; + } + + @Override + public void secureRandomBytes(byte[] out) { + if (out != null && out.length > 0) { + WindowsNative.secureRandomBytes(out); + } + } + + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return cryptoResult(WindowsNative.aesCrypt(transformation, true, key, iv, aad, plaintext), + "AES encrypt"); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return cryptoResult(WindowsNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), + "AES decrypt"); + } + + @Override + public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { + return cryptoResult(WindowsNative.rsaCrypt(transformation, true, publicKeyX509, plaintext), + "RSA encrypt"); + } + + @Override + public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { + return cryptoResult(WindowsNative.rsaCrypt(transformation, false, privateKeyPkcs8, ciphertext), + "RSA decrypt"); + } + + @Override + public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { + // The digest and the key type both follow from the algorithm name and + // the DER key itself, so keyAlgorithm adds nothing here. + return cryptoResult(WindowsNative.signData(algorithm, privateKeyPkcs8, data), "sign"); + } + + @Override + public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, + byte[] data, byte[] signature) { + return WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); + } + + @Override + public byte[][] generateRsaKeyPair(int bits) { + byte[] blob = cryptoResult(WindowsNative.generateRsaKeyPair(bits), "RSA key generation"); + if (blob.length < 4) { + throw new RuntimeException("RSA key generation returned a truncated pair"); + } + int publicLength = ((blob[0] & 0xff) << 24) | ((blob[1] & 0xff) << 16) + | ((blob[2] & 0xff) << 8) | (blob[3] & 0xff); + if (publicLength < 0 || publicLength > blob.length - 4) { + throw new RuntimeException("RSA key generation returned a malformed pair"); + } + byte[] publicKey = new byte[publicLength]; + byte[] privateKey = new byte[blob.length - 4 - publicLength]; + System.arraycopy(blob, 4, publicKey, 0, publicKey.length); + System.arraycopy(blob, 4 + publicLength, privateKey, 0, privateKey.length); + return new byte[][] { publicKey, privateKey }; + } + /* ------------------------------------------------------------ platform */ @Override diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 9d4bad3ebbb..e5b7633e83f 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -399,6 +399,36 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** Why the most recent open failed, for the exception the port raises. */ public static native String lastIoError(); + /* ---------------------------------------------------------- crypto */ + + public static native void secureRandomBytes(byte[] out); + + /** + * AES in the mode named by {@code transformation}. For GCM the + * authentication tag is appended to the ciphertext, which is the + * convention the portable API documents. + */ + public static native byte[] aesCrypt(String transformation, boolean encrypt, + byte[] key, byte[] iv, byte[] aad, byte[] data); + + /** RSA with an X.509 public key when encrypting, PKCS#8 when decrypting. */ + public static native byte[] rsaCrypt(String transformation, boolean encrypt, + byte[] key, byte[] data); + + public static native byte[] signData(String algorithm, byte[] privateKeyPkcs8, byte[] data); + + public static native boolean verifyData(String algorithm, byte[] publicKeyX509, + byte[] data, byte[] signature); + + /** + * A fresh RSA pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. + */ + public static native byte[] generateRsaKeyPair(int bits); + + /** Why the most recent crypto call failed, for the CryptoException message. */ + public static native String lastCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index d133e96c35d..f12947f3a97 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static @@ -1041,7 +1041,10 @@ private static void writeLinuxLinkSet(Writer writer) throws IOException { writer.append("pkg_check_modules(CN1DEPS REQUIRED\n"); writer.append(" gtk+-3.0 cairo pango pangocairo gdk-pixbuf-2.0 glib-2.0 gobject-2.0 gio-2.0\n"); writer.append(" fontconfig freetype2\n"); - writer.append(" libcurl)\n"); + // libcrypto backs the port's crypto bridge (cn1_linux_crypto.c). It is + // already present wherever libcurl is: the OpenSSL-flavoured curl links + // it, and the -dev package pulls in the headers. + writer.append(" libcurl libcrypto)\n"); writer.append("pkg_check_modules(CN1GL REQUIRED epoxy egl glesv2)\n"); // Optional feature libs (browser/media/secure-storage/notifications/ // location). The port dlopen()s these lazily at first use (see From 7cb8f3edeccde577b9fd9694ca13fadf59dda55f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:25:48 +0300 Subject: [PATCH 05/48] Name the test that wedges the suite instead of letting the run die quietly A test that blocks the event dispatch thread outright can never be timed out by the runner, because the per-test deadline is itself enforced by an EDT callback. The suite simply stopped: the log ended mid-line, every later test was published as "never run", and nothing said which test was responsible -- that is how Media360PanoramaScreenshotTest on Linux and CalendarApiTest on Windows have been going unattributed. A watchdog thread now tracks the running test and, thirty seconds past its deadline, reports it by name, emits a CN1SS:SUITE:WEDGED marker and exits. The normalized report then records that test as failed and the rest as unreached, which is what actually happened, and the harness stops burning its forty-minute cap on a thread that is not coming back. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 3b31c5bb407..cfb90839b9d 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -491,9 +491,59 @@ public void runSuite() { logThrowable("EDT", (Throwable)e.getSource()); }); }); + startWedgeWatchdog(); runNextTest(0); } + /// Name of the test the event dispatch thread is inside, and the wall clock + /// at which it stops being plausible that it is still working. Written on + /// the EDT, read by the watchdog thread. + private volatile String activeTestName; + private volatile long activeTestDeadline; + + /// Grace on top of a test's own timeout before the watchdog concludes the + /// EDT is not coming back. The per-test timeout is itself enforced by an + /// EDT callback, so a test that blocks the thread outright can never be + /// timed out by it -- the suite simply stops, and every remaining test is + /// published as "never run" with nothing saying why. + private static final long WEDGE_GRACE_MS = 30000L; + + private void startWedgeWatchdog() { + Thread watchdog = new Thread(() -> { + while (true) { + try { + Thread.sleep(1000L); + } catch (InterruptedException interrupted) { + return; + } + String name = activeTestName; + long deadline = activeTestDeadline; + if (name == null || deadline <= 0L) { + continue; + } + long overrun = System.currentTimeMillis() - (deadline + WEDGE_GRACE_MS); + if (overrun < 0L) { + continue; + } + // Report against the test rather than the suite: this is the + // one line that says which test stopped the run. + log("CN1SS:ERR:suite test=" + name + " failed: the event dispatch thread has not" + + " returned from this test " + overrun + "ms past its deadline; the suite" + + " cannot continue and every later test is unreached"); + log("CN1SS:SUITE:WEDGED test=" + name); + try { + Thread.sleep(250L); + } catch (InterruptedException ignored) { + // fall through to the exit below + } + Runtime.getRuntime().exit(70); + } + }); + watchdog.setName("cn1ss-wedge-watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + } + private void runNextTest(int index) { int offset = prependedTest != null ? 1 : 0; boolean includeJavaSeReferences = "SE".equals( @@ -529,6 +579,8 @@ private void runNextTest(int index) { CN.callSerially(() -> { Cn1ssDeviceRunnerHelper.clearTransportFailure(); log("CN1SS:INFO:suite starting test=" + testName); + activeTestName = testName; + activeTestDeadline = System.currentTimeMillis() + testTimeoutMs(testClass); try { testClass.prepare(); testClass.runTest(); @@ -566,6 +618,7 @@ private void awaitTestCompletion(int index, BaseTest testClass, String testName, } private void finalizeTest(int index, BaseTest testClass, String testName, boolean timedOut) { + activeTestName = null; final Runnable continueToNext = () -> { log("CN1SS:INFO:suite finished test=" + testName); runNextTest(index + 1); From 0c6c9701c8b903c2704973c0c6a35fd32bdf64f5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:29:05 +0300 Subject: [PATCH 06/48] Give the Windows port real named-time-zone offsets The shared POSIX implementation sets TZ and reads tm_gmtoff back. Neither half exists on Windows: the Microsoft C runtime only parses the "EST5EDT" form of TZ, not an IANA identifier, and its struct tm carries no GMT offset at all. Every named zone therefore resolved to an offset of zero, which is why TimeApiTest read America/New_York as UTC. Windows has shipped ICU since Windows 10 1703, and its calendar speaks IANA identifiers and knows the daylight rules for the instant being asked about. The three time zone natives now go through it and fall back to the previous behaviour if it is unavailable. The POSIX path is untouched and still passes the same probe through the ParparVM clean target. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/translator/ByteCodeTranslator.java | 2 +- vm/ByteCodeTranslator/src/nativeMethods.m | 100 ++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f12947f3a97..f4de6cd14e0 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt icu winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 6b40f077b39..41794a66d44 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2603,6 +2603,70 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx pthread_mutex_unlock(&cn1_timezone_mutex); } +/* + * Windows named-zone support. + * + * The POSIX path below sets TZ and reads tm_gmtoff back. Neither half works + * here: the Microsoft C runtime only understands the "EST5EDT" form of TZ, not + * an IANA identifier, and its struct tm carries no GMT offset at all -- so + * every named zone resolved to an offset of zero and, for instance, + * America/New_York reported UTC. Windows ships ICU (icu.dll, Windows 10 1703 + * and later), whose calendar speaks IANA identifiers directly and knows the + * daylight rules for the instant being asked about. + * + * cn1WinZoneOffsetMillis answers the total offset (zone + daylight) at an + * instant, or reports failure so the caller can fall back. + */ +#ifdef _WIN32 +#include + +static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { + UErrorCode status = U_ZERO_ERROR; + UChar zone[128]; + UCalendar* cal; + int32_t zoneOffset, dstOffset; + if (zoneId == 0 || zoneId[0] == 0) { + return 0; + } + u_strFromUTF8(zone, (int32_t) (sizeof(zone) / sizeof(zone[0])), NULL, zoneId, -1, &status); + if (U_FAILURE(status)) { + return 0; + } + cal = ucal_open(zone, -1, "en_US", UCAL_GREGORIAN, &status); + if (U_FAILURE(status) || cal == 0) { + return 0; + } + ucal_setMillis(cal, (UDate) millis, &status); + zoneOffset = ucal_get(cal, UCAL_ZONE_OFFSET, &status); + dstOffset = ucal_get(cal, UCAL_DST_OFFSET, &status); + ucal_close(cal); + if (U_FAILURE(status)) { + return 0; + } + if (offsetOut != 0) { + *offsetOut = (int) (zoneOffset + dstOffset); + } + if (dstOut != 0) { + *dstOut = dstOffset != 0; + } + return 1; +} + +/* Milliseconds since the epoch for a set of UTC calendar fields. */ +static long long cn1WinUtcMillis(int year, int month, int day, int millisOfDay) { + struct tm utc; + memset(&utc, 0, sizeof(utc)); + utc.tm_year = year - 1900; + utc.tm_mon = month - 1; + utc.tm_mday = day; + utc.tm_hour = millisOfDay / 3600000; + utc.tm_min = (millisOfDay / 60000) % 60; + utc.tm_sec = (millisOfDay / 1000) % 60; + utc.tm_isdst = 0; + return (long long) timegm(&utc) * 1000LL; +} +#endif + typedef struct { int year; int month; @@ -2709,6 +2773,15 @@ JAVA_OBJECT java_util_TimeZone_getTimezoneId___R_java_lang_String(CODENAME_ONE_T JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_INT year, JAVA_INT month, JAVA_INT day, JAVA_INT timeOfDayMillis) { const char* buffer = stringToUTF8(threadStateData, name); cn1_timezone_offset_ctx ctx; +#ifdef _WIN32 + { + int offset = 0; + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), + &offset, 0)) { + return offset; + } + } +#endif ctx.year = year; ctx.month = month; ctx.day = day; @@ -2721,6 +2794,25 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) { const char* buffer = stringToUTF8(threadStateData, name); cn1_timezone_raw_ctx ctx; +#ifdef _WIN32 + { + /* The raw offset is the standard-time one: sample both solstices and + * take whichever is not in daylight saving (either hemisphere). */ + int januaryOffset = 0, januaryDst = 0, julyOffset = 0, julyDst = 0; + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 1, 1, 43200000), + &januaryOffset, &januaryDst) && + cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 7, 1, 43200000), + &julyOffset, &julyDst)) { + if (!januaryDst) { + return januaryOffset; + } + if (!julyDst) { + return julyOffset; + } + return januaryOffset < julyOffset ? januaryOffset : julyOffset; + } + } +#endif ctx.januaryOffset = 0; ctx.januaryIsDst = 0; ctx.julyOffset = 0; @@ -2738,6 +2830,14 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA JAVA_BOOLEAN java_util_TimeZone_isTimezoneDST___java_lang_String_long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_LONG millis) { const char* buffer = stringToUTF8(threadStateData, name); cn1_timezone_dst_ctx ctx; +#ifdef _WIN32 + { + int dst = 0; + if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst)) { + return dst ? JAVA_TRUE : JAVA_FALSE; + } + } +#endif ctx.millis = millis; ctx.result = JAVA_FALSE; cn1_with_timezone(buffer, cn1_compute_timezone_dst, &ctx); From 19255ebe822bbea34490d3e577bf224c2e8aa9f4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:20:04 +0300 Subject: [PATCH 07/48] Report the wedging test through the harness, not a forbidden exit call The watchdog reached for Runtime.exit, which the bytecode compliance gate rejects along with System.exit -- both are outside the API the ports support, and exitApplication would have to run on the very thread that is stuck. That broke the suite build, and with it every job that compiles the suite. The watchdog now only reports: it names the test and emits CN1SS:SUITE:WEDGED. Both capture harnesses watch for that marker, stop waiting as soon as it appears and fail with the test name, which is ordinary JUnit code under no such restriction. It also starts through Display.startThread rather than configuring a raw Thread, and stops itself once the suite finishes. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 30 +++++++++++-------- .../CleanTargetIntegrationTest.java | 12 ++++++++ .../CleanTargetLinuxIntegrationTest.java | 11 +++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index cfb90839b9d..eba4b56d72f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -509,8 +509,11 @@ public void runSuite() { private static final long WEDGE_GRACE_MS = 30000L; private void startWedgeWatchdog() { - Thread watchdog = new Thread(() -> { - while (true) { + // Display.startThread rather than a bare Thread: the ports only support + // the thread surface the bytecode compliance gate allows, and this hands + // back a CodenameOneThread that the platform names and reaps for us. + Display.getInstance().startThread(() -> { + while (!suiteFinished) { try { Thread.sleep(1000L); } catch (InterruptedException interrupted) { @@ -531,19 +534,21 @@ private void startWedgeWatchdog() { + " returned from this test " + overrun + "ms past its deadline; the suite" + " cannot continue and every later test is unreached"); log("CN1SS:SUITE:WEDGED test=" + name); - try { - Thread.sleep(250L); - } catch (InterruptedException ignored) { - // fall through to the exit below - } - Runtime.getRuntime().exit(70); + // Nothing here can end the process: exitApplication would have + // to run on the very thread that is stuck, and the raw exit + // calls are not part of the API the ports support. The marker + // above is the contract instead -- the capture harness watches + // for it and stops the run, having been told which test to + // blame. + return; } - }); - watchdog.setName("cn1ss-wedge-watchdog"); - watchdog.setDaemon(true); - watchdog.start(); + }, "cn1ss-wedge-watchdog").start(); } + /// Set once the suite is over so the watchdog thread returns instead of + /// outliving the run. + private volatile boolean suiteFinished; + private void runNextTest(int index) { int offset = prependedTest != null ? 1 : 0; boolean includeJavaSeReferences = "SE".equals( @@ -728,6 +733,7 @@ private void finishSuite() { } log("CN1SS:INFO:swift_diag_status=" + status); } finally { + suiteFinished = true; log("CN1SS:SUITE:FINISHED"); } try { diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java index 8f1a5eb56af..60c049e3812 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java @@ -1225,6 +1225,10 @@ public void run() { final java.util.concurrent.atomic.AtomicInteger finishedTests = new java.util.concurrent.atomic.AtomicInteger(0); final java.util.concurrent.atomic.AtomicReference lastLine = new java.util.concurrent.atomic.AtomicReference(""); + // Set by the suite watchdog when a test blocks the event dispatch + // thread; the run cannot progress past that point, so stop waiting. + final java.util.concurrent.atomic.AtomicReference wedged = + new java.util.concurrent.atomic.AtomicReference(); // The real shared benchmark emits "CN1SS:STAT:: " lines // (Base64NativePerformanceTest: base64 native/CN1/SIMD + image // createMask/applyMask/modifyAlpha/PNG/JPEG, plus the SIMD kernel tally), @@ -1255,6 +1259,7 @@ public void run() { performanceFinished.set(true); } } + if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } int suite = line.indexOf("CN1SS:"); if (suite >= 0) { suiteLog.add(line.substring(suite)); } if (line.contains("CN1SS:") || line.contains("suite ")) { lastLine.set(line); } @@ -1286,6 +1291,10 @@ public void run() { long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { if (finished.get()) { break; } + if (wedged.get() != null) { + System.out.println("CN1SS:HARNESS: " + wedged.get()); + break; + } pngs = countPngFiles(outDir); if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); } if (!requireSuite && pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs @@ -1293,6 +1302,9 @@ public void run() { Thread.sleep(3000); } pngs = countPngFiles(outDir); + assertTrue(wedged.get() == null, + "the suite stopped because a test blocked the event dispatch thread: " + + wedged.get()); assertTrue(finished.get() || (!requireSuite && pngs >= minPngs && (!requirePerformance || performanceFinished.get())), "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index f374f19a818..0d304694a61 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -373,6 +373,9 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { appPb.redirectErrorStream(true); app = appPb.start(); final AtomicBoolean finished = new AtomicBoolean(false); + // Set by the suite watchdog when a test blocks the event dispatch thread; + // the run cannot progress past that point, so stop instead of waiting. + final java.util.concurrent.atomic.AtomicReference wedged = new java.util.concurrent.atomic.AtomicReference<>(); final Process appF = app; Thread areader = new Thread(() -> { // Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when @@ -395,6 +398,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { while ((line = r.readLine()) != null) { if (tee != null) { tee.println(line); } if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); } + if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } } } catch (IOException ignore) { } @@ -429,6 +433,10 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { if (finished.get()) { break; } + if (wedged.get() != null) { + System.out.println("CN1SS:HARNESS: " + wedged.get()); + break; + } if (!app.isAlive()) { // The suite intermittently DIES mid-run with no output (the // tee cuts mid-line): surface the exit status -- 128+N means @@ -449,6 +457,9 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs + " -- every test after the last logged one is reported as never run."); } + assertTrue(wedged.get() == null, + "the suite stopped because a test blocked the event dispatch thread: " + + wedged.get()); assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" + " suiteFinished=" + finished.get() + "\n" + serverLog); From 902b535c8051a953a9e3dbc99f368f34172bdd64 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:41:50 +0300 Subject: [PATCH 08/48] Address the second review round across crypto, time zones and the sweep Crypto - The RNG now fails closed. RAND_bytes and BCryptGenRandom report failure to Java, which throws, instead of leaving a zeroed buffer that KeyGenerator would hand out as a key. - OAEP masks with SHA-1 even when the digest is SHA-256, matching the JCE providers behind the JavaSE and Android ports. Naming the digest for both halves made anything sealed on a desktop port undecryptable elsewhere. - Initialization vectors are checked before they reach the platform library: a missing GCM nonce silently repeated across messages under one key, and a short CBC IV was read as a whole block past the Java array. - Windows imports private keys through NCrypt, which takes PKCS#8 for both RSA and EC, so the ECDSA signature APIs work instead of decoding every key as RSA. Sign and verify pick their padding from the key's own algorithm. Time zones - Custom IDs split their last two digits as minutes for the three-digit form too, so GMT+012 is UTC+00:12 rather than UTC+12. - The Windows raw offset samples the current year and prefers the later standard-time reading. A zone whose base offset changed mid-year with neither sample flagged as daylight saving -- Asia/Almaty in 2024 -- would otherwise report its retired offset forever. - The UWP native reads its fields as UTC like the POSIX, JavaScript and iOS implementations, rather than as host-local time. Port status - A report whose generated_at cannot be parsed is unusable rather than publishable; it would otherwise poison the sweep and the page's own rendering. - The sweep merges artifacts across candidate runs until every port a workflow owns is covered, so one failed matrix leg no longer hides the others, and compares exact elapsed seconds rather than whole days. - A feature whose tests all passed or were documented skips keeps its noted pass even when the suite run stopped early; the completion fallback now runs after that case rather than before it. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 28 ++- .../impl/linux/LinuxImplementation.java | 23 ++- .../com/codename1/impl/linux/LinuxNative.java | 3 +- .../UWP/VSProjectTemplate/UWPApp/App.xaml.cs | 8 +- .../nativeSources/cn1_windows_crypto.c | 195 +++++++++++------- .../impl/windows/WindowsImplementation.java | 28 ++- .../codename1/impl/windows/WindowsNative.java | 3 +- .../partials/port-status-feature-status.html | 8 +- .../conformance/backfill_port_status.sh | 44 +++- .../conformance/port_status.py | 11 + .../tools/translator/ByteCodeTranslator.java | 2 +- vm/ByteCodeTranslator/src/nativeMethods.m | 28 ++- vm/JavaAPI/src/java/util/TimeZone.java | 14 +- 13 files changed, 281 insertions(+), 114 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index db518bf43a0..9025e45e099 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -81,16 +81,21 @@ static const unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { /* ------------------------------------------------------------ random */ -JAVA_VOID com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { int length = 0; unsigned char* data = (unsigned char*) cn1Bytes(out, &length); if (data == 0 || length <= 0) { - return; + return JAVA_TRUE; } if (RAND_bytes(data, length) != 1) { + // Report the failure rather than leaving the buffer as it stands: + // KeyGenerator hands this straight back as key material, so a quiet + // return would mint a predictable key. cn1CryptoFail("secure random"); memset(data, 0, (size_t) length); + return JAVA_FALSE; } + return JAVA_TRUE; } /* ------------------------------------------------------------ AES */ @@ -120,6 +125,7 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo const unsigned char* aad = cn1Bytes(aadArray, &aadLength); const unsigned char* data = cn1Bytes(dataArray, &dataLength); int gcm = strstr(mode, "/GCM/") != 0; + int ecb = strstr(mode, "/ECB/") != 0; int padded = strstr(mode, "NoPadding") == 0; const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength); EVP_CIPHER_CTX* ctx = 0; @@ -133,6 +139,17 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo cn1CryptoFail("unsupported AES key length"); return JAVA_NULL; } + // OpenSSL would otherwise silently keep the context's zeroed default IV for + // a missing GCM nonce -- repeating a nonce under one key destroys GCM -- + // and read a whole block past a short CBC IV. + if (gcm && ivLength <= 0) { + cn1CryptoFail("AES-GCM requires a nonce"); + return JAVA_NULL; + } + if (!gcm && !ecb && ivLength != 16) { + cn1CryptoFail("AES-CBC requires a 16 byte initialization vector"); + return JAVA_NULL; + } if (gcm && !encrypt) { if (dataLength < CN1_GCM_TAG_BYTES) { cn1CryptoFail("AES-GCM input is shorter than its authentication tag"); @@ -238,9 +255,14 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { if (strstr(transformation, "OAEP") != 0) { const EVP_MD* md = strstr(transformation, "SHA-1") != 0 ? EVP_sha1() : EVP_sha256(); + // The mask function stays on SHA-1 even when the OAEP digest is + // SHA-256. That is what the JCE providers behind the JavaSE and + // Android ports do for this transformation name, and ciphertext has to + // stay readable across ports; naming the digest for both halves would + // make anything sealed here undecryptable there. if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 || - EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) { + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, EVP_sha1()) <= 0) { return 0; } return 1; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 6bc862cee74..07fd63b57e0 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2755,19 +2755,38 @@ private static byte[] cryptoResult(byte[] value, String operation) { @Override public void secureRandomBytes(byte[] out) { - if (out != null && out.length > 0) { - LinuxNative.secureRandomBytes(out); + // Fail loudly: KeyGenerator hands this buffer straight back as key + // material, so a quiet return after the platform RNG failed would mint + // a predictable key. + if (out != null && out.length > 0 && !LinuxNative.secureRandomBytes(out)) { + throw new RuntimeException("secure random failed: " + LinuxNative.lastCryptoError()); + } + } + + /// Rejects an initialization vector the mode cannot use. A GCM nonce that + /// is absent repeats across messages under one key, and a short CBC IV is + /// read as a whole block by the platform library. + private static void checkIv(String transformation, byte[] iv) { + String mode = transformation == null ? "" : transformation; + if (mode.indexOf("/GCM/") >= 0) { + if (iv == null || iv.length == 0) { + throw new RuntimeException("AES-GCM requires a nonce"); + } + } else if (mode.indexOf("/ECB/") < 0 && (iv == null || iv.length != 16)) { + throw new RuntimeException("AES-CBC requires a 16 byte initialization vector"); } } @Override public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + checkIv(transformation, iv); return cryptoResult(LinuxNative.aesCrypt(transformation, true, key, iv, aad, plaintext), "AES encrypt"); } @Override public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + checkIv(transformation, iv); return cryptoResult(LinuxNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), "AES decrypt"); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 79f66a38ff2..641dd1c4a9c 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -393,7 +393,8 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /* ---------------------------------------------------------- crypto */ - public static native void secureRandomBytes(byte[] out); + /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */ + public static native boolean secureRandomBytes(byte[] out); /** * AES in the mode named by {@code transformation}. For GCM the diff --git a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs index c67db7bbdd2..5786dc36acb 100644 --- a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs +++ b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs @@ -391,7 +391,13 @@ public override int getTimezoneOffset(string name, int year, int month, int day, int minutes = timeOfDayMillis / 1000 / 60 - hours * 60; int seconds = timeOfDayMillis / 1000 - (hours * 60 * 60) - (minutes * 60); int millis = timeOfDayMillis % 1000; - return (int)TimeZoneInfo.FindSystemTimeZoneById(name).GetUtcOffset(new DateTime(year, month, day, hours, minutes, seconds, DateTimeKind.Local)).TotalMilliseconds; + // The caller passes UTC fields -- the POSIX implementation of this + // native resolves them with timegm and the JavaScript one with + // Date.UTC -- so read them as UTC here too. DateTimeKind.Local + // shifted the instant by the host offset, which lands on the wrong + // side of a transition when the requested zone changes offset + // inside that window. + return (int)TimeZoneInfo.FindSystemTimeZoneById(name).GetUtcOffset(new DateTime(year, month, day, hours, minutes, seconds, DateTimeKind.Utc)).TotalMilliseconds; } public override int getTimezoneRawOffset(string name) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index b278a53f2c9..a15924c0ecc 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -95,18 +96,23 @@ static unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { /* ------------------------------------------------------------ random */ -JAVA_VOID com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { int length = 0; unsigned char* data = cn1Bytes(out, &length); NTSTATUS status; if (data == 0 || length <= 0) { - return; + return JAVA_TRUE; } status = BCryptGenRandom(NULL, data, (ULONG) length, BCRYPT_USE_SYSTEM_PREFERRED_RNG); if (status != STATUS_SUCCESS) { + /* Report the failure rather than leaving the buffer as it stands: + * KeyGenerator hands this straight back as key material, so a quiet + * return would mint a predictable key. */ cn1CryptoFail("secure random", status); memset(data, 0, (size_t) length); + return JAVA_FALSE; } + return JAVA_TRUE; } /* ------------------------------------------------------------ AES */ @@ -134,6 +140,17 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth; unsigned char tag[CN1_GCM_TAG_BYTES]; + /* A missing GCM nonce would otherwise repeat across messages under one + * key, which destroys the mode, and a short CBC IV is read as a whole + * block. */ + if (gcm && ivLength <= 0) { + cn1CryptoFail("AES-GCM requires a nonce", 0); + return JAVA_NULL; + } + if (!gcm && !ecb && ivLength != 16) { + cn1CryptoFail("AES-CBC requires a 16 byte initialization vector", 0); + return JAVA_NULL; + } if (gcm && !encrypt) { if (dataLength < CN1_GCM_TAG_BYTES) { cn1CryptoFail("AES-GCM input is shorter than its authentication tag", 0); @@ -275,55 +292,69 @@ static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) { return key; } -static BCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, BCRYPT_ALG_HANDLE* algOut) { - CRYPT_PRIVATE_KEY_INFO* info = 0; - DWORD infoLength = 0; - BCRYPT_RSAKEY_BLOB* blob = 0; - DWORD blobLength = 0; - BCRYPT_ALG_HANDLE alg = NULL; - BCRYPT_KEY_HANDLE key = NULL; - NTSTATUS status; - const unsigned char* pkcs1 = der; - DWORD pkcs1Length = (DWORD) length; - - *algOut = NULL; - /* PKCS#8 wraps the PKCS#1 RSAPrivateKey; tolerate a bare PKCS#1 too. */ - if (CryptDecodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, der, (DWORD) length, - CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { - pkcs1 = info->PrivateKey.pbData; - pkcs1Length = info->PrivateKey.cbData; - } - if (!CryptDecodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, pkcs1, pkcs1Length, - CRYPT_DECODE_ALLOC_FLAG, NULL, &blob, &blobLength)) { - cn1CryptoFailLast("private key is not PKCS#8 DER"); - if (info != 0) { - LocalFree(info); - } - return NULL; +/* Imports a PKCS#8 private key of either supported kind. + * + * The earlier version always decoded CNG_RSA_PRIVATE_KEY_BLOB, so an EC key + * failed to import and ECDSA signing could never work. NCrypt takes PKCS#8 + * directly and reads the algorithm out of the key itself, which covers RSA and + * EC with one path; *isEc reports which arrived so the caller can pick the + * matching padding. + */ +static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* isEc) { + NCRYPT_PROV_HANDLE provider = 0; + NCRYPT_KEY_HANDLE key = 0; + SECURITY_STATUS status; + WCHAR algorithm[64]; + DWORD algorithmBytes = 0; + + if (isEc != 0) { + *isEc = 0; + } + status = NCryptOpenStorageProvider(&provider, MS_KEY_STORAGE_PROVIDER, 0); + if (status != ERROR_SUCCESS) { + cn1CryptoFail("key storage provider", (NTSTATUS) status); + return 0; } - status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0); - if (status == STATUS_SUCCESS) { - /* The decoder emits either form depending on which primes it recovered. */ - LPCWSTR blobType = blob->Magic == BCRYPT_RSAFULLPRIVATE_MAGIC - ? BCRYPT_RSAFULLPRIVATE_BLOB : BCRYPT_RSAPRIVATE_BLOB; - status = BCryptImportKeyPair(alg, NULL, blobType, &key, (PUCHAR) blob, blobLength, 0); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("private key import", status); - BCryptCloseAlgorithmProvider(alg, 0); - alg = NULL; - key = NULL; - } + status = NCryptImportKey(provider, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, &key, + (PBYTE) der, (DWORD) length, NCRYPT_DO_NOT_FINALIZE_FLAG); + if (status != ERROR_SUCCESS) { + /* Retry without the no-finalize hint: ephemeral keys import directly. */ + status = NCryptImportKey(provider, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, &key, + (PBYTE) der, (DWORD) length, 0); } else { - cn1CryptoFail("RSA provider", status); + status = NCryptFinalizeKey(key, 0); } - LocalFree(blob); - if (info != 0) { - LocalFree(info); + NCryptFreeObject(provider); + if (status != ERROR_SUCCESS || key == 0) { + cn1CryptoFail("private key is not PKCS#8 DER", (NTSTATUS) status); + if (key != 0) { + NCryptFreeObject(key); + } + return 0; + } + if (isEc != 0 && + NCryptGetProperty(key, NCRYPT_ALGORITHM_GROUP_PROPERTY, (PBYTE) algorithm, + sizeof(algorithm), &algorithmBytes, 0) == ERROR_SUCCESS) { + *isEc = wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0 + || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0; } - *algOut = alg; return key; } +/* True when an X.509 SubjectPublicKeyInfo carries an elliptic-curve key. */ +static int cn1PublicKeyIsEc(const unsigned char* der, int length) { + CERT_PUBLIC_KEY_INFO* info = 0; + DWORD infoLength = 0; + int isEc = 0; + if (CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { + isEc = info->Algorithm.pszObjId != 0 + && strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0; + LocalFree(info); + } + return isEc; +} + static LPCWSTR cn1DigestAlgorithm(const char* algorithm) { if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { return BCRYPT_SHA512_ALGORITHM; @@ -375,9 +406,8 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String int keyLength = 0, dataLength = 0; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - BCRYPT_ALG_HANDLE alg = NULL; - BCRYPT_KEY_HANDLE key = encrypt ? cn1PublicKey(keyDer, keyLength) - : cn1PrivateKey(keyDer, keyLength, &alg); + BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; + NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); BCRYPT_OAEP_PADDING_INFO oaep; int oaepMode = strstr(mode, "OAEP") != 0; void* padding = 0; @@ -387,19 +417,24 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String NTSTATUS status; JAVA_OBJECT result = JAVA_NULL; - if (key == NULL) { + if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } if (oaepMode) { memset(&oaep, 0, sizeof(oaep)); - oaep.pszAlgId = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; + /* CNG derives the mask function from this same digest, and the JCE + * providers behind the JavaSE and Android ports mask with SHA-1 for + * this transformation name. Naming SHA-256 here would make ciphertext + * sealed on those ports undecryptable, so keep the SHA-1 mask. */ + oaep.pszAlgId = BCRYPT_SHA1_ALGORITHM; oaep.pbLabel = NULL; oaep.cbLabel = 0; padding = &oaep; } status = encrypt - ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) - : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags); + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, + NULL, 0, (DWORD*) &outLength, flags); if (status != STATUS_SUCCESS) { cn1CryptoFail("RSA size", status); goto done; @@ -410,8 +445,9 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String goto done; } status = encrypt - ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) - : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags); + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, + out, outLength, (DWORD*) &produced, flags); if (status != STATUS_SUCCESS) { cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); goto done; @@ -420,9 +456,11 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String done: free(out); - BCryptDestroyKey(key); - if (alg != NULL) { - BCryptCloseAlgorithmProvider(alg, 0); + if (publicKey != NULL) { + BCryptDestroyKey(publicKey); + } + if (privateKey != 0) { + NCryptFreeObject(privateKey); } return result; } @@ -430,31 +468,35 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); - int keyLength = 0, dataLength = 0; + int keyLength = 0, dataLength = 0, isEc = 0; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - BCRYPT_ALG_HANDLE alg = NULL; - BCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &alg); + NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &isEc); LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; int digestLength = cn1DigestLength(digestAlgorithm); BCRYPT_PKCS1_PADDING_INFO padding; + /* ECDSA carries no padding parameters; RSA signs with PKCS#1. */ + void* paddingInfo; + DWORD flags; unsigned char* out = 0; - ULONG outLength = 0, produced = 0; - NTSTATUS status; + DWORD outLength = 0, produced = 0; + SECURITY_STATUS status; JAVA_OBJECT result = JAVA_NULL; - if (key == NULL) { + if (key == 0) { return JAVA_NULL; } if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { goto done; } padding.pszAlgId = digestAlgorithm; - status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, NULL, 0, &outLength, - BCRYPT_PAD_PKCS1); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("sign size", status); + paddingInfo = isEc ? NULL : (void*) &padding; + flags = isEc ? 0 : BCRYPT_PAD_PKCS1; + status = NCryptSignHash(key, paddingInfo, digest, (DWORD) digestLength, NULL, 0, + &outLength, flags); + if (status != ERROR_SUCCESS) { + cn1CryptoFail("sign size", (NTSTATUS) status); goto done; } out = (unsigned char*) malloc((size_t) outLength + 1); @@ -462,20 +504,17 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String cn1CryptoFail("out of memory", 0); goto done; } - status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, out, outLength, &produced, - BCRYPT_PAD_PKCS1); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("sign", status); + status = NCryptSignHash(key, paddingInfo, digest, (DWORD) digestLength, out, outLength, + &produced, flags); + if (status != ERROR_SUCCESS) { + cn1CryptoFail("sign", (NTSTATUS) status); goto done; } result = cn1WinNewByteArray(threadStateData, out, (int) produced); done: free(out); - BCryptDestroyKey(key); - if (alg != NULL) { - BCryptCloseAlgorithmProvider(alg, 0); - } + NCryptFreeObject(key); return result; } @@ -487,6 +526,9 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); + /* CryptImportPublicKeyInfoEx2 handles both key kinds; only the padding + * differs, so read the algorithm out of the SubjectPublicKeyInfo. */ + int isEc = cn1PublicKeyIsEc(keyDer, keyLength); BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength); LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; @@ -500,8 +542,9 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { padding.pszAlgId = digestAlgorithm; /* A rejected signature is a normal answer here, not a fault. */ - if (BCryptVerifySignature(key, &padding, digest, (ULONG) digestLength, signature, - (ULONG) signatureLength, BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + if (BCryptVerifySignature(key, isEc ? NULL : &padding, digest, (ULONG) digestLength, + signature, (ULONG) signatureLength, + isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { result = JAVA_TRUE; } } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 50ab8c478bc..ea7a06919e3 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2674,7 +2674,10 @@ public String getAppHomePath() { if (!dir.endsWith("\\") && !dir.endsWith("/")) { dir += getFileSystemSeparator(); } - return "file://" + dir; + // com.codename1.io.File splits paths on '/' only, so a URL carrying + // backslashes would report the whole native path as a file's name and + // "file:/" as its parent. Native I/O accepts either separator. + return "file://" + dir.replace('\\', '/'); } @Override @@ -2760,19 +2763,38 @@ private static byte[] cryptoResult(byte[] value, String operation) { @Override public void secureRandomBytes(byte[] out) { - if (out != null && out.length > 0) { - WindowsNative.secureRandomBytes(out); + // Fail loudly: KeyGenerator hands this buffer straight back as key + // material, so a quiet return after the platform RNG failed would mint + // a predictable key. + if (out != null && out.length > 0 && !WindowsNative.secureRandomBytes(out)) { + throw new RuntimeException("secure random failed: " + WindowsNative.lastCryptoError()); + } + } + + /// Rejects an initialization vector the mode cannot use. A GCM nonce that + /// is absent repeats across messages under one key, and a short CBC IV is + /// read as a whole block by the platform library. + private static void checkIv(String transformation, byte[] iv) { + String mode = transformation == null ? "" : transformation; + if (mode.indexOf("/GCM/") >= 0) { + if (iv == null || iv.length == 0) { + throw new RuntimeException("AES-GCM requires a nonce"); + } + } else if (mode.indexOf("/ECB/") < 0 && (iv == null || iv.length != 16)) { + throw new RuntimeException("AES-CBC requires a 16 byte initialization vector"); } } @Override public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + checkIv(transformation, iv); return cryptoResult(WindowsNative.aesCrypt(transformation, true, key, iv, aad, plaintext), "AES encrypt"); } @Override public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + checkIv(transformation, iv); return cryptoResult(WindowsNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), "AES decrypt"); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index e5b7633e83f..5843a0eded8 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -401,7 +401,8 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /* ---------------------------------------------------------- crypto */ - public static native void secureRandomBytes(byte[] out); + /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */ + public static native boolean secureRandomBytes(byte[] out); /** * AES in the mode named by {@code transformation}. For GCM the diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index e33765bb811..32153e94ed1 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -61,17 +61,17 @@ {{- $state = "pass" -}} {{- $mark = "✓" -}} {{- $label = printf "All %d mapped test%s passed%s" $total (cond (eq $total 1) "" "s") $incomplete -}} - {{- else if not $complete -}} - {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}} {{- else if and $documented (eq (add $passed $skipped) $total) -}} {{- $state = "pass" -}} {{- $mark = "✓" -}} {{- $documentedSkips = $skippedTests -}} {{- if eq $passed 0 -}} - {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata" (delimit $skippedTests ", ") -}} + {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata%s" (delimit $skippedTests ", ") $incomplete -}} {{- else -}} - {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata" $passed $total (delimit $skippedTests ", ") -}} + {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata%s" $passed $total (delimit $skippedTests ", ") $incomplete -}} {{- end -}} + {{- else if not $complete -}} + {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}} {{- else if eq $skipped $total -}} {{- $label = "All mapped tests skipped" -}} {{- end -}} diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 2f0ded77ee2..660db3f9d70 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -80,13 +80,30 @@ while IFS= read -r workflow; do run_id="" download_dir="${tmp_dir}/${workflow}" mkdir -p "${download_dir}" - # A run that died before the suite reported uploads no artifact at all, and - # artifacts expire; walk back until one of the recent runs still has reports. + # Merge across candidate runs rather than stopping at the first with any + # artifact: a failed matrix run can upload the report for one leg only, and + # the other ports that workflow owns would then never be considered. + owned="$(jq -r --arg workflow "${workflow}" '.ports[] | select(.workflow == $workflow) | .id' "${MANIFEST}")" for candidate in ${candidates}; do - if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}" >/dev/null 2>&1; then - run_id="${candidate}" + missing=0 + for port in ${owned}; do + if [ ! -f "${download_dir}/covered-${port}" ]; then + missing=1 + fi + done + if [ "${missing}" -eq 0 ]; then break fi + if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}/run-${candidate}" >/dev/null 2>&1; then + run_id="${candidate}" + while IFS= read -r downloaded; do + found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" + if [ -n "${found}" ] && [ ! -f "${download_dir}/covered-${found}" ]; then + cp "${downloaded}" "${download_dir}/port-status-${found}.json" + : > "${download_dir}/covered-${found}" + fi + done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) + fi done if [ -z "${run_id}" ]; then echo "No recent ${workflow} run has a port status artifact." >&2 @@ -120,7 +137,7 @@ while IFS= read -r workflow; do echo "Publishing ${port} from run ${run_id} of ${workflow} (${generated})." PORT_STATUS_PUBLISH=1 "${SCRIPT_DIR}/publish_port_status.sh" "${report}" published=$((published + 1)) - done < <(find "${download_dir}" -type f -name 'port-status-*.json' | sort) + done < <(find "${download_dir}" -maxdepth 1 -type f -name 'port-status-*.json' | sort) done < <(jq -r '[.ports[].workflow] | unique | .[]' "${MANIFEST}") echo "Port status sweep: published ${published} report(s), ${skipped} already current." @@ -144,7 +161,10 @@ while IFS= read -r port; do continue fi generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" - age_days="$(python3 - "${generated}" <<'PY' + # Compare elapsed seconds, not whole days: the page marks a report stale the + # moment its exact age passes the window, so flooring to days would keep this + # green for almost another day after the column had already gone stale. + age_seconds="$(python3 - "${generated}" <<'AGE' import sys from datetime import datetime, timezone @@ -154,13 +174,15 @@ try: except ValueError: print(-1) else: - print(int((datetime.now(timezone.utc) - stamp).total_seconds() // 86400)) -PY + print(-1 if stamp.tzinfo is None + else int((datetime.now(timezone.utc) - stamp).total_seconds())) +AGE )" - if [ "${age_days}" -lt 0 ]; then + stale_seconds=$((stale_days * 86400)) + if [ "${age_seconds}" -lt 0 ]; then problems+=("${port}: unreadable generated_at ${generated:-}") - elif [ "${age_days}" -gt "${stale_days}" ]; then - problems+=("${port}: last report is ${age_days} days old (limit ${stale_days})") + elif [ "${age_seconds}" -gt "${stale_seconds}" ]; then + problems+=("${port}: last report is $((age_seconds / 3600)) hours old (limit ${stale_days} days)") fi done < <(jq -r '.ports[].id' "${MANIFEST}") diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 70e6d382c15..e8a347613c9 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -611,6 +611,17 @@ def publishable_report_problems( generated_at = report.get("generated_at") if not isinstance(generated_at, str) or not generated_at: malformed.append("report has no generated_at timestamp") + else: + # Anything unparseable ("unknown") would sail through publication and + # then break both the freshness sweep and the page's own time + # rendering, so classify it as unusable here instead. + try: + stamp = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + except ValueError: + malformed.append(f"generated_at {generated_at!r} is not a timestamp") + else: + if stamp.tzinfo is None: + malformed.append(f"generated_at {generated_at!r} has no time zone") mapped = test_to_feature(manifest) tests = report.get("tests") diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f4de6cd14e0..f5057c3233a 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt icu winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt icu winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 41794a66d44..f51ac8e1280 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2796,19 +2796,35 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA cn1_timezone_raw_ctx ctx; #ifdef _WIN32 { - /* The raw offset is the standard-time one: sample both solstices and - * take whichever is not in daylight saving (either hemisphere). */ + /* The raw offset is the current standard-time one. Sample both + * solstices of the current year rather than a fixed past year: a zone + * whose base offset changes (Asia/Almaty moved from UTC+6 to UTC+5 + * during 2024, with neither sample flagged as daylight saving) would + * otherwise report its retired offset forever. When neither sample is + * in daylight saving they can still differ, so prefer the later one -- + * that is the rule in force now. */ int januaryOffset = 0, januaryDst = 0, julyOffset = 0, julyDst = 0; - if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 1, 1, 43200000), + time_t nowSeconds = time(NULL); + struct tm nowUtc; + int currentYear = 2024; +#ifdef _WIN32 + if (gmtime_s(&nowUtc, &nowSeconds) == 0) { + currentYear = nowUtc.tm_year + 1900; + } +#endif + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 1, 1, 43200000), &januaryOffset, &januaryDst) && - cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 7, 1, 43200000), + cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 7, 1, 43200000), &julyOffset, &julyDst)) { - if (!januaryDst) { - return januaryOffset; + if (!januaryDst && !julyDst) { + return julyOffset; } if (!julyDst) { return julyOffset; } + if (!januaryDst) { + return januaryOffset; + } return januaryOffset < julyOffset ? januaryOffset : julyOffset; } } diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 2fbbaa0cb46..39e6e048c8b 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -254,11 +254,15 @@ private static TimeZone customTimeZone(String ID) { String minutePart = "0"; String secondPart = "0"; if (colon < 0) { - // The colon-less forms are hh, hhmm and hhmmss. - if (digits.length() == 4 || digits.length() == 6) { - hourPart = digits.substring(0, 2); - minutePart = digits.substring(2, 4); - secondPart = digits.length() == 6 ? digits.substring(4, 6) : "0"; + // The colon-less forms are h, hh, hmm, hhmm and hhmmss: the last two + // digits are always the minutes once there are more than two, so a + // one-digit hour ("GMT+012" is UTC+00:12) splits the same way. + int length = digits.length(); + if (length == 3 || length == 4 || length == 6) { + int hourDigits = length == 6 ? 2 : length - 2; + hourPart = digits.substring(0, hourDigits); + minutePart = digits.substring(hourDigits, hourDigits + 2); + secondPart = length == 6 ? digits.substring(4, 6) : "0"; } } else { int secondColon = rest.indexOf(':'); From 81fe244c37f8f8062679ce678b76a021eb69b25c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:13:43 +0300 Subject: [PATCH 09/48] Pad OAEP in the Windows port and encode ECDSA signatures as DER Two defects the previous round introduced. CNG's BCRYPT_OAEP_PADDING_INFO names one digest, and CNG uses it for the label hash as well as the mask, so naming SHA-1 there to match the JCE mask downgraded the whole transformation to OAEP-SHA1 -- weaker than documented, and still not interoperable. The padding is now built in the port (SHA-256 label hash, SHA-1 mask, the pairing the JCE providers and the Linux port use) and the key operation runs unpadded, which is the only way CNG can express that combination. NCryptSignHash answers the fixed-width r||s of P1363 while the portable Signature contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. Sign converts to DER and verify converts back, so ECDSA signatures cross between Windows and the other ports. Both encodings were verified against OpenSSL off-device rather than reasoned about: our OAEP block is accepted by OpenSSL's own SHA-256/SHA-1 unpadder and ours accepts theirs, and our DER matches i2d_ECDSA_SIG byte for byte, including the leading-zero trim and the high-bit pad. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_crypto.c | 365 ++++++++++++++++-- 1 file changed, 328 insertions(+), 37 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index a15924c0ecc..8a29158113f 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -399,6 +399,209 @@ static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, return 1; } + +/* ------------------------------------------------- OAEP and ECDSA encodings + * + * Two shapes CNG cannot produce on its own: + * + * OAEP -- BCRYPT_OAEP_PADDING_INFO carries one digest, which CNG uses for both + * the label hash and the mask function. The JCE providers behind the JavaSE and + * Android ports pair a SHA-256 label hash with a SHA-1 mask for + * "OAEPWithSHA-256AndMGF1Padding", and the Linux port matches them, so + * ciphertext has to use that pairing to stay readable across ports. Naming one + * digest for both halves either weakens the label hash or breaks interop, so + * the padding is built here and the key operation runs unpadded. + * + * ECDSA -- NCryptSignHash answers the fixed-width r||s of P1363, while the + * portable Signature contract (and Jwt.derToJoseEcdsa) expects ASN.1 DER, so + * signatures are converted in both directions. + */ + +static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedLength, + unsigned char* mask, int maskLength) { + int digestLength = cn1DigestLength(digestAlgorithm); + unsigned char counted[256]; + unsigned char digest[64]; + int produced = 0; + unsigned int counter = 0; + if (seedLength + 4 > (int) sizeof(counted)) { + return 0; + } + memcpy(counted, seed, (size_t) seedLength); + while (produced < maskLength) { + int chunk = maskLength - produced; + counted[seedLength] = (unsigned char) ((counter >> 24) & 0xff); + counted[seedLength + 1] = (unsigned char) ((counter >> 16) & 0xff); + counted[seedLength + 2] = (unsigned char) ((counter >> 8) & 0xff); + counted[seedLength + 3] = (unsigned char) (counter & 0xff); + if (!cn1Digest(digestAlgorithm, counted, seedLength + 4, digest, digestLength)) { + return 0; + } + if (chunk > digestLength) { + chunk = digestLength; + } + memcpy(mask + produced, digest, (size_t) chunk); + produced += chunk; + counter++; + } + return 1; +} + +/* EME-OAEP encoding of `message` into a `blockLength`-byte block. */ +static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned char* message, + int messageLength, unsigned char* block, int blockLength) { + int hashLength = cn1DigestLength(labelDigest); + int dbLength = blockLength - hashLength - 1; + unsigned char seed[64]; + unsigned char mask[512]; + int i; + if (dbLength <= 0 || messageLength > dbLength - hashLength - 1 || dbLength > (int) sizeof(mask)) { + cn1CryptoFail("RSA-OAEP message is too long for the key", 0); + return 0; + } + memset(block, 0, (size_t) blockLength); + /* DB = lHash || PS || 0x01 || M, with an empty label. */ + if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, block + 1 + hashLength, hashLength)) { + return 0; + } + block[blockLength - messageLength - 1] = 0x01; + if (messageLength > 0) { + memcpy(block + blockLength - messageLength, message, (size_t) messageLength); + } + if (BCryptGenRandom(NULL, seed, (ULONG) hashLength, BCRYPT_USE_SYSTEM_PREFERRED_RNG) + != STATUS_SUCCESS) { + cn1CryptoFail("RSA-OAEP seed", 0); + return 0; + } + if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + return 0; + } + for (i = 0; i < dbLength; i++) { + block[1 + hashLength + i] ^= mask[i]; + } + if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + return 0; + } + for (i = 0; i < hashLength; i++) { + block[1 + i] = (unsigned char) (seed[i] ^ mask[i]); + } + return 1; +} + +/* Reverses cn1OaepEncode, writing the recovered message and its length. */ +static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* block, + int blockLength, unsigned char* message, int* messageLength) { + int hashLength = cn1DigestLength(labelDigest); + int dbLength = blockLength - hashLength - 1; + unsigned char mask[512]; + unsigned char labelHash[64]; + unsigned char seed[64]; + int i, index; + if (dbLength <= 0 || dbLength > (int) sizeof(mask) || block[0] != 0x00) { + cn1CryptoFail("RSA-OAEP block is malformed", 0); + return 0; + } + if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + return 0; + } + for (i = 0; i < hashLength; i++) { + seed[i] = (unsigned char) (block[1 + i] ^ mask[i]); + } + if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + return 0; + } + for (i = 0; i < dbLength; i++) { + block[1 + hashLength + i] ^= mask[i]; + } + if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) { + return 0; + } + if (memcmp(labelHash, block + 1 + hashLength, (size_t) hashLength) != 0) { + cn1CryptoFail("RSA-OAEP label hash does not match", 0); + return 0; + } + index = 1 + hashLength + hashLength; + while (index < blockLength && block[index] == 0x00) { + index++; + } + if (index >= blockLength || block[index] != 0x01) { + cn1CryptoFail("RSA-OAEP padding is malformed", 0); + return 0; + } + index++; + *messageLength = blockLength - index; + if (*messageLength > 0) { + memcpy(message, block + index, (size_t) *messageLength); + } + return 1; +} + +/* One DER INTEGER holding an unsigned big-endian value. */ +static int cn1DerInteger(const unsigned char* value, int length, unsigned char* out) { + int start = 0; + int written = 0; + int pad; + while (start < length - 1 && value[start] == 0) { + start++; + } + pad = (value[start] & 0x80) != 0 ? 1 : 0; + out[written++] = 0x02; + out[written++] = (unsigned char) (length - start + pad); + if (pad) { + out[written++] = 0x00; + } + memcpy(out + written, value + start, (size_t) (length - start)); + return written + length - start; +} + +/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects. */ +static int cn1EcdsaToDer(const unsigned char* raw, int rawLength, unsigned char* der) { + int half = rawLength / 2; + unsigned char body[160]; + int bodyLength = 0; + if (rawLength <= 0 || (rawLength & 1) != 0 || half > 66) { + return 0; + } + bodyLength = cn1DerInteger(raw, half, body); + bodyLength += cn1DerInteger(raw + half, half, body + bodyLength); + der[0] = 0x30; + der[1] = (unsigned char) bodyLength; + memcpy(der + 2, body, (size_t) bodyLength); + return bodyLength + 2; +} + +/* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */ +static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) { + int index = 2; + int part; + if (derLength < 8 || der[0] != 0x30) { + return 0; + } + memset(raw, 0, (size_t) (half * 2)); + for (part = 0; part < 2; part++) { + int length, start, copy; + if (index + 2 > derLength || der[index] != 0x02) { + return 0; + } + length = der[index + 1]; + index += 2; + if (index + length > derLength) { + return 0; + } + start = 0; + while (start < length - 1 && der[index + start] == 0) { + start++; + } + copy = length - start; + if (copy > half) { + return 0; + } + memcpy(raw + part * half + (half - copy), der + index + start, (size_t) copy); + index += length; + } + return 1; +} + JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { @@ -408,54 +611,113 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String unsigned char* data = cn1Bytes(dataArray, &dataLength); BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); - BCRYPT_OAEP_PADDING_INFO oaep; int oaepMode = strstr(mode, "OAEP") != 0; - void* padding = 0; - ULONG flags = oaepMode ? BCRYPT_PAD_OAEP : BCRYPT_PAD_PKCS1; + LPCWSTR labelDigest = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; unsigned char* out = 0; + unsigned char* block = 0; ULONG outLength = 0, produced = 0; + DWORD modulusBytes = 0, propertyBytes = 0; NTSTATUS status; JAVA_OBJECT result = JAVA_NULL; if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } + if (oaepMode) { - memset(&oaep, 0, sizeof(oaep)); - /* CNG derives the mask function from this same digest, and the JCE - * providers behind the JavaSE and Android ports mask with SHA-1 for - * this transformation name. Naming SHA-256 here would make ciphertext - * sealed on those ports undecryptable, so keep the SHA-1 mask. */ - oaep.pszAlgId = BCRYPT_SHA1_ALGORITHM; - oaep.pbLabel = NULL; - oaep.cbLabel = 0; - padding = &oaep; - } - status = encrypt - ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) - : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, - NULL, 0, (DWORD*) &outLength, flags); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("RSA size", status); - goto done; - } - out = (unsigned char*) malloc((size_t) outLength + 1); - if (out == 0) { - cn1CryptoFail("out of memory", 0); - goto done; - } - status = encrypt - ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) - : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, - out, outLength, (DWORD*) &produced, flags); - if (status != STATUS_SUCCESS) { - cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); - goto done; + /* CNG's padding info names one digest for both the label hash and the + * mask, so it cannot express the SHA-256 label with the SHA-1 mask that + * the JCE providers -- and therefore the JavaSE, Android and Linux + * ports -- use for this transformation. Pad here and run the key + * operation raw so ciphertext stays readable across ports. */ + ULONG bits = 0; + if (encrypt) { + status = BCryptGetProperty(publicKey, BCRYPT_KEY_STRENGTH, (PUCHAR) &bits, + sizeof(bits), &propertyBytes, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA key size", status); + goto done; + } + modulusBytes = bits / 8; + } else { + if (NCryptGetProperty(privateKey, NCRYPT_LENGTH_PROPERTY, (PBYTE) &bits, + sizeof(bits), &propertyBytes, 0) != ERROR_SUCCESS) { + cn1CryptoFail("RSA key size", 0); + goto done; + } + modulusBytes = bits / 8; + } + block = (unsigned char*) malloc((size_t) modulusBytes + 1); + if (block == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + if (encrypt) { + if (!cn1OaepEncode(labelDigest, BCRYPT_SHA1_ALGORITHM, data, dataLength, block, + (int) modulusBytes)) { + goto done; + } + out = (unsigned char*) malloc((size_t) modulusBytes + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = BCryptEncrypt(publicKey, block, modulusBytes, NULL, NULL, 0, out, + modulusBytes, &produced, BCRYPT_PAD_NONE); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA encrypt", status); + goto done; + } + } else { + DWORD recovered = 0; + int messageLength = 0; + if (NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, block, modulusBytes, + &recovered, NCRYPT_NO_PADDING_FLAG) != ERROR_SUCCESS) { + cn1CryptoFail("RSA decrypt", 0); + goto done; + } + out = (unsigned char*) malloc((size_t) modulusBytes + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + if (!cn1OaepDecode(labelDigest, BCRYPT_SHA1_ALGORITHM, block, (int) modulusBytes, + out, &messageLength)) { + goto done; + } + produced = (ULONG) messageLength; + } + } else { + ULONG flags = BCRYPT_PAD_PKCS1; + status = encrypt + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, NULL, NULL, 0, NULL, 0, + &outLength, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, NULL, 0, + (DWORD*) &outLength, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = encrypt + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, NULL, NULL, 0, out, + outLength, &produced, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, out, + outLength, (DWORD*) &produced, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); + goto done; + } } result = cn1WinNewByteArray(threadStateData, out, (int) produced); done: free(out); + free(block); if (publicKey != NULL) { BCryptDestroyKey(publicKey); } @@ -510,7 +772,19 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String cn1CryptoFail("sign", (NTSTATUS) status); goto done; } - result = cn1WinNewByteArray(threadStateData, out, (int) produced); + if (isEc) { + /* NCrypt answers the fixed-width r||s of P1363; the portable Signature + * contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. */ + unsigned char der[160]; + int derLength = cn1EcdsaToDer(out, (int) produced, der); + if (derLength <= 0) { + cn1CryptoFail("ECDSA signature encoding", 0); + goto done; + } + result = cn1WinNewByteArray(threadStateData, der, derLength); + } else { + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + } done: free(out); @@ -540,11 +814,28 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str return JAVA_FALSE; } if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { + unsigned char raw[132]; + const unsigned char* toVerify = signature; + ULONG toVerifyLength = (ULONG) signatureLength; + int usable = 1; padding.pszAlgId = digestAlgorithm; + if (isEc) { + /* Signatures arrive as DER; CNG verifies the P1363 pair. The half + * width follows the key size, which for the supported curves is + * the digest the caller named. */ + int half = cn1DigestLength(digestAlgorithm); + if (half == 20) { + half = 32; + } + usable = cn1EcdsaFromDer(signature, signatureLength, raw, half); + toVerify = raw; + toVerifyLength = (ULONG) (half * 2); + } /* A rejected signature is a normal answer here, not a fault. */ - if (BCryptVerifySignature(key, isEc ? NULL : &padding, digest, (ULONG) digestLength, - signature, (ULONG) signatureLength, - isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + if (usable && BCryptVerifySignature(key, isEc ? NULL : &padding, digest, + (ULONG) digestLength, (PUCHAR) toVerify, + toVerifyLength, + isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { result = JAVA_TRUE; } } From 9ef1a7bb019f8e3a3c250a3aead0974266917597 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:18:33 +0300 Subject: [PATCH 10/48] Pad OAEP in the Windows port and encode ECDSA signatures as DER Two defects the previous round introduced. CNG's BCRYPT_OAEP_PADDING_INFO names one digest, and CNG uses it for the label hash as well as the mask, so naming SHA-1 there to match the JCE mask downgraded the whole transformation to OAEP-SHA1 -- weaker than documented, and still not interoperable. The padding is now built in the port (SHA-256 label hash, SHA-1 mask, the pairing the JCE providers and the Linux port use) and the key operation runs unpadded, which is the only way CNG can express that combination. NCryptSignHash answers the fixed-width r||s of P1363 while the portable Signature contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. Sign converts to DER and verify converts back, so ECDSA signatures cross between Windows and the other ports. Both encodings were verified against OpenSSL off-device rather than reasoned about: our OAEP block is accepted by OpenSSL's own SHA-256/SHA-1 unpadder and ours accepts theirs, and our DER matches i2d_ECDSA_SIG byte for byte, including the leading-zero trim and the high-bit pad. The UWP template file picked up the project header, which the gate requires of any file this branch touches. Co-Authored-By: Claude Opus 5 (1M context) --- .../UWP/VSProjectTemplate/UWPApp/App.xaml.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs index 5786dc36acb..46dbd125738 100644 --- a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs +++ b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ using IKVM.Attributes; using IKVM.Internal; using System; From 879f159fa7ee2e701b41cc4a99291eed354792bf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:05:30 +0300 Subject: [PATCH 11/48] Resolve Windows ICU at runtime so the clean target still links The clean-target build compiles nativeMethods.m for Windows but links only its own library set, so naming the ICU entry points directly left u_strFromUTF8, ucal_open, ucal_setMillis, ucal_get and ucal_close undefined and took ten of its integration tests down with it. icu.dll is now opened with LoadLibrary and the four calendar entry points resolved through GetProcAddress, with the UTF-16 conversion done by MultiByteToWideChar instead of ICU's own. Nothing includes or links icu.lib any more, so the minimal SDK layout the clean target builds against is enough, and a host without ICU falls back to the C runtime rather than failing to load. The POSIX path is unchanged and still passes its probe through the clean target, custom offset IDs included. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/translator/ByteCodeTranslator.java | 2 +- vm/ByteCodeTranslator/src/nativeMethods.m | 74 +++++++++++++++---- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f5057c3233a..bdd68811d6e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt icu winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index f51ac8e1280..4c9e166fdfe 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2618,29 +2618,75 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx * instant, or reports failure so the caller can fall back. */ #ifdef _WIN32 -#include +/* + * ICU ships with Windows 10 1703 and later as icu.dll, and its calendar speaks + * IANA identifiers and knows the daylight rules for a given instant. It is + * resolved at runtime rather than linked: the clean target builds against a + * minimal SDK layout that need not carry icu.lib or , and a host + * without ICU degrades to the fallback below instead of failing to load. + * + * The two enum values used here are fixed by ICU's stable C API -- + * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields. + */ +#define CN1_UCAL_GREGORIAN 1 +#define CN1_UCAL_ZONE_OFFSET 15 +#define CN1_UCAL_DST_OFFSET 16 + +typedef void* CN1UCalendar; + +static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); +static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_close)(CN1UCalendar); +static int cn1IcuResolved; + +static int cn1IcuAvailable(void) { + HMODULE icu; + if (cn1IcuResolved != 0) { + return cn1IcuResolved > 0; + } + cn1IcuResolved = -1; + icu = LoadLibraryA("icu.dll"); + if (icu == NULL) { + return 0; + } + cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_open"); + cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) + GetProcAddress(icu, "ucal_setMillis"); + cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_get"); + cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); + if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { + return 0; + } + cn1IcuResolved = 1; + return 1; +} +/* Total offset (zone plus daylight) for a zone at an instant, 0 when ICU + * cannot answer -- the caller then keeps the C runtime's reply. */ static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { - UErrorCode status = U_ZERO_ERROR; - UChar zone[128]; - UCalendar* cal; + WCHAR zone[128]; + CN1UCalendar cal; + int32_t status = 0; int32_t zoneOffset, dstOffset; - if (zoneId == 0 || zoneId[0] == 0) { + if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) { return 0; } - u_strFromUTF8(zone, (int32_t) (sizeof(zone) / sizeof(zone[0])), NULL, zoneId, -1, &status); - if (U_FAILURE(status)) { + if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone, + (int) (sizeof(zone) / sizeof(zone[0]))) == 0) { return 0; } - cal = ucal_open(zone, -1, "en_US", UCAL_GREGORIAN, &status); - if (U_FAILURE(status) || cal == 0) { + cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status); + if (status > 0 || cal == 0) { return 0; } - ucal_setMillis(cal, (UDate) millis, &status); - zoneOffset = ucal_get(cal, UCAL_ZONE_OFFSET, &status); - dstOffset = ucal_get(cal, UCAL_DST_OFFSET, &status); - ucal_close(cal); - if (U_FAILURE(status)) { + cn1_ucal_setMillis(cal, (double) millis, &status); + zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status); + dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status); + cn1_ucal_close(cal); + if (status > 0) { return 0; } if (offsetOut != 0) { From e04582e5dc1dea5f96d251844164d0d02dab9dd1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:08:35 +0300 Subject: [PATCH 12/48] Keep the wedge watchdog off the JavaScript port That port schedules its threads cooperatively on the browser's single thread, so the watchdog waking every second to poll competed with the suite it was meant to be watching: the JavaScript job stopped finishing and burned its whole forty-minute budget, exiting 5 on the harness timeout. The watchdog now returns immediately on HTML5. Its harness already bounds the run, and the wedges it exists to name -- Media360Panorama on Linux, CalendarApiTest on Windows -- are on the native desktop ports. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/Cn1ssDeviceRunner.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index eba4b56d72f..9b5fcc41dd0 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -509,6 +509,15 @@ public void runSuite() { private static final long WEDGE_GRACE_MS = 30000L; private void startWedgeWatchdog() { + // Not on HTML5. That port schedules its threads cooperatively on the + // browser's single thread, so a second thread waking every second to + // poll competes with the suite it is supposed to be watching -- it cost + // the JavaScript job its whole 40 minute budget. Its harness already + // bounds the run, and the wedges this watchdog exists to name are on + // the native desktop ports. + if ("HTML5".equals(Display.getInstance().getPlatformName())) { + return; + } // Display.startThread rather than a bare Thread: the ports only support // the thread surface the bytecode compliance gate allows, and this hands // back a CodenameOneThread that the platform names and reaps for us. From 887e31cce7a626fd8cafe20e0c1a90639078432e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:18:37 +0700 Subject: [PATCH 13/48] Keep the Windows natives and the runner's lambda numbering intact Two self-inflicted CI breakages from the previous round, both in shared plumbing that the local checks did not exercise. Windows natives did not compile The ICU lookup added for named time zones used WCHAR, HMODULE, LoadLibraryA and MultiByteToWideChar directly in nativeMethods, which never sees : cn1_win_compat.h is deliberately free of it, because cn1_globals.h pulls that header into every translated compilation unit and leaking the Win32 macro soup that broadly collides with generated symbol names. The lookup now lives in cn1_win_compat.c, the one translation unit that does include and whose stated job is mapping the runtime's API onto Win32, and nativeMethods calls it through a single declaration. This is what took down clean-target on both architectures, the x64 PE cross-compile, the screenshot capture and the suite exe build. Verified by running the cross-compile locally rather than in CI: CleanTargetIntegrationTest#crossCompilesWindowsExeWithXwin now passes on this machine against an xwin-laid-out SDK with clang-cl and lld-link, which is the same test and toolchain the Linux cross-compile job runs. The wedge watchdog stopped the JavaScript suite after one test The JavaScript port hand-binds three of Cn1ssDeviceRunner's Runnable lambdas by translated id -- lambda_1_run through _3_run in port.js -- and the translator numbers lambdas in declaration order within the class. The watchdog was written as a lambda ahead of the ones in runNextTest, so every id shifted by one: javap confirms lambda$startWedgeWatchdog$2 displacing runNextTest$2 to $3, awaitTestCompletion$3 to $4 and finalizeTest$4 to $5. The ids still resolved, so nothing reported an error -- the bridge that polls for a test's completion was simply handed the lambda that starts a test, and the suite stopped advancing after its first one. The run log shows it plainly: on master lambda1RunBridge takes index 0 and lambda2RunBridge index 1, while on the broken build lambda2RunBridge takes index 0. The watchdog body is now a named inner class, which has its own method namespace and leaves that numbering alone, with a comment on it saying why it must not be turned back into a lambda. Compiling the runner against master and against this change emits an identical set of seven synthetic lambdas. The HTML5 early return stays, but the reasoning in its comment was wrong and has been corrected: the previous round blamed the watchdog thread for competing with the cooperative scheduler, and the symptom was unchanged by skipping it -- one screenshot, then a forty minute timeout -- because the renumbering, not the thread, was the cause. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 44 ++++++---- vm/ByteCodeTranslator/src/cn1_win_compat.c | 84 +++++++++++++++++++ vm/ByteCodeTranslator/src/cn1_win_compat.h | 9 ++ vm/ByteCodeTranslator/src/nativeMethods.m | 83 ++---------------- 4 files changed, 128 insertions(+), 92 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 9b5fcc41dd0..9c2a546a782 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -508,20 +508,20 @@ public void runSuite() { /// published as "never run" with nothing saying why. private static final long WEDGE_GRACE_MS = 30000L; - private void startWedgeWatchdog() { - // Not on HTML5. That port schedules its threads cooperatively on the - // browser's single thread, so a second thread waking every second to - // poll competes with the suite it is supposed to be watching -- it cost - // the JavaScript job its whole 40 minute budget. Its harness already - // bounds the run, and the wedges this watchdog exists to name are on - // the native desktop ports. - if ("HTML5".equals(Display.getInstance().getPlatformName())) { - return; - } - // Display.startThread rather than a bare Thread: the ports only support - // the thread surface the bytecode compliance gate allows, and this hands - // back a CodenameOneThread that the platform names and reaps for us. - Display.getInstance().startThread(() -> { + /// The watchdog body. + /// + /// This is a named class and MUST NOT be rewritten as a lambda. The + /// JavaScript port hand-binds three of this class's Runnable lambdas by + /// translated id -- Cn1ssDeviceRunner_lambda_1_run through _3_run, see + /// bindCiFallback in port.js -- and the translator numbers lambdas in their + /// declaration order within the class. A lambda declared here, ahead of the + /// ones in runNextTest, shifts every one of those ids by one, so the bridge + /// that polls for a test's completion gets handed the lambda that starts a + /// test instead. The ids still resolve, so nothing reports an error: the + /// suite simply stops advancing after its first test. A named class has its + /// own method namespace and leaves that numbering alone. + private final class WedgeWatchdog implements Runnable { + public void run() { while (!suiteFinished) { try { Thread.sleep(1000L); @@ -551,7 +551,21 @@ private void startWedgeWatchdog() { // blame. return; } - }, "cn1ss-wedge-watchdog").start(); + } + } + + private void startWedgeWatchdog() { + // Not on HTML5. That port drives the suite from the browser's single + // thread through the bridges described on WedgeWatchdog, and its harness + // already bounds the run and force-advances a stalled dispatch. The + // wedges this watchdog exists to name are on the native desktop ports. + if ("HTML5".equals(Display.getInstance().getPlatformName())) { + return; + } + // Display.startThread rather than a bare Thread: the ports only support + // the thread surface the bytecode compliance gate allows, and this hands + // back a CodenameOneThread that the platform names and reaps for us. + Display.getInstance().startThread(new WedgeWatchdog(), "cn1ss-wedge-watchdog").start(); } /// Set once the suite is over so the watchdog thread returns instead of diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index efcfd12fee2..20527ae1945 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -288,4 +288,88 @@ int gettimeofday(struct timeval* tv, void* tz) { return 0; } +/* + * IANA time zone offsets. + * + * The Microsoft C runtime only understands the "EST5EDT" form of TZ, not an + * IANA identifier, and its struct tm carries no GMT offset at all -- so the + * POSIX path the runtime uses elsewhere reports zero for every named zone. + * Windows ships ICU as icu.dll (Windows 10 1703 and later), whose calendar + * speaks IANA identifiers directly and knows the daylight rules for the + * instant being asked about. + * + * ICU is resolved at runtime rather than linked: the clean target builds + * against a minimal SDK layout that need not carry icu.lib or , and a + * host without ICU degrades to the caller's fallback instead of failing to + * load. The two enum values used here are fixed by ICU's stable C API -- + * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields. + */ +#define CN1_UCAL_GREGORIAN 1 +#define CN1_UCAL_ZONE_OFFSET 15 +#define CN1_UCAL_DST_OFFSET 16 + +typedef void* CN1UCalendar; + +static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); +static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_close)(CN1UCalendar); +static int cn1IcuResolved; + +static int cn1IcuAvailable(void) { + HMODULE icu; + if (cn1IcuResolved != 0) { + return cn1IcuResolved > 0; + } + cn1IcuResolved = -1; + icu = LoadLibraryA("icu.dll"); + if (icu == NULL) { + return 0; + } + cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_open"); + cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) + GetProcAddress(icu, "ucal_setMillis"); + cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_get"); + cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); + if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { + return 0; + } + cn1IcuResolved = 1; + return 1; +} + +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { + WCHAR zone[128]; + CN1UCalendar cal; + int32_t status = 0; + int32_t zoneOffset, dstOffset; + if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) { + return 0; + } + if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone, + (int) (sizeof(zone) / sizeof(zone[0]))) == 0) { + return 0; + } + cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status); + if (status > 0 || cal == 0) { + return 0; + } + cn1_ucal_setMillis(cal, (double) millis, &status); + zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status); + dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status); + cn1_ucal_close(cal); + if (status > 0) { + return 0; + } + if (offsetOut != 0) { + *offsetOut = (int) (zoneOffset + dstOffset); + } + if (dstOut != 0) { + *dstOut = dstOffset != 0; + } + return 1; +} + #endif /* _WIN32 */ diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h index 599e02f5446..1ff1db0c903 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.h +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h @@ -143,6 +143,15 @@ int gettimeofday(struct timeval* tv, void* tz); system clock must not stretch or cut the remaining sleep. */ long long cn1_monotonic_micros(void); +/* --- IANA time zone offsets --- + Answers the total offset (zone plus daylight) in milliseconds for an IANA + zone identifier at an instant, writing the offset to offsetOut and whether + daylight time is in effect to dstOut. Returns non-zero on success, and zero + when the platform cannot answer -- the caller then keeps whatever the C + runtime reported. Lives in cn1_win_compat.c because resolving it needs + , which this header keeps out of translated units. */ +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut); + /* --- environment / time.h POSIX helpers absent from MSVC --- Thin static-inline wrappers over the MSVC equivalents; used by the date / timezone runtime in nativeMethods. */ diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 4c9e166fdfe..26394d14cf4 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2618,84 +2618,13 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx * instant, or reports failure so the caller can fall back. */ #ifdef _WIN32 -/* - * ICU ships with Windows 10 1703 and later as icu.dll, and its calendar speaks - * IANA identifiers and knows the daylight rules for a given instant. It is - * resolved at runtime rather than linked: the clean target builds against a - * minimal SDK layout that need not carry icu.lib or , and a host - * without ICU degrades to the fallback below instead of failing to load. - * - * The two enum values used here are fixed by ICU's stable C API -- - * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields. - */ -#define CN1_UCAL_GREGORIAN 1 -#define CN1_UCAL_ZONE_OFFSET 15 -#define CN1_UCAL_DST_OFFSET 16 - -typedef void* CN1UCalendar; - -static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*); -static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); -static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); -static void (__cdecl *cn1_ucal_close)(CN1UCalendar); -static int cn1IcuResolved; - -static int cn1IcuAvailable(void) { - HMODULE icu; - if (cn1IcuResolved != 0) { - return cn1IcuResolved > 0; - } - cn1IcuResolved = -1; - icu = LoadLibraryA("icu.dll"); - if (icu == NULL) { - return 0; - } - cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_open"); - cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) - GetProcAddress(icu, "ucal_setMillis"); - cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_get"); - cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); - if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { - return 0; - } - cn1IcuResolved = 1; - return 1; -} - -/* Total offset (zone plus daylight) for a zone at an instant, 0 when ICU - * cannot answer -- the caller then keeps the C runtime's reply. */ +/* Total offset (zone plus daylight) for a zone at an instant, 0 when the + * platform cannot answer -- the caller then keeps the C runtime's reply. + * The lookup itself lives in cn1_win_compat.c, the one translation unit that + * may include ; keeping it out of here is what lets the clean + * target compile this file against a minimal SDK layout. */ static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { - WCHAR zone[128]; - CN1UCalendar cal; - int32_t status = 0; - int32_t zoneOffset, dstOffset; - if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) { - return 0; - } - if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone, - (int) (sizeof(zone) / sizeof(zone[0]))) == 0) { - return 0; - } - cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status); - if (status > 0 || cal == 0) { - return 0; - } - cn1_ucal_setMillis(cal, (double) millis, &status); - zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status); - dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status); - cn1_ucal_close(cal); - if (status > 0) { - return 0; - } - if (offsetOut != 0) { - *offsetOut = (int) (zoneOffset + dstOffset); - } - if (dstOut != 0) { - *dstOut = dstOffset != 0; - } - return 1; + return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut); } /* Milliseconds since the epoch for a set of UTC calendar fields. */ From 3eee8ab37a03ffd96551f5c85cfc6d218ffa1afd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:27:16 +0700 Subject: [PATCH 14/48] Address the third review round: OAEP, ECDSA DER and shared error state Windows OAEP could not use the larger RSA keys MGF1 staged seed || counter in a 256-byte buffer, but its second call seeds from the whole masked DB -- 351 bytes for a 3072-bit key and 479 for a 4096-bit one, both of which KeyGenerator.rsa() offers -- so OAEP simply failed on the two higher-security sizes. The seed and counter now go to the hash object in two BCryptHashData calls, so nothing has to hold them together. Windows OAEP unpadding was a padding oracle cn1OaepDecode returned early with a distinct message for a nonzero leading byte, a bad label hash and a missing delimiter, and cryptoResult puts that text in the CryptoException an application may surface. Distinguishing those causes is what the adaptive attacks OAEP exists to stop rely on. Every check on the decrypted block now folds into one accumulator, the delimiter scan runs the full block instead of stopping at the first hit, and one generic "decryption failed" is reported. Only the block geometry, which follows the key rather than the ciphertext, still bails early. ECDSA DER was wrong for P-521 A P-521 signature body is about 138 bytes, so DER requires the long form (0x81 then the length); a single byte there sets the high bit, which Jwt.derToJoseEcdsa and every conforming parser read as a long-form marker. Encoding now emits the long form and parsing accepts it. Verification also took the coordinate width from the named digest, making ES512 64 bytes when P-521 needs 66, so a valid signature reached CNG as a 128-byte pair instead of 132; the width now comes off the key via BCRYPT_KEY_STRENGTH. Verified off-device against OpenSSL as an oracle, with the padding and encoding helpers extracted from the shipped source by the harness rather than copied: OAEP round trips at 2048/3072/4096 bits, our padding accepted by RSA_padding_check_PKCS1_OAEP_mgf1 and OpenSSL's accepted by ours, tampering rejected with the generic message, and P-256/384/521 signatures parsed by d2i_ECDSA_SIG with i2d output read back. The same harness run against the previous code reproduces all four defects. Error buffers were shared across threads cn1LastIoError, cn1WinLastIoError, cn1CryptoError and cn1WinCryptoError were process-wide, so two threads failing at once could overwrite each other and lastIoError/lastCryptoError could report an unrelated call's reason. All four are now per-thread. ICU resolution raced cn1IcuAvailable published "resolved" before loading the DLL, so a second thread asking for a zone at startup would conclude ICU was unavailable and fall through to the CRT, which cannot read IANA identifiers -- that query intermittently answered UTC. Resolution now runs under a lock and the flag is written only once the function pointers are in place. The page validator failed when nothing was skipped Requiring at least one documented-skip cell tied the website build to what the live reports happened to skip, so a round in which every port ran everything -- the best possible outcome -- would have failed it. The assertion is now that the marker and the cell's own label agree, which still catches a renderer that drops one (verified by stripping the class from a built page) without depending on skips existing. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 5 +- Ports/LinuxPort/nativeSources/cn1_linux_io.c | 5 +- .../nativeSources/cn1_windows_crypto.c | 203 +++++++++++++----- .../nativeSources/cn1_windows_io.c | 4 +- scripts/website/validate_port_status.mjs | 15 +- vm/ByteCodeTranslator/src/cn1_win_compat.c | 47 ++-- 6 files changed, 206 insertions(+), 73 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 9025e45e099..424810157e4 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -51,7 +51,10 @@ #define CN1_GCM_TAG_BYTES 16 -static char cn1CryptoError[512]; +/* Per-thread: crypto failures on different threads would otherwise overwrite + * each other and lastCryptoError() could answer with an unrelated call's + * message. */ +static __thread char cn1CryptoError[512]; static void cn1CryptoFail(const char* what) { unsigned long code = ERR_get_error(); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 86c179e5c86..2afadd4168d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -171,7 +171,10 @@ static const char* cn1JStr(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { /* Reason the last open failed. The port reports "could not open X" from Java, * where errno is long gone; without this the only way to tell a missing * directory from a permission problem was another CI round trip. */ -static char cn1LastIoError[512]; +/* Per-thread: two threads failing an open at once would otherwise overwrite + * each other and lastIoError() could report the wrong reason, or a torn + * mixture of both. */ +static __thread char cn1LastIoError[512]; static void cn1RecordIoError(const char* path) { snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno)); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 8a29158113f..8a0995db236 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -54,7 +54,10 @@ #define CN1_GCM_TAG_BYTES 16 -static char cn1WinCryptoError[512]; +/* Per-thread: crypto failures on different threads would otherwise overwrite + * each other and lastCryptoError() could answer with an unrelated call's + * message. */ +static __declspec(thread) char cn1WinCryptoError[512]; static void cn1CryptoFail(const char* what, NTSTATUS status) { _snprintf(cn1WinCryptoError, sizeof(cn1WinCryptoError), "%s (status 0x%08lx)", what, @@ -417,24 +420,57 @@ static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, * signatures are converted in both directions. */ +/* One digest over two buffers in sequence, without joining them first. + * + * MGF1's second call seeds from the whole masked DB -- 351 bytes for a + * 3072-bit key and 479 for a 4096-bit one, both of which KeyGenerator.rsa() + * supports -- so the seed cannot be staged in a buffer sized for a hash. This + * feeds the seed and the counter to the hash object directly instead. */ +static int cn1DigestPair(LPCWSTR algorithm, const unsigned char* first, int firstLength, + const unsigned char* second, int secondLength, + unsigned char* digest, int digestLength) { + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_HASH_HANDLE hash = NULL; + NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, algorithm, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest provider", status); + return 0; + } + status = BCryptCreateHash(alg, &hash, NULL, 0, NULL, 0, 0); + if (status == STATUS_SUCCESS) { + status = BCryptHashData(hash, (PUCHAR) first, (ULONG) firstLength, 0); + } + if (status == STATUS_SUCCESS) { + status = BCryptHashData(hash, (PUCHAR) second, (ULONG) secondLength, 0); + } + if (status == STATUS_SUCCESS) { + status = BCryptFinishHash(hash, digest, (ULONG) digestLength, 0); + } + if (hash != NULL) { + BCryptDestroyHash(hash); + } + BCryptCloseAlgorithmProvider(alg, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest", status); + return 0; + } + return 1; +} + static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedLength, unsigned char* mask, int maskLength) { int digestLength = cn1DigestLength(digestAlgorithm); - unsigned char counted[256]; + unsigned char counter[4]; unsigned char digest[64]; int produced = 0; - unsigned int counter = 0; - if (seedLength + 4 > (int) sizeof(counted)) { - return 0; - } - memcpy(counted, seed, (size_t) seedLength); + unsigned int count = 0; while (produced < maskLength) { int chunk = maskLength - produced; - counted[seedLength] = (unsigned char) ((counter >> 24) & 0xff); - counted[seedLength + 1] = (unsigned char) ((counter >> 16) & 0xff); - counted[seedLength + 2] = (unsigned char) ((counter >> 8) & 0xff); - counted[seedLength + 3] = (unsigned char) (counter & 0xff); - if (!cn1Digest(digestAlgorithm, counted, seedLength + 4, digest, digestLength)) { + counter[0] = (unsigned char) ((count >> 24) & 0xff); + counter[1] = (unsigned char) ((count >> 16) & 0xff); + counter[2] = (unsigned char) ((count >> 8) & 0xff); + counter[3] = (unsigned char) (count & 0xff); + if (!cn1DigestPair(digestAlgorithm, seed, seedLength, counter, 4, digest, digestLength)) { return 0; } if (chunk > digestLength) { @@ -442,7 +478,7 @@ static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedL } memcpy(mask + produced, digest, (size_t) chunk); produced += chunk; - counter++; + count++; } return 1; } @@ -453,9 +489,13 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; unsigned char seed[64]; - unsigned char mask[512]; + unsigned char mask[1024]; int i; - if (dbLength <= 0 || messageLength > dbLength - hashLength - 1 || dbLength > (int) sizeof(mask)) { + if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + cn1CryptoFail("RSA-OAEP block does not fit the key", 0); + return 0; + } + if (messageLength > dbLength - hashLength - 1) { cn1CryptoFail("RSA-OAEP message is too long for the key", 0); return 0; } @@ -488,19 +528,43 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned return 1; } -/* Reverses cn1OaepEncode, writing the recovered message and its length. */ +/* All ones when a == b, zero otherwise, without branching on the values. */ +static unsigned int cn1CtEqMask(unsigned int a, unsigned int b) { + unsigned int diff = a ^ b; + /* 1 when diff is nonzero, 0 when it is zero; minus one turns that into a + * full-width mask without a comparison the compiler can branch on. */ + unsigned int nonZero = (diff | (0u - diff)) >> 31; + return nonZero - 1u; +} + +/* Reverses cn1OaepEncode, writing the recovered message and its length. + * + * Every check on the decrypted block feeds one accumulator and the function + * reports a single generic failure, rather than returning early with a + * distinct message per cause. An application that decrypts attacker-chosen + * ciphertext and surfaces the exception (WindowsImplementation.cryptoResult + * puts this text in it) would otherwise hand back which of the leading byte, + * the label hash or the delimiter was wrong -- and telling those apart is + * enough to mount the adaptive attacks OAEP exists to prevent. Only the + * block geometry, which follows the key and not the ciphertext, is allowed to + * bail early. */ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* block, int blockLength, unsigned char* message, int* messageLength) { int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; - unsigned char mask[512]; + unsigned char mask[1024]; unsigned char labelHash[64]; unsigned char seed[64]; - int i, index; - if (dbLength <= 0 || dbLength > (int) sizeof(mask) || block[0] != 0x00) { - cn1CryptoFail("RSA-OAEP block is malformed", 0); + int i; + unsigned int bad = 0; + unsigned int seenDelimiter = 0; + unsigned int messageStart = 0; + if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } + /* The leading byte must be zero; fold it in rather than returning here. */ + bad |= (unsigned int) block[0]; if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { return 0; } @@ -516,22 +580,29 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) { return 0; } - if (memcmp(labelHash, block + 1 + hashLength, (size_t) hashLength) != 0) { - cn1CryptoFail("RSA-OAEP label hash does not match", 0); - return 0; - } - index = 1 + hashLength + hashLength; - while (index < blockLength && block[index] == 0x00) { - index++; - } - if (index >= blockLength || block[index] != 0x01) { - cn1CryptoFail("RSA-OAEP padding is malformed", 0); + for (i = 0; i < hashLength; i++) { + bad |= (unsigned int) (labelHash[i] ^ block[1 + hashLength + i]); + } + /* Walk the whole padding: zeros until one 0x01, then the message. The loop + * never stops early, so its timing follows the key size alone. */ + for (i = 1 + hashLength + hashLength; i < blockLength; i++) { + unsigned int value = block[i]; + unsigned int isDelimiter = cn1CtEqMask(value, 0x01); + unsigned int isZero = cn1CtEqMask(value, 0x00); + unsigned int firstDelimiter = isDelimiter & ~seenDelimiter; + messageStart |= ((unsigned int) (i + 1)) & firstDelimiter; + /* Ahead of the delimiter nothing but zeros is allowed. */ + bad |= ~seenDelimiter & ~isDelimiter & ~isZero; + seenDelimiter |= isDelimiter; + } + bad |= ~seenDelimiter; /* no delimiter anywhere in the block */ + if (bad != 0) { + cn1CryptoFail("RSA-OAEP decryption failed", 0); return 0; } - index++; - *messageLength = blockLength - index; + *messageLength = blockLength - (int) messageStart; if (*messageLength > 0) { - memcpy(message, block + index, (size_t) *messageLength); + memcpy(message, block + messageStart, (size_t) *messageLength); } return 1; } @@ -554,29 +625,62 @@ static int cn1DerInteger(const unsigned char* value, int length, unsigned char* return written + length - start; } -/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects. */ +/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects. + * + * P-521 coordinates are 66 bytes each, so the sequence body runs to about 138 + * bytes and DER requires the long form (0x81 followed by the length) for + * anything over 127. A single length byte there sets the high bit, which + * Jwt.derToJoseEcdsa and every conforming parser read as a long-form marker, + * and the ES512 signature is rejected. */ static int cn1EcdsaToDer(const unsigned char* raw, int rawLength, unsigned char* der) { int half = rawLength / 2; unsigned char body[160]; int bodyLength = 0; + int written = 0; if (rawLength <= 0 || (rawLength & 1) != 0 || half > 66) { return 0; } bodyLength = cn1DerInteger(raw, half, body); bodyLength += cn1DerInteger(raw + half, half, body + bodyLength); - der[0] = 0x30; - der[1] = (unsigned char) bodyLength; - memcpy(der + 2, body, (size_t) bodyLength); - return bodyLength + 2; + der[written++] = 0x30; + if (bodyLength > 127) { + der[written++] = 0x81; + } + der[written++] = (unsigned char) bodyLength; + memcpy(der + written, body, (size_t) bodyLength); + return written + bodyLength; +} + +/* Coordinate width of an EC key in bytes: 66 for P-521, whose 521 bits do not + * fill a whole byte count that any digest length happens to match. */ +static int cn1EcCoordinateBytes(BCRYPT_KEY_HANDLE key) { + DWORD bits = 0; + ULONG copied = 0; + if (BCryptGetProperty(key, BCRYPT_KEY_STRENGTH, (PUCHAR) &bits, sizeof(bits), &copied, 0) + != STATUS_SUCCESS || bits == 0) { + return 0; + } + return (int) ((bits + 7) / 8); } /* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) { - int index = 2; + int index = 1; int part; if (derLength < 8 || der[0] != 0x30) { return 0; } + /* Accept the long form the P-521 body needs, and only that one extra + * length byte -- a sequence of two integers never runs past 255 bytes. */ + if (der[index] == 0x81) { + index++; + if (index >= derLength) { + return 0; + } + } else if ((der[index] & 0x80) != 0) { + return 0; + } + index++; memset(raw, 0, (size_t) (half * 2)); for (part = 0; part < 2; part++) { int length, start, copy; @@ -585,7 +689,7 @@ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned cha } length = der[index + 1]; index += 2; - if (index + length > derLength) { + if (length <= 0 || index + length > derLength) { return 0; } start = 0; @@ -821,15 +925,18 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str padding.pszAlgId = digestAlgorithm; if (isEc) { /* Signatures arrive as DER; CNG verifies the P1363 pair. The half - * width follows the key size, which for the supported curves is - * the digest the caller named. */ - int half = cn1DigestLength(digestAlgorithm); - if (half == 20) { - half = 32; + * width is the curve's, read off the key -- deriving it from the + * named digest gets P-521 wrong, whose coordinates are 66 bytes + * while SHA-512 is 64, so a valid ES512 signature would be handed + * to CNG as a 128-byte pair instead of the required 132. */ + int half = cn1EcCoordinateBytes(key); + if (half <= 0 || half * 2 > (int) sizeof(raw)) { + usable = 0; + } else { + usable = cn1EcdsaFromDer(signature, signatureLength, raw, half); + toVerify = raw; + toVerifyLength = (ULONG) (half * 2); } - usable = cn1EcdsaFromDer(signature, signatureLength, raw, half); - toVerify = raw; - toVerifyLength = (ULONG) (half * 2); } /* A rejected signature is a normal answer here, not a fault. */ if (usable && BCryptVerifySignature(key, isEc ? NULL : &padding, digest, diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index a3a0528d6cb..96b000d487c 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -85,7 +85,9 @@ static JAVA_OBJECT cn1WinWideToJavaString(CODENAME_ONE_THREAD_STATE, const WCHAR /* Reason the last open failed. The port reports "could not open X" from Java, * where the thread's last-error value is long gone; without this the only way * to tell a missing directory from a sharing violation was another CI run. */ -static DWORD cn1WinLastIoError; +/* Per-thread: two threads failing an open at once would otherwise overwrite + * each other and lastIoError() could report the wrong reason. */ +static __declspec(thread) DWORD cn1WinLastIoError; JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { char buffer[256]; diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index 62418bb169c..41a4cd68877 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -169,9 +169,20 @@ function validate() { // A cell may only read as a pass while carrying a skip when the errata name // that exact test, so a green mark can never outrun its explanation. + // + // Whether any such cell exists at all depends on what the live reports + // skipped this round: a round in which every port ran everything is a good + // outcome, not a page defect, so requiring at least one would fail the + // website build for the best possible reason. What must hold instead is + // that the marker and the cell's own label agree. Stale cells are exempt + // because staleness drops the marker while keeping the label it replaced. const notedCells = primaryCellTags.filter((cell) => /\bhas-documented-skip\b/.test(cell)); - if (notedCells.length === 0) { - fail("no cell reports a documented skip; the errata and the table disagree"); + const labelledSkipCells = primaryCellTags.filter((cell) => + !/\bis-stale\b/.test(attribute(cell, "class")) && + /skipped by the CI environment/i.test(attribute(cell, "title"))); + if (notedCells.length !== labelledSkipCells.length) { + fail(`documented-skip markers and cell labels disagree: ${notedCells.length} marked, ` + + `${labelledSkipCells.length} labelled`); } for (const cell of notedCells) { const skips = attribute(cell, "data-documented-skip").split(/\s+/).filter(Boolean); diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index 20527ae1945..829b337295b 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -315,29 +315,36 @@ static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); static void (__cdecl *cn1_ucal_close)(CN1UCalendar); static int cn1IcuResolved; +/* Resolution runs under a lock, and cn1IcuResolved is written only once the + * function pointers are in place. Publishing "in progress" first, as a plain + * flag test would, lets a second thread asking for a zone at startup see a + * nonzero value, conclude ICU is unavailable and fall through to the CRT -- + * which cannot read IANA identifiers, so that one query intermittently + * answers UTC. */ +static SRWLOCK cn1IcuLock = SRWLOCK_INIT; static int cn1IcuAvailable(void) { - HMODULE icu; - if (cn1IcuResolved != 0) { - return cn1IcuResolved > 0; - } - cn1IcuResolved = -1; - icu = LoadLibraryA("icu.dll"); - if (icu == NULL) { - return 0; - } - cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_open"); - cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) - GetProcAddress(icu, "ucal_setMillis"); - cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_get"); - cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); - if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { - return 0; + int resolved; + AcquireSRWLockExclusive(&cn1IcuLock); + if (cn1IcuResolved == 0) { + HMODULE icu = LoadLibraryA("icu.dll"); + int ok = 0; + if (icu != NULL) { + cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_open"); + cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) + GetProcAddress(icu, "ucal_setMillis"); + cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_get"); + cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); + ok = cn1_ucal_open != 0 && cn1_ucal_setMillis != 0 + && cn1_ucal_get != 0 && cn1_ucal_close != 0; + } + cn1IcuResolved = ok ? 1 : -1; } - cn1IcuResolved = 1; - return 1; + resolved = cn1IcuResolved; + ReleaseSRWLockExclusive(&cn1IcuLock); + return resolved > 0; } int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { From 4244c0c680635a9a3b18a33212511cf16f4b21fb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:10:05 +0700 Subject: [PATCH 15/48] Deliver a health result on the EDT even to a listener that arrives late HealthEdtDeliveryTest.aFacadeActionDeliversOnTheEdt was failing on this PR. The bug is not this branch's -- EdtResult.java is byte-identical to master -- but it fails the branch's CI, and a result that lands on a different thread from one run to the next is a real defect rather than a flaky test. EdtResult promises one outcome delivered on the EDT, and delivers on half of it: it hops completion to the EDT, but AsyncResource runs a callback that is registered against an already-finished resource immediately, on whichever thread registered it. Health.openHealthSettings() completes inside the call, so which thread the callback saw came down to whether the EDT had drained the hop before the caller reached onResult. Same call, either answer. Every callback is now wrapped so it runs on the EDT wherever it is reached from. A callback already on the EDT sees isEdt() and runs inline, so this costs a branch and never an extra queued runnable. A caller that names an EasyThread is asking for delivery there specifically, which is the point of that overload, so those are left alone. The existing test only caught this when it lost the race. The new one waits for isDone() before it listens, making the late case the only case, so it fails every time against the bug rather than occasionally. Test helpers that assumed inline delivery Five test classes each carried a copy of an errorOf helper that registered an except callback and read the error straight back, which the change makes return nothing. They now share one implementation in HealthAwait, which waits for the delivery the same way settled() waits for the outcome. It registers on both sides: plenty of callers ask errorOf about a resource that succeeded and expect null, and exactly one of ready/except ever fires, so waiting on except alone burned the full limit on every successful call -- which is also what made concurrentStartsYieldOneSession miss its own deadline with eight threads each paying it. The waiting flags are atomics because the callback runs on the EDT and the value is read from the test thread. Verified with the whole core-unittests suite rather than a health subset: 4677 tests, no failures, through `verify` so the static analysis gates ran too. The narrower -Dtest='Health*' selection I used first does not match LocalHealthPersistenceTest, LocalHealthStoreTest or WorkoutAndNutritionTest, which is how the first two attempts at this looked green while breaking 11 and then 14 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/impl/health/EdtResult.java | 70 +++++++++++++++++++ .../com/codename1/health/HealthAwait.java | 67 ++++++++++++++++++ .../health/HealthEdtDeliveryTest.java | 50 +++++++++++++ .../codename1/health/HealthFallbackTest.java | 14 +--- .../com/codename1/health/HealthWireTest.java | 12 +--- .../health/LocalHealthPersistenceTest.java | 13 +--- .../health/LocalHealthStoreTest.java | 17 +---- .../health/WorkoutAndNutritionTest.java | 13 +--- 8 files changed, 198 insertions(+), 58 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/health/EdtResult.java b/CodenameOne/src/com/codename1/impl/health/EdtResult.java index 780a1d2c79e..29800dc6370 100644 --- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java +++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java @@ -23,6 +23,9 @@ package com.codename1.impl.health; import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import com.codename1.util.EasyThread; +import com.codename1.util.SuccessCallback; /// The resource every public health operation hands back: one outcome, /// delivered on the EDT. @@ -65,6 +68,73 @@ public void error(Throwable t) { Display.getInstance().callSerially(new Deliver(this, null, t)); } + /// Completing on the EDT is only half of the guarantee. `AsyncResource` + /// runs a callback registered against an already-finished resource + /// immediately, on whichever thread registered it, so the outcome landing + /// on the EDT does not mean the callback does. + /// + /// That is the ordinary case for the operations that resolve before they + /// return -- the facade's `openHealthSettings` and `openProviderSetup` + /// complete inside the call -- where the delivery thread came down to + /// whether the EDT had drained the hop above before the caller got as far + /// as `onResult`. The same call arrived on the EDT or off it from one run + /// to the next. + /// + /// Wrapping every callback closes that half. A callback already reached on + /// the EDT sees `isEdt()` and runs inline, so this costs a branch and + /// never an extra queued runnable. Both arities funnel through the + /// `EasyThread` overloads, so overriding these two covers `ready`, + /// `except` and `onResult` alike. + /// + /// A caller who names an `EasyThread` is asking for delivery there + /// specifically, which is the point of that overload, so those are left + /// alone -- the default is the EDT, not an override of an explicit choice. + @Override + public AsyncResource ready(SuccessCallback callback, EasyThread t) { + return super.ready(t == null ? new OnEdt(callback) : callback, t); + } + + @Override + public AsyncResource except(SuccessCallback callback, EasyThread t) { + return super.except(t == null ? new OnEdt(callback) : callback, t); + } + + /// Named rather than anonymous so the hop carries no synthetic reference + /// to anything enclosing (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class OnEdt implements SuccessCallback { + + private final SuccessCallback delegate; + + OnEdt(SuccessCallback delegate) { + this.delegate = delegate; + } + + @Override + public void onSucess(V value) { + if (Display.getInstance().isEdt()) { + delegate.onSucess(value); + return; + } + Display.getInstance().callSerially(new Invoke(delegate, value)); + } + } + + private static final class Invoke implements Runnable { + + private final SuccessCallback delegate; + private final V value; + + Invoke(SuccessCallback delegate, V value) { + this.delegate = delegate; + this.value = value; + } + + @Override + public void run() { + delegate.onSucess(value); + } + } + /// Named rather than anonymous so the hop carries no synthetic reference /// to anything enclosing (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). private static final class Deliver implements Runnable { diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java index 9eeb5fc32a2..e38775d7eb2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java @@ -24,6 +24,10 @@ import com.codename1.ui.CN; import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -69,6 +73,69 @@ public void run() { return res; } + /** + * Settles `res` and returns the failure it carries, or null if it + * succeeded. + * + *

Five test classes each had their own copy of this, all of them + * registering an {@code except} callback and reading the result straight + * back on the assumption that a callback attached to an already-failed + * resource fires inline on the registering thread. That assumption no + * longer holds: health results are delivered on the EDT whether the + * listener arrives before the outcome or after it, so an off-EDT caller + * gets the failure queued. Waiting for it is the same thing {@link + * #settled} does one step earlier, and having one copy means the next + * change to the delivery rule has one place to land.

+ */ + static Throwable errorOf(AsyncResource res) { + settled(res); + // Atomics rather than a one-element array: the callback runs on the + // EDT and the value is read from the test thread, so an unguarded + // field would be a data race that only misbehaves under CI timing. + final AtomicReference err = new AtomicReference(); + final AtomicBoolean delivered = new AtomicBoolean(); + res.except(new SuccessCallback() { + public void onSucess(Throwable t) { + err.set(t); + delivered.set(true); + } + }); + // Both sides, because plenty of callers ask this of a resource that + // succeeded and expect null back. Only one of the two ever fires, so + // waiting on `except` alone would wait out the whole limit on every + // successful call. + res.ready(new SuccessCallback() { + public void onSucess(T value) { + delivered.set(true); + } + }); + if (!delivered.get()) { + if (CN.isEdt()) { + CN.invokeAndBlock(new Runnable() { + public void run() { + pollDelivered(delivered); + } + }); + } else { + pollDelivered(delivered); + } + } + assertTrue(delivered.get(), + "the failure must be delivered rather than hang"); + return err.get(); + } + + private static void pollDelivered(AtomicBoolean delivered) { + long deadline = System.currentTimeMillis() + LIMIT_MILLIS; + while (!delivered.get() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + } + private static void poll(AsyncResource res) { long deadline = System.currentTimeMillis() + LIMIT_MILLIS; while (!res.isDone() && System.currentTimeMillis() < deadline) { diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java index c51fc2e35d6..4762ad779bd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java @@ -310,6 +310,56 @@ private static String enclosingMethod(String[] lines, int at) { return ""; } + /** + * A listener attached after the outcome has already landed still arrives on + * the EDT. + * + *

{@code AsyncResource} runs a callback registered against a finished + * resource immediately, on whichever thread registered it, so completing on + * the EDT is only half the guarantee. The facade operations resolve inside + * the call, which made {@link #aFacadeActionDeliversOnTheEdt()} a race: it + * passed when the caller reached {@code onResult} before the EDT drained + * the completion, and failed when it did not.

+ * + *

Waiting for {@code isDone()} before listening makes the late case the + * only case, so this fails every time against that bug rather than + * occasionally.

+ */ + @Test + void aListenerAttachedAfterCompletionStillArrivesOnTheEdt() { + final Landing landing = new Landing(); + CN.invokeAndBlock(new Runnable() { + public void run() { + assertFalse(CN.isEdt(), "the operation must start off the EDT"); + AsyncResource settings = + Health.getInstance().openHealthSettings(); + long deadline = System.currentTimeMillis() + 10_000L; + while (!settings.isDone() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + assertTrue(settings.isDone(), "the outcome must have landed first"); + settings.onResult(landing); + deadline = System.currentTimeMillis() + 10_000L; + while (!landing.arrived.get() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + } + }); + assertTrue(landing.arrived.get(), "the callback must arrive"); + assertTrue(landing.onEdt.get(), + "a late listener must still be called on the EDT"); + } + @Test void aFacadeActionDeliversOnTheEdt() { final Landing landing = new Landing(); diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java index bcc8a179353..25191591bc4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java @@ -202,18 +202,8 @@ private static void assertFailedWith(HealthError expected, assertEquals(expected, ((HealthException) err).getError()); } - /** - * An {@code except} callback registered on an already-failed resource - * fires synchronously, so the error can be read out without waiting -- - * the same trick {@code BtTestUtil} uses. - */ + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(AsyncResource r) { - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java index 6d472eb223b..67552f3ba19 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java @@ -599,15 +599,9 @@ void seriesTypesAreDeletableOnAndroidEvenThoughTheyAreNotWritable() { HealthDataType.SLEEP)); } - private static Throwable errorOf( - com.codename1.util.AsyncResource r) { - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ + private static Throwable errorOf(com.codename1.util.AsyncResource r) { + return HealthAwait.errorOf(r); } /** diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java index 06aa715ecb8..9ad70b56f35 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java @@ -532,18 +532,9 @@ HealthDataType.STEPS, new HealthQuantity(2, HealthUnit.COUNT), "and the record this build cannot read must survive"); } + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(AsyncResource r) { - // Settled first. Results are delivered on the EDT on every backend - // now, so an off-EDT caller sees the error queued rather than already - // attached, and reading it without waiting found nothing. - HealthAwait.settled(r); - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } /** A store whose backing storage refuses to take anything. */ diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java index 363a5b94b06..2edebc2ac61 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java @@ -731,22 +731,9 @@ void sourceFilterExcludesOtherApps() { filtered.get(0).getSource().getBundleId()); } - /** - * An {@code except} callback on an already-settled resource fires - * synchronously, so the error can be read without waiting. - */ + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(com.codename1.util.AsyncResource r) { - // Settled first. Results are delivered on the EDT on every backend - // now, so an off-EDT caller sees the error queued rather than already - // attached, and reading it without waiting found nothing. - HealthAwait.settled(r); - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } /** diff --git a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java index 9420dd863cf..185f34b8335 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java @@ -134,18 +134,9 @@ void aTerminalSessionIsReleasedOnTransitionNotOnTheNextGetter() next.discard(); } + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(AsyncResource r) { - // Settled first: workout operations deliver on the EDT like every - // other result, so an off-EDT caller sees the error queued rather - // than already attached. - HealthAwait.settled(r); - final Throwable[] err = new Throwable[1]; - r.except(new SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } private static WorkoutSession startedSession() { From e78fa049d413dd1e228aa0fcb16c0c6debcc882f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:18:50 +0700 Subject: [PATCH 16/48] Address the fourth review round, and the health delivery fix's last caller DER signatures are now accepted only in their canonical form cn1EcdsaFromDer read the two INTEGERs and returned success without checking the SEQUENCE's own length or that the input was fully consumed. Being liberal there is not harmless: CNG verifies the P1363 pair it produces, so a signature with trailing bytes, a falsified outer length or a non-minimal INTEGER would verify on Windows and be rejected by JavaSE and every conforming verifier -- the same signature good on one port and bad on another. The whole input must now be exactly one ECDSA-Sig-Value, with minimal lengths and canonical INTEGERs. The OpenSSL harness grew cases for each: trailing garbage, a falsified outer length and a needless leading zero are refused at P-256 and P-521, while well-formed signatures -- including OpenSSL's own encodings -- still convert. An algorithm may no longer be paired with the wrong key cryptoSign discarded keyAlgorithm on the desktop ports, with a comment claiming it added nothing. The native picks the family off the DER key and takes only the digest from the algorithm name, so SHA256withRSA handed an EC key quietly produced an ECDSA signature -- and the matching verify accepted it, so nothing looked wrong until another port read it. JavaSE rejects the pairing when the Signature is initialised; both ports now do the same, on sign and on verify. The sweep gates a report before claiming its port backfill_port_status.sh wrote the coverage marker while downloading and ran the publication gate afterwards, so a newest run that uploaded an unusable report claimed the port and stopped the older candidates from being consulted -- the sweep would keep serving stale data, or fail its closing freshness assertion, with a good report sitting in the run behind it. The gate now runs before the marker. Timestamps from the future are refused A skewed producer clock poisons the data branch rather than just looking odd: the page reads the report as permanently fresh, and the sweep's lexical "is this newer" comparison then refuses every later correct timestamp, which nothing downstream can undo. An hour of tolerance absorbs ordinary skew. Two tests cover both sides. The javase health tests HealthReadAuthTrapTest has its own copy of the errorOf helper, in a module the core-unittests run does not touch, so the EDT delivery fix broke it and I did not see it until CI did -- the third time this change has found a caller I had not looked for. Fixed the same way, waiting on both sides so a resource that succeeded still answers null promptly. The whole javase suite passes, 207 tests, alongside core-unittests' 4677. Verified: the Windows cross-compile still links (crossCompilesWindowsExeWithXwin against an xwin SDK), the OAEP and ECDSA harness passes including the new strictness cases, and the conformance contract tests pass at 18. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/linux/LinuxImplementation.java | 23 +++++++++- .../nativeSources/cn1_windows_crypto.c | 31 ++++++++++++-- .../impl/windows/WindowsImplementation.java | 23 +++++++++- .../javase/health/HealthReadAuthTrapTest.java | 42 +++++++++++++++++-- .../conformance/backfill_port_status.sh | 17 ++++++-- .../conformance/port_status.py | 17 +++++++- .../conformance/test_port_status.py | 28 +++++++++++++ 7 files changed, 166 insertions(+), 15 deletions(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 07fd63b57e0..f83c6c3935c 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2805,17 +2805,36 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c @Override public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { - // The digest and the key type both follow from the algorithm name and - // the DER key itself, so keyAlgorithm adds nothing here. + checkKeyFamily(algorithm, keyAlgorithm); return cryptoResult(LinuxNative.signData(algorithm, privateKeyPkcs8, data), "sign"); } @Override public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { + checkKeyFamily(algorithm, keyAlgorithm); return LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); } + /// The portable contract pairs an algorithm with a key of its own family, + /// and JavaSE rejects a mismatch when the Signature is initialised. The + /// native here reads the family off the DER key and takes only the digest + /// from the algorithm name, so `SHA256withRSA` handed an EC key would + /// quietly produce an ECDSA signature -- and the matching verify would + /// accept it, so nothing looks wrong until another port reads it. Refuse + /// the pairing rather than silently substituting the algorithm. + private static void checkKeyFamily(String algorithm, String keyAlgorithm) { + if (algorithm == null || keyAlgorithm == null) { + return; + } + boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; + boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + if (wantsEc != keyIsEc) { + throw new RuntimeException(algorithm + " cannot be used with a " + + keyAlgorithm + " key"); + } + } + @Override public byte[][] generateRsaKeyPair(int bits) { byte[] blob = cryptoResult(LinuxNative.generateRsaKeyPair(bits), "RSA key generation"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 8a0995db236..df8a4f62d24 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -664,23 +664,39 @@ static int cn1EcCoordinateBytes(BCRYPT_KEY_HANDLE key) { } /* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */ +/* Rejects anything that is not the one canonical encoding of this signature. + * + * Being liberal here is not harmless: CNG verifies the P1363 pair this + * produces, so a signature carrying trailing bytes, a falsified outer length + * or a non-minimal INTEGER would verify on Windows and be rejected by JavaSE + * and by any conforming verifier -- the same signature accepted on one port + * and refused on another. The whole input must be exactly one + * ECDSA-Sig-Value. */ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) { int index = 1; int part; + int bodyLength; if (derLength < 8 || der[0] != 0x30) { return 0; } /* Accept the long form the P-521 body needs, and only that one extra - * length byte -- a sequence of two integers never runs past 255 bytes. */ + * length byte -- a sequence of two integers never runs past 255 bytes. + * DER also requires the shortest form, so a 0x81 that encodes a length + * under 128 is not canonical. */ if (der[index] == 0x81) { index++; - if (index >= derLength) { + if (index >= derLength || der[index] < 0x80) { return 0; } } else if ((der[index] & 0x80) != 0) { return 0; } + bodyLength = der[index]; index++; + /* The sequence must describe exactly the bytes that follow it. */ + if (index + bodyLength != derLength) { + return 0; + } memset(raw, 0, (size_t) (half * 2)); for (part = 0; part < 2; part++) { int length, start, copy; @@ -692,6 +708,14 @@ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned cha if (length <= 0 || index + length > derLength) { return 0; } + /* Canonical INTEGER: no leading 0x00 unless it is there to keep the + * value positive, and never negative. */ + if (length > 1 && der[index] == 0x00 && (der[index + 1] & 0x80) == 0) { + return 0; + } + if ((der[index] & 0x80) != 0) { + return 0; + } start = 0; while (start < length - 1 && der[index + start] == 0) { start++; @@ -703,7 +727,8 @@ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned cha memcpy(raw + part * half + (half - copy), der + index + start, (size_t) copy); index += length; } - return 1; + /* Nothing may follow the second INTEGER. */ + return index == derLength; } JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index ea7a06919e3..574e91aff89 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2813,17 +2813,36 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c @Override public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { - // The digest and the key type both follow from the algorithm name and - // the DER key itself, so keyAlgorithm adds nothing here. + checkKeyFamily(algorithm, keyAlgorithm); return cryptoResult(WindowsNative.signData(algorithm, privateKeyPkcs8, data), "sign"); } @Override public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { + checkKeyFamily(algorithm, keyAlgorithm); return WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); } + /// The portable contract pairs an algorithm with a key of its own family, + /// and JavaSE rejects a mismatch when the Signature is initialised. The + /// native here reads the family off the DER key and takes only the digest + /// from the algorithm name, so `SHA256withRSA` handed an EC key would + /// quietly produce an ECDSA signature -- and the matching verify would + /// accept it, so nothing looks wrong until another port reads it. Refuse + /// the pairing rather than silently substituting the algorithm. + private static void checkKeyFamily(String algorithm, String keyAlgorithm) { + if (algorithm == null || keyAlgorithm == null) { + return; + } + boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; + boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + if (wantsEc != keyIsEc) { + throw new RuntimeException(algorithm + " cannot be used with a " + + keyAlgorithm + " key"); + } + } + @Override public byte[][] generateRsaKeyPair(int bits) { byte[] blob = cryptoResult(WindowsNative.generateRsaKeyPair(bits), "RSA key generation"); diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java index 303cd57e09f..61cdc8000bd 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java @@ -34,6 +34,9 @@ import com.codename1.health.QuantitySample; import com.codename1.health.SampleQuery; import com.codename1.util.AsyncResource; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import com.codename1.util.SuccessCallback; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -138,15 +141,46 @@ private static AsyncResource settled(AsyncResource r) { return r; } - private static Throwable errorOf(AsyncResource r) { + /// Settles `r` and returns the failure it carries, or null if it + /// succeeded. + /// + /// This used to read the error straight back, because a callback attached + /// to an already-failed resource fired inline on the registering thread. + /// Health results are delivered on the EDT whether the listener arrives + /// before the outcome or after it, so an off-EDT caller gets it queued and + /// has to wait -- the same wait `settled` makes one step earlier. + /// + /// Both sides are registered because most callers ask this of a resource + /// that succeeded and expect null; only one of the two ever fires, so + /// waiting on `except` alone would burn the whole limit on every + /// successful call. + private static Throwable errorOf(AsyncResource r) { settled(r); - final Throwable[] err = new Throwable[1]; + // Atomics because the callback runs on the EDT while the value is read + // from the test thread. + final AtomicReference err = new AtomicReference(); + final AtomicBoolean delivered = new AtomicBoolean(); r.except(new SuccessCallback() { public void onSucess(Throwable t) { - err[0] = t; + err.set(t); + delivered.set(true); } }); - return err[0]; + r.ready(new SuccessCallback() { + public void onSucess(T value) { + delivered.set(true); + } + }); + long deadline = System.currentTimeMillis() + 10_000L; + while (!delivered.get() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + break; + } + } + assertTrue(delivered.get(), "the outcome must be delivered rather than hang"); + return err.get(); } /** The permissive baseline: data is there and comes back. */ diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 660db3f9d70..d714e498e5c 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -98,10 +98,21 @@ while IFS= read -r workflow; do run_id="${candidate}" while IFS= read -r downloaded; do found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" - if [ -n "${found}" ] && [ ! -f "${download_dir}/covered-${found}" ]; then - cp "${downloaded}" "${download_dir}/port-status-${found}.json" - : > "${download_dir}/covered-${found}" + if [ -z "${found}" ] || [ -f "${download_dir}/covered-${found}" ]; then + continue fi + # Gate before marking the port covered, not after. A newest run that + # uploaded an unusable report would otherwise claim the port and stop + # the older candidates from being consulted, so the sweep would keep + # serving stale data -- or fail its closing freshness assertion -- + # while a perfectly good report sat in the run behind it. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept \ + --port "${found}" --report "${downloaded}" >/dev/null 2>&1; then + echo "Ignoring the ${found} report from run ${candidate}: not usable by the website." >&2 + continue + fi + cp "${downloaded}" "${download_dir}/port-status-${found}.json" + : > "${download_dir}/covered-${found}" done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) fi done diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index e8a347613c9..6742d9ab4c6 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -10,7 +10,7 @@ import re import sys from collections import Counter -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path @@ -30,6 +30,12 @@ ACCEPT_CONTRACT_DRIFT = 11 ACCEPT_UNUSABLE = 12 +# Producers and this checker can disagree by a little without anything +# being wrong -- runner clocks drift, and a report is stamped slightly +# before it is published. An hour absorbs that; a skewed clock or a +# mistyped --generated-at lands far outside it. +FUTURE_STAMP_TOLERANCE = timedelta(hours=1) + START_RE = re.compile(r"suite starting test=([A-Za-z0-9_]+)") FINISH_RE = re.compile(r"suite finished test=([A-Za-z0-9_]+)") SKIP_RE = re.compile(r"test=([A-Za-z0-9_]+) status=SKIPPED(?: reason=([^\s]+))?") @@ -622,6 +628,15 @@ def publishable_report_problems( else: if stamp.tzinfo is None: malformed.append(f"generated_at {generated_at!r} has no time zone") + elif stamp - datetime.now(timezone.utc) > FUTURE_STAMP_TOLERANCE: + # A clock skewed far ahead poisons the data branch rather than + # just looking odd: the page reads the report as permanently + # fresh, and the sweep's lexical "is this newer" comparison + # then refuses every later, correct timestamp. Nothing + # downstream can recover from that, so refuse it at the gate. + malformed.append( + f"generated_at {generated_at!r} is in the future" + ) mapped = test_to_feature(manifest) tests = report.get("tests") diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 4db22b7469f..3d0b1418802 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -3,6 +3,7 @@ import json import tempfile import unittest +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -300,6 +301,33 @@ def test_publishable_accepts_a_documented_test_skip(self): self.manifest, "android", report )) + def test_publishable_rejects_a_timestamp_from_the_future(self): + # A skewed producer clock poisons the data branch rather than merely + # looking odd: the page reads the report as permanently fresh, and the + # sweep's "is this newer" comparison then refuses every later correct + # timestamp. Nothing downstream can undo it, so it has to be refused + # here. + ahead = datetime.now(timezone.utc) + timedelta(days=400) + report = self.publishable_report( + "android", generated_at=ahead.strftime("%Y-%m-%dT%H:%M:%SZ") + ) + drift, malformed = port_status.publishable_report_problems( + self.manifest, "android", report + ) + self.assertEqual([], drift) + self.assertTrue(any("future" in problem for problem in malformed), malformed) + + def test_publishable_allows_a_little_clock_skew(self): + # Runner clocks drift and a report is stamped a moment before it is + # published, so being marginally ahead is normal rather than a defect. + skewed = datetime.now(timezone.utc) + timedelta(minutes=5) + report = self.publishable_report( + "android", generated_at=skewed.strftime("%Y-%m-%dT%H:%M:%SZ") + ) + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "android", report + )) + def test_publishable_separates_contract_drift_from_a_broken_report(self): report = self.publishable_report("android") del report["tests"]["CameraApiTest"] From 880bc2824ea657a3dc08876496be45c2c7caedfd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:22:12 +0700 Subject: [PATCH 17/48] Give the Linux browser its JS bridge instead of navigating to codenameone.com BrowserComponentScreenshotTest times out intermittently on the Linux runner -- "timeout waiting for DONE stage=show-completed", twice in a row including the harness's retry, with no failure from the test itself. It passes on most commits, which is what makes it a bug rather than a flake: the test hangs in one of its unbounded waits instead of failing. The wait it hangs in is the execute() callback. BrowserComponent.execute generates JavaScript that returns its value by calling cn1application.shouldNavigate(url), and falls back to `window.location.href = "https://www.codenameone.com/..."` when the page has no such object. The Linux port never defined one, so every execute() callback was a real navigation to the internet: the return value came back only if the runner could reach that host, and the navigation took the page under test away with it. Nothing bounded that wait, so a callback that never arrived stopped the suite rather than failing the test. The native side was already half-way there -- it registers a "cn1" script message handler and pushes its messages as MSG| events, and the file header describes that as the JS->Java bridge -- but nothing injected the script that posts to it and the Java side dropped MSG| on the floor. Both halves are now present: a document-start user script defines cn1application.shouldNavigate to post to the handler (the same bootstrap the iOS port injects), and poll() routes MSG| into fireBrowserNavigationCallbacks, the same sink a navigation callback uses. The portable layer decodes the return-value URL either way. The three WebKitGTK entry points this needs are resolved optionally rather than added to the required set: the bootstrap is an improvement on the navigation fallback, not a requirement for browsing, so a build without them keeps a working BrowserComponent instead of reporting it unsupported. The function pointers are declared with __typeof__ of the real declarations, so the header decides their signatures and a mismatched call is a compile error. That is as far as local verification goes here -- the port does not build on macOS, so the runtime behaviour of this one is on CI. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_browser.c | 32 +++++++++++++++++++ .../impl/linux/LinuxBrowserComponent.java | 9 ++++++ 2 files changed, 41 insertions(+) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c index 3cadd982b42..63d941aa492 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c @@ -56,6 +56,9 @@ static __typeof__(jsc_value_to_string)* p static __typeof__(webkit_web_view_load_html)* p_webkit_web_view_load_html; static __typeof__(webkit_web_view_load_uri)* p_webkit_web_view_load_uri; static __typeof__(webkit_web_view_run_javascript)* p_webkit_web_view_run_javascript; +static __typeof__(webkit_user_script_new)* p_webkit_user_script_new; +static __typeof__(webkit_user_content_manager_add_script)* p_webkit_user_content_manager_add_script; +static __typeof__(webkit_user_script_unref)* p_webkit_user_script_unref; static int cn1_wk_state = 0; /* 0 = untried, 1 = available, -1 = unavailable */ @@ -91,6 +94,14 @@ static int cn1LoadWebkit(void) { CN1_WK_SYM(p_webkit_web_view_load_uri, "webkit_web_view_load_uri"); CN1_WK_SYM(p_webkit_web_view_run_javascript, "webkit_web_view_run_javascript"); #undef CN1_WK_SYM + /* Optional: the JS->Java bootstrap is an improvement on the navigation + * fallback, not a requirement for browsing, so a build missing any of these + * keeps a working BrowserComponent rather than reporting unsupported. */ +#define CN1_WK_OPT(ptr, name) do { *(void**)(&ptr) = dlsym(h, name); } while (0) + CN1_WK_OPT(p_webkit_user_script_new, "webkit_user_script_new"); + CN1_WK_OPT(p_webkit_user_content_manager_add_script, "webkit_user_content_manager_add_script"); + CN1_WK_OPT(p_webkit_user_script_unref, "webkit_user_script_unref"); +#undef CN1_WK_OPT cn1_wk_state = ok ? 1 : -1; if (!ok) { cn1LinuxStubOnce("WebKitGTK present but an expected symbol was missing; BrowserComponent unsupported"); } return ok; @@ -148,6 +159,27 @@ static void cn1BrowserCreateOnMain(void* p) { pthread_mutex_init(&b->lock, 0); p_webkit_user_content_manager_register_script_message_handler(mgr, "cn1"); g_signal_connect(mgr, "script-message-received::cn1", G_CALLBACK(cn1BrowserScriptMessage), b); + /* Give the page the object BrowserComponent.execute's generated JavaScript + * looks for. Without it that code falls through to its last resort, + * `window.location.href = "https://www.codenameone.com/..."`, so every + * execute() callback and every JS->Java message became a real navigation to + * the internet: the return value only came back if the runner could reach + * that host, and the page navigated away from the content under test. This + * is the same bootstrap the iOS port injects, routed to the "cn1" message + * handler registered above. */ + if (p_webkit_user_script_new != 0 && p_webkit_user_content_manager_add_script != 0) { + WebKitUserScript* bootstrap = p_webkit_user_script_new( + "window.cn1application = window.cn1application || {};" + "window.cn1application.shouldNavigate = function(url) {" + " window.webkit.messageHandlers.cn1.postMessage(String(url));" + "};", + WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, 0, 0); + p_webkit_user_content_manager_add_script(mgr, bootstrap); + if (p_webkit_user_script_unref != 0) { + p_webkit_user_script_unref(bootstrap); + } + } b->view = p_webkit_web_view_new_with_user_content_manager(mgr); g_signal_connect(b->view, "load-changed", G_CALLBACK(cn1BrowserLoadChanged), b); cn1LinuxOverlayAdd(b->view, 0, 0, req->w > 0 ? req->w : 1, req->h > 0 ? req->h : 1); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java index 626e3be2f4f..1cc8d3b2cf9 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java @@ -117,6 +117,15 @@ private void poll() { browser.fireWebEvent(BrowserComponent.onLoad, new ActionEvent("")); } else if (ev.startsWith("NAV|")) { browser.fireBrowserNavigationCallbacks(ev.substring(4)); + } else if (ev.startsWith("MSG|")) { + // The JS->Java bridge. The page's cn1application.shouldNavigate + // posts here rather than assigning window.location, so an + // execute() return value comes back through the message handler + // instead of a navigation to codenameone.com -- which needed + // working egress to deliver a callback and took the page under + // test away with it. Same sink as a navigation callback: the + // portable layer decodes the return-value URL either way. + browser.fireBrowserNavigationCallbacks(ev.substring(4)); } } } From 445dfd3cda08adc44dc01fb5de9b79cd70b8946f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:23:30 +0700 Subject: [PATCH 18/48] Give LinuxBrowserComponent a copyright header and an accurate description The copyright gate compares against master and only sees files a branch touches, so editing this one brought it into scope and it turned out never to have carried a header. I pushed the previous commit without noticing, because I ran the gate and the commit in one line separated by `;` rather than `&&` -- the gate failed and the commit went out anyway. Its class description was also the Windows port's, naming WebView2, Direct2D and a .cpp file, none of which exist on this port. Replaced with what the peer actually is: a WebKitGTK WebView captured to PNG for the offscreen screenshot. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/linux/LinuxBrowserComponent.java | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java index 1cc8d3b2cf9..b1f2ea6b855 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.linux; import com.codename1.ui.BrowserComponent; @@ -9,15 +31,17 @@ import com.codename1.ui.geom.Dimension; import com.codename1.ui.util.UITimer; -/// Native Linux BrowserComponent peer backed by a WebView2 instance (the -/// native lifecycle lives in cn1_linux_browser.cpp). The component is rendered -/// from a cached image: the native side CapturePreview's the WebView2 to PNG -/// bytes after each navigation, which `generatePeerImage()` turns into the peer -/// image that `PeerComponent.paint()` draws (so it appears in the offscreen -/// Direct2D screenshot, where the live WebView2 visual would not). The peer polls -/// the native event queue to fire `onLoad` and to route the JS return-value -/// bridge (a cancelled navigation to a `/!cn1return/` URL) into the +/// Native Linux BrowserComponent peer backed by a WebKitGTK WebView (the native +/// lifecycle lives in cn1_linux_browser.c). The component is rendered from a +/// cached image: the native side captures the view to PNG bytes after each +/// navigation, which `generatePeerImage()` turns into the peer image that +/// `PeerComponent.paint()` draws, so it appears in the offscreen screenshot +/// where the live WebKit widget would not. The peer polls the native event +/// queue to fire `onLoad` and to route the JS return-value bridge into the /// BrowserComponent's navigation callbacks. +/// +/// (This description previously named WebView2, Direct2D and a .cpp file, none +/// of which exist here -- it had been copied from the Windows port.) class LinuxBrowserComponent extends PeerComponent { private final long peer; private final BrowserComponent browser; From 29dc949ebb9484617610369dd09a80419bd8aa17 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:20:49 +0700 Subject: [PATCH 19/48] Stop the browser test from accepting a blank frame as a rendered page With the JS bridge working, BrowserComponentScreenshotTest got as far as emitting a screenshot on Linux -- and the picture is empty. The browser area is plain white; none of the fixture's content is in it. Two separate things let that pass as success. containsRenderedBrowserContent looked only for bright pixels, on the reasoning that the fixture is white and cyan text and an uncomposited peer is black. A peer that never composited at all is neither: it leaves the form's white background, which is bright everywhere and satisfies the text test on the first row it scans. The check now requires the fixture's dark #0e1116 backdrop as well, so evidence of the page itself is needed rather than evidence of brightness. The Linux port cannot composite the peer at all. browserCapturePng is a stub that returns null pending the async WebKit snapshot bridge, so generatePeerImage never produces an image and the peer is never in the capture. That is the same position the Mac and JavaSE baselines are already in, and the test already has a branch for it; Linux now takes that branch instead of waiting twelve seconds for pixels that cannot arrive. The DOM assertion through execute() -- which only started working with the bridge fix in the previous commit -- is the real coverage there. This leaves the Linux run needing a committed golden for the surrounding form. Seeding it from this code's own CI output rather than from the previous capture, since the capture path is what changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/BrowserComponentScreenshotTest.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java index 23918b1b5ae..e7dba2a264b 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java @@ -81,6 +81,10 @@ private static boolean isHtml5() { return "HTML5".equals(Display.getInstance().getPlatformName()); } + private static boolean isLinux() { + return "linux".equals(Display.getInstance().getPlatformName()); + } + private void checkReady() { if (!loaded || readyRunnable == null) { return; @@ -106,11 +110,15 @@ public void onSucess(BrowserComponent.JSRef result) { return; } - if (isHtml5() || CN.isDesktop()) { + if (isHtml5() || CN.isDesktop() || isLinux()) { // Desktop screenshots intentionally cannot include the native web // peer (the committed Mac/JavaSE baselines contain its black - // placeholder). The execute() assertion above validates the real - // DOM; use the normal harness capture for the surrounding form. + // placeholder), and the Linux port cannot either: its + // browserCapturePng is a documented stub pending the async WebKit + // snapshot bridge, so generatePeerImage always answers null and the + // peer never reaches the capture. The execute() assertion above + // validates the real DOM; use the normal harness capture for the + // surrounding form. UITimer.timer(2000, false, form, readyRunnable); } else { // DOM readiness and even WebKit's first meaningful paint do not @@ -179,7 +187,9 @@ private boolean containsRenderedBrowserContent(Image screen) { screen.toRGB(visualBand, 0, 0, left, top, bandWidth, bandHeight); int[] rgb = visualBand.getRGB(); int requiredBrightPixels = Math.max(32, bandWidth / 20); + int requiredDarkPixels = Math.max(32, bandWidth / 20); int brightPixels = 0; + int darkPixels = 0; for (int y = 0; y < bandHeight; y++) { int rowOffset = y * bandWidth; for (int x = 0; x < bandWidth; x++) { @@ -191,9 +201,19 @@ private boolean containsRenderedBrowserContent(Image screen) { // background. The black uncomposited peer contains neither. if ((r > 160 && g > 160 && b > 160) || (g > 120 && b > 160 && b > r + 30)) { - if (++brightPixels >= requiredBrightPixels) { - return true; - } + brightPixels++; + } else if (r < 80 && g < 80 && b < 80) { + // The fixture's #0e1116 backdrop. Requiring it as well as + // the text is what stops a blank frame from passing: a peer + // that never composited leaves the form's plain white + // background, which is bright everywhere and would satisfy + // the text test on its own -- which is exactly how the + // Linux port, whose peer capture is still a stub, recorded + // an empty rectangle as a rendered page. + darkPixels++; + } + if (brightPixels >= requiredBrightPixels && darkPixels >= requiredDarkPixels) { + return true; } } } From 9c37773f90a4c3f98f1806dd630ad9676449cd94 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:21:07 +0700 Subject: [PATCH 20/48] Add the Linux golden for the browser test's surrounding form Seeded from CI rather than a local build, and from a run of the current code rather than the earlier capture, since the capture path is what changed. The x64 and arm64 legs produced byte-identical images, which is the evidence that this baseline is stable rather than a snapshot of one run's timing. The picture is the form with an empty browser area, which is the honest baseline for this port: browserCapturePng is a stub pending the async WebKit snapshot bridge, so the peer cannot appear in a capture. The DOM assertion through execute() is what actually covers BrowserComponent on Linux. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/linux/screenshots/BrowserComponent.png | Bin 0 -> 5895 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/linux/screenshots/BrowserComponent.png diff --git a/scripts/linux/screenshots/BrowserComponent.png b/scripts/linux/screenshots/BrowserComponent.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2ed97233837f5acb328e87ef71539fe92316f0 GIT binary patch literal 5895 zcmeHL`BM}2whvOZyA+jXYYN1|) zD68yN8a5$7NeB>%c8{R~LZ(-FZ%{ z$^ih5c3gA$+V5V8l%JfNH+fUJj$L|%0PJ^J=$ZQy@rldzZp7L3?Ak9Q>`q1Av1?gB zO0LJEP-lu;*oQ~0o{Xqk<7mK`=3F`Q>^JB#5@h}>P-X3B%i^2E(a{1e_{-)_?U)e6oU5*8@dT7!B^_<> zP_2N=>JnLSLA*4Qc-8>jP1Kp6mbgV}z~=fw>tRq};N^V%4l;u?D-s&4w0v+>5)!4H z#gtp*`JiCz5Uh}9D$`3Ao@R!7sT4-0`Q#L39J2JRYDDXLA+zm9Z{=2VmVXA$aVrO; z;VaD?WAxfXCaK%$!>S#M42$C*Wl`7fy9*rth?Tom+QbNU=&RP?()f{Id#|6cOnFP+ zLOYVAUDg8dEx++c`t60K^cLH4 zhqVk{|4D`(33d_$BpsT@EVNcy)Hj@gs(Bp_Abn3SMnBrrjS-Oi0%2Q!${81x1Dbqt zVA3Ho`&&bes~bH=GHut-g&1DqC`qJO^0i;Uo(+1fylHhPxyqVP*UzSZ5hp8BEpT+< zcvb0_#m)_HJ8uqmj~J_7rNLatDCP!oS>P;1>s9Lt4$aF^ynsPr45-qlVmiHAl-qz4 z=YY((j2042vD&U4Q%qo-NkHW$ZQi9 zxiD=q*QeHcX*g^m zOmv>E`pL6vYkMnDu#=+cJmH)yWwR_O+)7DwGGd;FalF*pCx-dKaD9u*uvkGu5@;=t3Z)ir*H%*t<68WSvl!g z*N?&C`u_v#kiWCya2`kN$zCpIA&_BR#4Ii$7(hLAe?LEnfYkq%skxA#OJYAUFpejn zdR`@?HFy&|owI%lD2rRLvIKHh>qc7UbO*zB!#nMefN0w9)6u+|Dl@-8RPw6sVE$ca ze}46~!^-N&)YxIkNhu+Bb)e10o5aRD-5Gew^d#2<$vd~MSP@80_L{CdbF#+M4Dy5T z+QgwxCm5COxLGO1iMm)xtS3QY*`?kT{{v=moJU+<=!@G8Ko1i*e2S*q(U-SL9uo1~ zyxr74c7HgMIhl1y2B)-oUJ(Ut@o4O@gmnlnp{LEA+rZFy_sOT#^P0yoQPXOZDVwkz zAJ-al+}sKZE&A@fywmW&)R1QK4YCYvn`wvGw6D9m%h3{staEEKWT~OS#4hs9lj{wh zyygvBn~f1S>vHSHj=gE4TTSr~1ARc%s6TL4Aqr1b<8!huzDRua`WiFcGvat_Pgi|v z)aV<9&*h`DYbq9x&djUMDk!VZU*#Zd>#Os-iXnaO-niB|W>o+qR@fV5 zRgC{pv-<&p?|J5|ebaf{wdpVqgmOJc(Lk#}OzvDV;(ABSs?8cv*?vi2InmFpZ*?-d zmYY(2w=S4gS>cytcjr*yFe<3Iti(awv~^->2-{R~b6~+x>|SFka3ty!iOoyI?I1?J z%G3@2s2F?$b-YsW3<%p`0%1J0xXPlCyRoT}=^D5)s2mG&BaB(`Q2Wil@9}4L~UwE5!+d$$b>G~PwO%8FZAwsDM)Ci83the_wJA!s* zzY#lT^Jy0R%)OLE*BX>r6Km<@pPt-C+$HMbX#PwFM+_`-qNaA?`B0>XQ|v#Py+|~@4Y3RJ3y;m(FsR7nH@~F^GBOkOO9{7m zl(MTwP8F2kdCfJD= zP-7bZI+@Z+@`VspBWZ4$7lq@pdl6XF`0tNO^?^+Z+rM9XU~L@|vr`bv;3XMOL96+3 zZ(cto6T+F%)>8#OZqjMttRN^5mdK*$<43uz@D;Q~nn{(OcC(@-DZe0)h+KsQ;obOj zq>Arz&_G7F;iP-hM4~O_=xcpcSzkHBZw1SVh9=YyYBx`P7c^trw=YxwoK6Jgvuaommkh;&>U=7a9$?}aIVRSK#lRBzTW6^)54-#d1-_A&g@DjEvTGs=)3_Dwn6Y7q&v?^L|s$LWd7^~Tl zKplQ(p?tS_k%MzNG-YBSmZ0rPw+MZ6@W|K7+rXoD!=-rbxX3jn)px-UT$i|&cSOv2 zNf-;4h1*hB9~0&NR`C_Xx`8|+mQKzDdPA0{ixn|ZX48s|an@<*c$f$?Vp)lsfAD~! zwnvTI*ZqPb{K0*=)KttmYb>-h_e)% zqZ3y~EssIA*5XW~O%EJ$np*){=$pJtaB@1-IkmhAnIdda@~~Pmq-4EW4IQlPNZs(_SJSN9&&32HubZTd9T9{z^|8_INZolLYi1;@Ucr-2fZI-* ztTAAOaDEo0Z69DHUh#iV?(a3;|F_Xgaf+{KYuMtzt~&S*Wz%zHR|Q=BUn=1Jef>K< zvFGXibk|;;_UiPW5`7OUdr;Yf%KI*~_Smw=mOZxo|7>Y&9|liOVH9TpfOcTuyEU*5 alzk2n%@U8*?rydL*RFcG5Wl(c^M3&r87f); literal 0 HcmV?d00001 From d4da95c410cb88a00573ffdda4b1e8df5d380dfb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:07:40 +0700 Subject: [PATCH 21/48] Add the arm64 golden for the browser test The Linux port keeps separate reference sets per architecture (screenshots and screenshots-arm); I seeded only the x64 one, so x64 went green and arm64 failed the same gate for the same reason. Seeded from this run's own arm64 artifact, which is byte-identical to the x64 capture. Co-Authored-By: Claude Opus 5 (1M context) --- .../linux/screenshots-arm/BrowserComponent.png | Bin 0 -> 5895 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/linux/screenshots-arm/BrowserComponent.png diff --git a/scripts/linux/screenshots-arm/BrowserComponent.png b/scripts/linux/screenshots-arm/BrowserComponent.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2ed97233837f5acb328e87ef71539fe92316f0 GIT binary patch literal 5895 zcmeHL`BM}2whvOZyA+jXYYN1|) zD68yN8a5$7NeB>%c8{R~LZ(-FZ%{ z$^ih5c3gA$+V5V8l%JfNH+fUJj$L|%0PJ^J=$ZQy@rldzZp7L3?Ak9Q>`q1Av1?gB zO0LJEP-lu;*oQ~0o{Xqk<7mK`=3F`Q>^JB#5@h}>P-X3B%i^2E(a{1e_{-)_?U)e6oU5*8@dT7!B^_<> zP_2N=>JnLSLA*4Qc-8>jP1Kp6mbgV}z~=fw>tRq};N^V%4l;u?D-s&4w0v+>5)!4H z#gtp*`JiCz5Uh}9D$`3Ao@R!7sT4-0`Q#L39J2JRYDDXLA+zm9Z{=2VmVXA$aVrO; z;VaD?WAxfXCaK%$!>S#M42$C*Wl`7fy9*rth?Tom+QbNU=&RP?()f{Id#|6cOnFP+ zLOYVAUDg8dEx++c`t60K^cLH4 zhqVk{|4D`(33d_$BpsT@EVNcy)Hj@gs(Bp_Abn3SMnBrrjS-Oi0%2Q!${81x1Dbqt zVA3Ho`&&bes~bH=GHut-g&1DqC`qJO^0i;Uo(+1fylHhPxyqVP*UzSZ5hp8BEpT+< zcvb0_#m)_HJ8uqmj~J_7rNLatDCP!oS>P;1>s9Lt4$aF^ynsPr45-qlVmiHAl-qz4 z=YY((j2042vD&U4Q%qo-NkHW$ZQi9 zxiD=q*QeHcX*g^m zOmv>E`pL6vYkMnDu#=+cJmH)yWwR_O+)7DwGGd;FalF*pCx-dKaD9u*uvkGu5@;=t3Z)ir*H%*t<68WSvl!g z*N?&C`u_v#kiWCya2`kN$zCpIA&_BR#4Ii$7(hLAe?LEnfYkq%skxA#OJYAUFpejn zdR`@?HFy&|owI%lD2rRLvIKHh>qc7UbO*zB!#nMefN0w9)6u+|Dl@-8RPw6sVE$ca ze}46~!^-N&)YxIkNhu+Bb)e10o5aRD-5Gew^d#2<$vd~MSP@80_L{CdbF#+M4Dy5T z+QgwxCm5COxLGO1iMm)xtS3QY*`?kT{{v=moJU+<=!@G8Ko1i*e2S*q(U-SL9uo1~ zyxr74c7HgMIhl1y2B)-oUJ(Ut@o4O@gmnlnp{LEA+rZFy_sOT#^P0yoQPXOZDVwkz zAJ-al+}sKZE&A@fywmW&)R1QK4YCYvn`wvGw6D9m%h3{staEEKWT~OS#4hs9lj{wh zyygvBn~f1S>vHSHj=gE4TTSr~1ARc%s6TL4Aqr1b<8!huzDRua`WiFcGvat_Pgi|v z)aV<9&*h`DYbq9x&djUMDk!VZU*#Zd>#Os-iXnaO-niB|W>o+qR@fV5 zRgC{pv-<&p?|J5|ebaf{wdpVqgmOJc(Lk#}OzvDV;(ABSs?8cv*?vi2InmFpZ*?-d zmYY(2w=S4gS>cytcjr*yFe<3Iti(awv~^->2-{R~b6~+x>|SFka3ty!iOoyI?I1?J z%G3@2s2F?$b-YsW3<%p`0%1J0xXPlCyRoT}=^D5)s2mG&BaB(`Q2Wil@9}4L~UwE5!+d$$b>G~PwO%8FZAwsDM)Ci83the_wJA!s* zzY#lT^Jy0R%)OLE*BX>r6Km<@pPt-C+$HMbX#PwFM+_`-qNaA?`B0>XQ|v#Py+|~@4Y3RJ3y;m(FsR7nH@~F^GBOkOO9{7m zl(MTwP8F2kdCfJD= zP-7bZI+@Z+@`VspBWZ4$7lq@pdl6XF`0tNO^?^+Z+rM9XU~L@|vr`bv;3XMOL96+3 zZ(cto6T+F%)>8#OZqjMttRN^5mdK*$<43uz@D;Q~nn{(OcC(@-DZe0)h+KsQ;obOj zq>Arz&_G7F;iP-hM4~O_=xcpcSzkHBZw1SVh9=YyYBx`P7c^trw=YxwoK6Jgvuaommkh;&>U=7a9$?}aIVRSK#lRBzTW6^)54-#d1-_A&g@DjEvTGs=)3_Dwn6Y7q&v?^L|s$LWd7^~Tl zKplQ(p?tS_k%MzNG-YBSmZ0rPw+MZ6@W|K7+rXoD!=-rbxX3jn)px-UT$i|&cSOv2 zNf-;4h1*hB9~0&NR`C_Xx`8|+mQKzDdPA0{ixn|ZX48s|an@<*c$f$?Vp)lsfAD~! zwnvTI*ZqPb{K0*=)KttmYb>-h_e)% zqZ3y~EssIA*5XW~O%EJ$np*){=$pJtaB@1-IkmhAnIdda@~~Pmq-4EW4IQlPNZs(_SJSN9&&32HubZTd9T9{z^|8_INZolLYi1;@Ucr-2fZI-* ztTAAOaDEo0Z69DHUh#iV?(a3;|F_Xgaf+{KYuMtzt~&S*Wz%zHR|Q=BUn=1Jef>K< zvFGXibk|;;_UiPW5`7OUdr;Yf%KI*~_Smw=mOZxo|7>Y&9|liOVH9TpfOcTuyEU*5 alzk2n%@U8*?rydL*RFcG5Wl(c^M3&r87f); literal 0 HcmV?d00001 From fc3907660423a8558b402ef4512e6fc70679fb8e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:15:36 +0700 Subject: [PATCH 22/48] Refuse crypto names outside the advertised set, and read the raw offset now Four review findings, three of them the same defect: quietly doing something other than what the caller asked for. Cipher transformations The AES dispatch tested for "/GCM/" and "/ECB/" and treated everything else as CBC, so AES/CTR/NoPadding was executed as CBC. RSA treated every non-OAEP string as PKCS#1, and any unrecognized OAEP digest as SHA-256. JavaSE hands these names to JCE, which supports a name as written or refuses it, so the same request encrypted differently depending on the port and nobody was told. Both ports now match the four AES and two RSA constants exactly and refuse anything else. Signature algorithms cn1SignatureDigest / cn1DigestAlgorithm fell through to SHA-256, so a null, misspelled, differently cased or unsupported name -- MD5withRSA -- came back as a valid signature over a digest the caller never named, which no other port would agree with. Both now match Signature's six advertised algorithms and fail otherwise. Making the Windows one answer NULL meant cn1DigestLength(NULL) would call wcscmp on it, and it is evaluated in the callers' declarations, before their checks run; the function and both entry points are guarded. Windows raw time zone offset It sampled 1 January and 1 July of the current year and preferred July when neither was in daylight saving. That is wrong whenever the base offset changes mid-year: in February 2024 Asia/Almaty was still UTC+6, but July's reading is the UTC+5 rule that had not taken effect, and a change landing after July stayed invisible for the rest of the year. The fix removes the sampling rather than adding to it. ICU keeps the base offset and the daylight adjustment in separate calendar fields, so UCAL_ZONE_OFFSET read at the current instant is the raw offset directly. Sweep candidate window A fixed newest-five slice could hide a usable report behind five runs that each omitted a different matrix leg -- the Linux producer especially, whose reports are not reliably published by workflow_run. Candidates are now every run inside the contract's own staleness horizon; the loop already stops as soon as each owned port is covered, so this costs nothing when the newest run is complete. Verified: the OpenSSL harness still passes all its OAEP and ECDSA checks including the DER strictness cases, the conformance contract tests pass at 18, both port sources compile, and crossCompilesWindowsExeWithXwin still links -- which matters here because this touched nativeMethods.m and the compat header again. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 74 ++++++++++++++++--- .../nativeSources/cn1_windows_crypto.c | 68 +++++++++++++++-- .../conformance/backfill_port_status.sh | 23 +++++- vm/ByteCodeTranslator/src/cn1_win_compat.c | 8 +- vm/ByteCodeTranslator/src/cn1_win_compat.h | 9 ++- vm/ByteCodeTranslator/src/nativeMethods.m | 53 +++++-------- 6 files changed, 174 insertions(+), 61 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 424810157e4..8287560eadf 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -101,6 +101,43 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRA return JAVA_TRUE; } +/* ------------------------------------------- advertised names, and only those + * + * JavaSE hands these strings to JCE, which either supports a name as written or + * refuses it. Matching loosely here -- picking CBC because a string contains + * neither "/GCM/" nor "/ECB/", or SHA-256 because it names no digest we know -- + * means the same request encrypts or signs differently depending on which port + * runs it, and the caller is never told. A name outside the advertised set is + * refused instead. + */ + +static int cn1IsAesTransformation(const char* transformation) { + return strcmp(transformation, "AES/GCM/NoPadding") == 0 + || strcmp(transformation, "AES/CBC/PKCS5Padding") == 0 + || strcmp(transformation, "AES/CBC/NoPadding") == 0 + || strcmp(transformation, "AES/ECB/PKCS5Padding") == 0; +} + +static int cn1IsRsaTransformation(const char* transformation) { + return strcmp(transformation, "RSA/ECB/OAEPWithSHA-256AndMGF1Padding") == 0 + || strcmp(transformation, "RSA/ECB/PKCS1Padding") == 0; +} + +/* The digest half of Signature's six advertised algorithms; 0 for anything + * else, which the callers turn into a failure. */ +static const EVP_MD* cn1SignatureDigestOrNull(const char* algorithm) { + if (strcmp(algorithm, "SHA256withRSA") == 0 || strcmp(algorithm, "SHA256withECDSA") == 0) { + return EVP_sha256(); + } + if (strcmp(algorithm, "SHA384withRSA") == 0 || strcmp(algorithm, "SHA384withECDSA") == 0) { + return EVP_sha384(); + } + if (strcmp(algorithm, "SHA512withRSA") == 0 || strcmp(algorithm, "SHA512withECDSA") == 0) { + return EVP_sha512(); + } + return 0; +} + /* ------------------------------------------------------------ AES */ static const EVP_CIPHER* cn1AesCipher(const char* transformation, int keyLength) { @@ -127,7 +164,12 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo const unsigned char* iv = cn1Bytes(ivArray, &ivLength); const unsigned char* aad = cn1Bytes(aadArray, &aadLength); const unsigned char* data = cn1Bytes(dataArray, &dataLength); - int gcm = strstr(mode, "/GCM/") != 0; + int gcm; + if (!cn1IsAesTransformation(mode)) { + cn1CryptoFail("unsupported cipher transformation"); + return JAVA_NULL; + } + gcm = strstr(mode, "/GCM/") != 0; int ecb = strstr(mode, "/ECB/") != 0; int padded = strstr(mode, "NoPadding") == 0; const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength); @@ -256,8 +298,12 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { } static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { + if (!cn1IsRsaTransformation(transformation)) { + cn1CryptoFail("unsupported cipher transformation"); + return 0; + } if (strstr(transformation, "OAEP") != 0) { - const EVP_MD* md = strstr(transformation, "SHA-1") != 0 ? EVP_sha1() : EVP_sha256(); + const EVP_MD* md = EVP_sha256(); // The mask function stays on SHA-1 even when the OAEP digest is // SHA-256. That is what the JCE providers behind the JavaSE and // Android ports do for this transformation name, and ciphertext has to @@ -327,16 +373,7 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boo /* ------------------------------------------------------------ signatures */ static const EVP_MD* cn1SignatureDigest(const char* algorithm) { - if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { - return EVP_sha512(); - } - if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { - return EVP_sha384(); - } - if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { - return EVP_sha1(); - } - return EVP_sha256(); + return cn1SignatureDigestOrNull(algorithm); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( @@ -354,6 +391,14 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byt if (key == 0) { return JAVA_NULL; } + if (cn1SignatureDigest(name) == 0) { + /* Not one of Signature's advertised algorithms. Passing a null digest + * on would let OpenSSL choose one, which is how an unsupported name + * used to come back as a valid signature over a different digest. */ + cn1CryptoFail("unsupported signature algorithm"); + EVP_PKEY_free(key); + return JAVA_NULL; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); @@ -398,6 +443,11 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_ if (key == 0) { return JAVA_FALSE; } + if (cn1SignatureDigest(name) == 0) { + cn1CryptoFail("unsupported signature algorithm"); + EVP_PKEY_free(key); + return JAVA_FALSE; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index df8a4f62d24..5090e98c897 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -118,6 +118,28 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1 return JAVA_TRUE; } +/* ------------------------------------------- advertised names, and only those + * + * JavaSE hands these strings to JCE, which either supports a name as written or + * refuses it. Matching loosely here -- picking CBC because a string contains + * neither "/GCM/" nor "/ECB/", or SHA-256 because it names no digest we know -- + * means the same request encrypts or signs differently depending on which port + * runs it, and the caller is never told. A name outside the advertised set is + * refused instead. + */ + +static int cn1IsAesTransformation(const char* transformation) { + return strcmp(transformation, "AES/GCM/NoPadding") == 0 + || strcmp(transformation, "AES/CBC/PKCS5Padding") == 0 + || strcmp(transformation, "AES/CBC/NoPadding") == 0 + || strcmp(transformation, "AES/ECB/PKCS5Padding") == 0; +} + +static int cn1IsRsaTransformation(const char* transformation) { + return strcmp(transformation, "RSA/ECB/OAEPWithSHA-256AndMGF1Padding") == 0 + || strcmp(transformation, "RSA/ECB/PKCS1Padding") == 0; +} + /* ------------------------------------------------------------ AES */ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( @@ -129,7 +151,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String unsigned char* iv = cn1Bytes(ivArray, &ivLength); unsigned char* aad = cn1Bytes(aadArray, &aadLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - int gcm = strstr(mode, "/GCM/") != 0; + int gcm; int ecb = strstr(mode, "/ECB/") != 0; int padded = strstr(mode, "NoPadding") == 0; int bodyLength = dataLength; @@ -143,6 +165,11 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth; unsigned char tag[CN1_GCM_TAG_BYTES]; + if (!cn1IsAesTransformation(mode)) { + cn1CryptoFail("unsupported cipher transformation", 0); + return JAVA_NULL; + } + gcm = strstr(mode, "/GCM/") != 0; /* A missing GCM nonce would otherwise repeat across messages under one * key, which destroys the mode, and a short CBC IV is read as a whole * block. */ @@ -358,20 +385,29 @@ static int cn1PublicKeyIsEc(const unsigned char* der, int length) { return isEc; } +/* The digest half of Signature's six advertised algorithms; NULL for anything + * else, which the callers turn into a failure rather than signing with a digest + * nobody asked for. */ static LPCWSTR cn1DigestAlgorithm(const char* algorithm) { - if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { - return BCRYPT_SHA512_ALGORITHM; + if (strcmp(algorithm, "SHA256withRSA") == 0 || strcmp(algorithm, "SHA256withECDSA") == 0) { + return BCRYPT_SHA256_ALGORITHM; } - if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { + if (strcmp(algorithm, "SHA384withRSA") == 0 || strcmp(algorithm, "SHA384withECDSA") == 0) { return BCRYPT_SHA384_ALGORITHM; } - if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { - return BCRYPT_SHA1_ALGORITHM; + if (strcmp(algorithm, "SHA512withRSA") == 0 || strcmp(algorithm, "SHA512withECDSA") == 0) { + return BCRYPT_SHA512_ALGORITHM; } - return BCRYPT_SHA256_ALGORITHM; + return NULL; } static int cn1DigestLength(LPCWSTR algorithm) { + if (algorithm == NULL) { + /* cn1DigestAlgorithm answers NULL for a name outside the advertised + * set; the callers check for that, but this is evaluated in their + * declarations, before the check runs. */ + return 0; + } if (wcscmp(algorithm, BCRYPT_SHA512_ALGORITHM) == 0) { return 64; } @@ -741,7 +777,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); int oaepMode = strstr(mode, "OAEP") != 0; - LPCWSTR labelDigest = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; + LPCWSTR labelDigest = BCRYPT_SHA256_ALGORITHM; unsigned char* out = 0; unsigned char* block = 0; ULONG outLength = 0, produced = 0; @@ -749,6 +785,10 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String NTSTATUS status; JAVA_OBJECT result = JAVA_NULL; + if (!cn1IsRsaTransformation(mode)) { + cn1CryptoFail("unsupported cipher transformation", 0); + return JAVA_NULL; + } if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } @@ -878,6 +918,13 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String if (key == 0) { return JAVA_NULL; } + if (digestAlgorithm == NULL) { + /* Not one of Signature's advertised algorithms. Falling back to SHA-256 + * would return a valid signature over a digest the caller never asked + * for, which no other port would agree with. */ + cn1CryptoFail("unsupported signature algorithm", 0); + goto done; + } if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { goto done; } @@ -942,6 +989,11 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str if (key == NULL) { return JAVA_FALSE; } + if (digestAlgorithm == NULL) { + cn1CryptoFail("unsupported signature algorithm", 0); + BCryptDestroyKey(key); + return JAVA_FALSE; + } if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { unsigned char raw[132]; const unsigned char* toVerify = signature; diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index d714e498e5c..374cb72a3b0 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -61,17 +61,32 @@ trap cleanup EXIT published=0 skipped=0 +# The contract's own freshness window bounds how far back a candidate run is +# worth considering: a report older than this is stale by definition, so there +# is nothing to be gained by looking past it. +sweep_stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")" + # One producing workflow can own several ports (the iOS suite emits four), so # sweep per workflow and let the report itself name the port it belongs to. while IFS= read -r workflow; do # Newest first, and a failed run counts: a suite that fails still uploads the # normalized report, and a report that records real failures is the result # the table is supposed to show. - candidates="$(gh run list --workflow "${workflow}" --branch master --limit 40 \ + # Every run still inside the staleness horizon is a candidate, rather than a + # fixed newest-five slice. A workflow whose matrix legs fail independently -- + # the Linux producer especially, whose reports are not reliably published by + # workflow_run -- can accumulate several runs that each omit a different leg, + # and a five-run cap then hides a perfectly good report just behind them. The + # loop below stops as soon as every port the workflow owns is covered, so the + # wider net costs nothing when the newest run is complete. + horizon="$(date -u -d "${sweep_stale_days} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)" + candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ - --jq '[.[] | select((.event == "push" or .event == "schedule") and - (.conclusion == "success" or .conclusion == "failure"))] - | sort_by(.updatedAt) | reverse | .[0:5] | .[].databaseId')" + --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule") and + (.conclusion == "success" or .conclusion == "failure") and + (.updatedAt >= $horizon))] + | sort_by(.updatedAt) | reverse | .[].databaseId')" if [ -z "${candidates}" ]; then echo "No completed master run for ${workflow}; nothing to publish." >&2 continue diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index 829b337295b..eefef097452 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -347,7 +347,8 @@ static int cn1IcuAvailable(void) { return resolved > 0; } -int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, + int* dstOut, int* rawOut) { WCHAR zone[128]; CN1UCalendar cal; int32_t status = 0; @@ -376,6 +377,11 @@ int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offset if (dstOut != 0) { *dstOut = dstOffset != 0; } + if (rawOut != 0) { + /* UCAL_ZONE_OFFSET is the standard-time offset on its own; the daylight + * adjustment is the separate UCAL_DST_OFFSET field. */ + *rawOut = (int) zoneOffset; + } return 1; } diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h index 1ff1db0c903..8f77f92da75 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.h +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h @@ -148,9 +148,14 @@ long long cn1_monotonic_micros(void); zone identifier at an instant, writing the offset to offsetOut and whether daylight time is in effect to dstOut. Returns non-zero on success, and zero when the platform cannot answer -- the caller then keeps whatever the C - runtime reported. Lives in cn1_win_compat.c because resolving it needs + runtime reported. rawOut, when non-null, receives the zone's standard-time + offset at that instant with any daylight adjustment excluded -- ICU tracks + the two separately, so the base offset in force now needs no guessing from + seasonal samples. Any output pointer may be null. + Lives in cn1_win_compat.c because resolving it needs , which this header keeps out of translated units. */ -int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut); +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, + int* dstOut, int* rawOut); /* --- environment / time.h POSIX helpers absent from MSVC --- Thin static-inline wrappers over the MSVC equivalents; used by the date / diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 26394d14cf4..c2d14639aa1 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2623,8 +2623,9 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx * The lookup itself lives in cn1_win_compat.c, the one translation unit that * may include ; keeping it out of here is what lets the clean * target compile this file against a minimal SDK layout. */ -static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { - return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut); +static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, + int* dstOut, int* rawOut) { + return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut, rawOut); } /* Milliseconds since the epoch for a set of UTC calendar fields. */ @@ -2752,7 +2753,7 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int { int offset = 0; if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), - &offset, 0)) { + &offset, 0, 0)) { return offset; } } @@ -2771,36 +2772,20 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA cn1_timezone_raw_ctx ctx; #ifdef _WIN32 { - /* The raw offset is the current standard-time one. Sample both - * solstices of the current year rather than a fixed past year: a zone - * whose base offset changes (Asia/Almaty moved from UTC+6 to UTC+5 - * during 2024, with neither sample flagged as daylight saving) would - * otherwise report its retired offset forever. When neither sample is - * in daylight saving they can still differ, so prefer the later one -- - * that is the rule in force now. */ - int januaryOffset = 0, januaryDst = 0, julyOffset = 0, julyDst = 0; - time_t nowSeconds = time(NULL); - struct tm nowUtc; - int currentYear = 2024; -#ifdef _WIN32 - if (gmtime_s(&nowUtc, &nowSeconds) == 0) { - currentYear = nowUtc.tm_year + 1900; - } -#endif - if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 1, 1, 43200000), - &januaryOffset, &januaryDst) && - cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 7, 1, 43200000), - &julyOffset, &julyDst)) { - if (!januaryDst && !julyDst) { - return julyOffset; - } - if (!julyDst) { - return julyOffset; - } - if (!januaryDst) { - return januaryOffset; - } - return januaryOffset < julyOffset ? januaryOffset : julyOffset; + /* The raw offset is the standard-time one in force right now. ICU keeps + * the base offset and the daylight adjustment in separate calendar + * fields, so asking at the current instant answers it directly. + * + * This used to sample both solstices of the current year and prefer + * July's when neither was in daylight saving, which is wrong whenever + * the base offset changes mid-year: with the clock in February 2024, + * Asia/Almaty was still UTC+6 but July's reading is the UTC+5 rule that + * had not taken effect yet, and a change landing after July stayed + * invisible for the rest of the year. */ + int rawOffset = 0; + long long nowMillis = (long long) time(NULL) * 1000LL; + if (cn1WinZoneOffsetMillis(buffer, nowMillis, 0, 0, &rawOffset)) { + return rawOffset; } } #endif @@ -2824,7 +2809,7 @@ JAVA_BOOLEAN java_util_TimeZone_isTimezoneDST___java_lang_String_long_R_boolean( #ifdef _WIN32 { int dst = 0; - if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst)) { + if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst, 0)) { return dst ? JAVA_TRUE : JAVA_FALSE; } } From eeb59d114070164ae90fe1f233c1dd2a4fab3bd1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:25:46 +0700 Subject: [PATCH 23/48] Make a documented skip prove itself, and compare timestamps as instants The skip erratum was the important one: a green cell could outrun its explanation. The table treated any skip of a named test as documented, matching on the test name alone. VideoIORoundTripTest wrapped writer creation and frame writing in one catch and reported everything as "encode-unavailable-on-", so an encoder that regressed produced the same skip an encoder-less runner does -- and the video round trip rendered green, explained, and wrong. The test now separates the two. Failing to obtain a writer is the documented "this runner exposes no encoder" case and still skips. Failing after one was obtained means the encoder is present and broke, which is a failure. The errata now declare which reason codes they cover, and a cell reads as documented only when every reason the run reported matches one of them. Verified by injecting a regression-style reason into a report: the cell drops from a documented green to partial, while the twelve genuine documented cells are unchanged. The sweep's own timestamp handling disagreed with its gate The gate deliberately tolerates an hour of clock skew, and then the closing freshness assertion read any negative age as an unreadable timestamp -- so the nightly job could fail over a report the same run had just published, until wall time caught up. The assertion now allows the same margin, and keeps failing on a genuinely unparseable value, which is now reported distinctly rather than sharing the -1 sentinel. The "is this newer" test was lexical, which is not chronological for any timestamp the gate accepts but that is not normalized to Z: 2026-08-01T01:00:00+02:00 sorts after 2026-08-01T00:00:00Z while being an hour older. Both are parsed and compared as instants now; the example above is one of the cases checked. A report may only claim a port its workflow produces The port is read out of the artifact, so a misconfigured matrix stamping another port's id on its report would have been published straight over that port's entry -- Linux evidence replacing Android's genuine result, with both the gate and the freshness check satisfied. The id must now be in the set the workflow is declared to own. Co-Authored-By: Claude Opus 5 (1M context) --- docs/website/data/port_status_supplement.json | 783 ++++++++++++++++-- .../partials/port-status-feature-status.html | 30 +- .../tests/VideoIORoundTripTest.java | 17 +- .../conformance/backfill_port_status.sh | 60 +- 4 files changed, 810 insertions(+), 80 deletions(-) diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index 2f2ad1cd4d7..bc4b6f719aa 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -4,13 +4,20 @@ "test": "CameraApiTest", "reason": "The unattended runner cannot respond to operating-system camera permission prompts or provide stable physical-camera input. The test therefore avoids opening a real session on targets where doing so could hang CI or produce nondeterministic frames.", "platform_support": "A skipped camera test does not mean the Codename One port is unsupported. Every listed target remains a supported port; camera availability is a separate runtime capability and depends on the device, permissions, and camera backend.", - "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table." + "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table.", + "reason_codes": [ + "needs-runtime-permission-on-" + ] }, { "test": "VideoIORoundTripTest", "reason": "This assertion requires a working video encoder as well as a decoder. Apple simulator and constrained-device runners do not expose a stable encoder to the headless job, so the encode/decode round trip is skipped there.", "platform_support": "The port remains supported. Video playback and frame decoding are measured separately by VideoIODecodedFramesScreenshotTest; this skip is limited to creating a new encoded video in that CI environment.", - "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds." + "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds.", + "reason_codes": [ + "encode-unavailable-on-", + "VideoIO-unsupported-on-" + ] } ], "features": [ @@ -22,12 +29,59 @@ "testing": "Build each native backend, then run permission-granted capture checks on hardware or an interactive browser and verify a real frame and saved image.", "why_not_automated": "A headless portability run cannot accept consent dialogs, guarantee a camera device, or compare nondeterministic sensor frames.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "CameraX", "detail": "Native CameraX backend; runtime permission and hardware required."}, - {"ports": ["ios-gl", "ios-metal", "mac-native"], "state": "supported", "label": "AVFoundation", "detail": "Native AVFoundation backend; signed build, entitlement, permission, and camera hardware required."}, - {"ports": ["javascript"], "state": "conditional", "label": "MediaDevices", "detail": "Browser mediaDevices backend when the page is secure and the user grants access."}, - {"ports": ["linux-x64", "linux-arm64"], "state": "conditional", "label": "Native camera", "detail": "Native desktop camera backend when a compatible host camera is available."}, - {"ports": ["windows-x64", "windows-arm64"], "state": "conditional", "label": "Media Foundation", "detail": "Native Media Foundation camera backend when the host exposes a camera."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No camera", "detail": "The target form factor does not expose a Codename One camera-capture backend."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "CameraX", + "detail": "Native CameraX backend; runtime permission and hardware required." + }, + { + "ports": [ + "ios-gl", + "ios-metal", + "mac-native" + ], + "state": "supported", + "label": "AVFoundation", + "detail": "Native AVFoundation backend; signed build, entitlement, permission, and camera hardware required." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "MediaDevices", + "detail": "Browser mediaDevices backend when the page is secure and the user grants access." + }, + { + "ports": [ + "linux-x64", + "linux-arm64" + ], + "state": "conditional", + "label": "Native camera", + "detail": "Native desktop camera backend when a compatible host camera is available." + }, + { + "ports": [ + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Media Foundation", + "detail": "Native Media Foundation camera backend when the host exposes a camera." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No camera", + "detail": "The target form factor does not expose a Codename One camera-capture backend." + } ] }, { @@ -38,10 +92,45 @@ "testing": "Use a signed permission-granted build, record a calibrated tone or voice sample, and verify duration, level, and playback on the target device.", "why_not_automated": "Hosted runners do not provide consistent microphones and operating systems deliberately require interactive consent.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native"], "state": "supported", "label": "Native recorder", "detail": "Native media recorder with runtime permission and physical input."}, - {"ports": ["javascript"], "state": "conditional", "label": "Browser capture", "detail": "Available through browser media capture on a secure origin after consent."}, - {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Host audio", "detail": "Available when the native desktop media stack exposes an input device."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No suite backend", "detail": "No general-purpose Codename One recording backend is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native" + ], + "state": "supported", + "label": "Native recorder", + "detail": "Native media recorder with runtime permission and physical input." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Browser capture", + "detail": "Available through browser media capture on a secure origin after consent." + }, + { + "ports": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Host audio", + "detail": "Available when the native desktop media stack exposes an input device." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No suite backend", + "detail": "No general-purpose Codename One recording backend is declared for this target." + } ] }, { @@ -52,11 +141,52 @@ "testing": "Run the AR or sensor sample on supported hardware, verify session startup and sensor updates, and compare motion against a controlled physical action.", "why_not_automated": "Simulators do not provide a real camera pose, AR runtime, or reproducible physical movement; the automated row checks API contracts and unsupported fallbacks instead.", "coverage": [ - {"ports": ["android"], "state": "conditional", "label": "ARCore / sensors", "detail": "Available on compatible devices with the required vendor services and sensors."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "ARKit / sensors", "detail": "Available on compatible iPhone or iPad hardware."}, - {"ports": ["watchos"], "state": "conditional", "label": "Motion sensors", "detail": "Motion sensing is form-factor dependent; general AR sessions are not applicable."}, - {"ports": ["javascript"], "state": "conditional", "label": "Browser sensors", "detail": "Browser sensor APIs depend on browser policy, secure context, consent, and hardware."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "tvos"], "state": "unavailable", "label": "Fallback contract", "detail": "The portable API reports that no live AR or motion backend is present."} + { + "ports": [ + "android" + ], + "state": "conditional", + "label": "ARCore / sensors", + "detail": "Available on compatible devices with the required vendor services and sensors." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "ARKit / sensors", + "detail": "Available on compatible iPhone or iPad hardware." + }, + { + "ports": [ + "watchos" + ], + "state": "conditional", + "label": "Motion sensors", + "detail": "Motion sensing is form-factor dependent; general AR sessions are not applicable." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Browser sensors", + "detail": "Browser sensor APIs depend on browser policy, secure context, consent, and hardware." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "tvos" + ], + "state": "unavailable", + "label": "Fallback contract", + "detail": "The portable API reports that no live AR or motion backend is present." + } ] }, { @@ -67,10 +197,45 @@ "testing": "Install a signed build, grant the appropriate permission level, feed known routes or physically cross a geofence, and verify foreground and background callbacks.", "why_not_automated": "The result depends on user permission, GPS/radio state, operating-system scheduling, and physical movement outside the hosted runner.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native location", "detail": "Native foreground and background location subject to OS permissions and policy."}, - {"ports": ["mac-native", "watchos"], "state": "conditional", "label": "Apple location", "detail": "Available where the target and entitlement expose Core Location behavior."}, - {"ports": ["javascript"], "state": "conditional", "label": "Geolocation", "detail": "Foreground browser geolocation on a secure origin after consent; background behavior is browser-limited."}, - {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "tvos"], "state": "unavailable", "label": "No declared backend", "detail": "No complete location and geofencing backend is declared for this portability target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native location", + "detail": "Native foreground and background location subject to OS permissions and policy." + }, + { + "ports": [ + "mac-native", + "watchos" + ], + "state": "conditional", + "label": "Apple location", + "detail": "Available where the target and entitlement expose Core Location behavior." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Geolocation", + "detail": "Foreground browser geolocation on a secure origin after consent; background behavior is browser-limited." + }, + { + "ports": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "tvos" + ], + "state": "unavailable", + "label": "No declared backend", + "detail": "No complete location and geofencing backend is declared for this portability target." + } ] }, { @@ -81,11 +246,52 @@ "testing": "Use a signed app and real provider credentials, register a device, send from the push service, and verify foreground, background, and launch delivery.", "why_not_automated": "Push requires certificates or provider keys, an externally reachable service, a uniquely registered installation, and asynchronous OS delivery.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "FCM", "detail": "Firebase Cloud Messaging with app credentials and notification permission."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "APNs", "detail": "Apple Push Notification service with signing entitlements and permission."}, - {"ports": ["javascript"], "state": "conditional", "label": "Web Push", "detail": "Supported by compatible browsers on HTTPS with service-worker and provider setup."}, - {"ports": ["mac-native"], "state": "conditional", "label": "Apple entitlement", "detail": "Depends on the signed macOS target and its notification entitlements."}, - {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No standalone backend", "detail": "No current standalone Codename One remote-push backend is declared for this target."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "FCM", + "detail": "Firebase Cloud Messaging with app credentials and notification permission." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "APNs", + "detail": "Apple Push Notification service with signing entitlements and permission." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Web Push", + "detail": "Supported by compatible browsers on HTTPS with service-worker and provider setup." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "Apple entitlement", + "detail": "Depends on the signed macOS target and its notification entitlements." + }, + { + "ports": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No standalone backend", + "detail": "No current standalone Codename One remote-push backend is declared for this target." + } ] }, { @@ -96,10 +302,45 @@ "testing": "Create sandbox products, sign with a store account, complete purchase and restore flows, and validate receipts against the store or Commerce backend.", "why_not_automated": "The flow requires store-side product configuration, signed identities, sandbox accounts, payment UI, and mutable server receipt state.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Play Billing", "detail": "Google Play purchase and subscription flow in a store-installed build."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "StoreKit", "detail": "Apple StoreKit purchase, restore, and subscription flow."}, - {"ports": ["mac-native"], "state": "conditional", "label": "StoreKit", "detail": "Requires a signed Mac App Store configuration and matching products."}, - {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No store adapter", "detail": "No standalone Codename One store-purchase adapter is declared for this target."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Play Billing", + "detail": "Google Play purchase and subscription flow in a store-installed build." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "StoreKit", + "detail": "Apple StoreKit purchase, restore, and subscription flow." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "StoreKit", + "detail": "Requires a signed Mac App Store configuration and matching products." + }, + { + "ports": [ + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No store adapter", + "detail": "No standalone Codename One store-purchase adapter is declared for this target." + } ] }, { @@ -110,9 +351,38 @@ "testing": "Populate a device test account, grant access, exercise read/select/write operations, and verify the result in the native contacts or calendar application.", "why_not_automated": "Access is permission-gated and the expected result lives in private user databases and native UI outside the test process.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native stores", "detail": "Native contacts and calendar services after the user grants access."}, - {"ports": ["mac-native"], "state": "conditional", "label": "Apple services", "detail": "Availability depends on the macOS target, entitlement, and user account."}, - {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No portable store", "detail": "The target does not expose a complete Codename One personal-data backend."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native stores", + "detail": "Native contacts and calendar services after the user grants access." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "Apple services", + "detail": "Availability depends on the macOS target, entitlement, and user account." + }, + { + "ports": [ + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No portable store", + "detail": "The target does not expose a complete Codename One personal-data backend." + } ] }, { @@ -123,9 +393,38 @@ "testing": "Enroll biometrics on hardware, run success, cancellation, lockout, and enrollment-change cases, and verify protected-secret invalidation.", "why_not_automated": "Hosted runners have no enrolled biometric hardware and native prompts are intentionally controlled by the user and secure hardware.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "BiometricPrompt", "detail": "Android BiometricPrompt or legacy fingerprint support, backed by Android Keystore."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "LocalAuthentication", "detail": "Face ID or Touch ID through LocalAuthentication and Keychain."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "NOT_AVAILABLE", "detail": "The portable API returns its documented non-supporting fallback."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "BiometricPrompt", + "detail": "Android BiometricPrompt or legacy fingerprint support, backed by Android Keystore." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "LocalAuthentication", + "detail": "Face ID or Touch ID through LocalAuthentication and Keychain." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "NOT_AVAILABLE", + "detail": "The portable API returns its documented non-supporting fallback." + } ] }, { @@ -136,9 +435,38 @@ "testing": "Present known physical tags and cards, verify payloads and errors, and use a certified reader for host-card-emulation exchanges.", "why_not_automated": "NFC requires short-range hardware, physical tags or readers, user presentation, and platform entitlements that hosted runners lack.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Android NFC", "detail": "NDEF, tag technologies, and HCE on compatible hardware."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "Core NFC", "detail": "NDEF and selected tag technologies; HCE is restricted by iOS version and region."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "NOT_AVAILABLE", "detail": "The API returns its documented non-supporting fallback."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Android NFC", + "detail": "NDEF, tag technologies, and HCE on compatible hardware." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "Core NFC", + "detail": "NDEF and selected tag technologies; HCE is restricted by iOS version and region." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "NOT_AVAILABLE", + "detail": "The API returns its documented non-supporting fallback." + } ] }, { @@ -149,9 +477,38 @@ "testing": "Use a known peripheral or protocol simulator, scan and connect, then verify characteristic reads, writes, notifications, reconnects, and permission denial.", "why_not_automated": "Radio state, nearby peripherals, pairing, permissions, and timing are external to the deterministic screenshot runner.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "cn1-bluetooth", "detail": "Maintained native library backend on supported phone and tablet hardware."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Host-dependent", "detail": "Desktop hardware support depends on the selected maintained library backend and host adapter."}, - {"ports": ["javascript", "watchos", "tvos"], "state": "unavailable", "label": "No declared backend", "detail": "No maintained general-purpose Bluetooth backend is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "cn1-bluetooth", + "detail": "Maintained native library backend on supported phone and tablet hardware." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Host-dependent", + "detail": "Desktop hardware support depends on the selected maintained library backend and host adapter." + }, + { + "ports": [ + "javascript", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No declared backend", + "detail": "No maintained general-purpose Bluetooth backend is declared for this target." + } ] }, { @@ -162,9 +519,38 @@ "testing": "Install a signed build, configure the required modes, background or terminate it, and observe callbacks across OS throttling and restart scenarios.", "why_not_automated": "Scheduling is deliberately nondeterministic, power-policy controlled, entitlement dependent, and often takes longer than a CI job window.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native scheduler", "detail": "Platform background mechanisms subject to OS quotas, permissions, and lifecycle policy."}, - {"ports": ["mac-native"], "state": "conditional", "label": "Target-dependent", "detail": "Availability depends on the macOS application mode and signed capabilities."}, - {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No equivalent contract", "detail": "No equivalent Codename One background-fetch contract is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native scheduler", + "detail": "Platform background mechanisms subject to OS quotas, permissions, and lifecycle policy." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "Target-dependent", + "detail": "Availability depends on the macOS application mode and signed capabilities." + }, + { + "ports": [ + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No equivalent contract", + "detail": "No equivalent Codename One background-fetch contract is declared for this target." + } ] }, { @@ -175,9 +561,38 @@ "testing": "Use release-like signed builds, compromised and clean devices, fresh server nonces, and backend verification of vendor-signed verdicts.", "why_not_automated": "Trust verdicts depend on hardware-backed keys, store-distributed builds, vendor services, device state, and an application backend.", "coverage": [ - {"ports": ["android"], "state": "conditional", "label": "Play Integrity", "detail": "Root signals and optional Google Play Integrity when enabled and verified by a backend."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "App Attest", "detail": "Jailbreak signals and optional Apple App Attest when enabled and verified by a backend."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "Unsupported fallback", "detail": "No hardware-backed Codename One attestation backend is declared for this target."} + { + "ports": [ + "android" + ], + "state": "conditional", + "label": "Play Integrity", + "detail": "Root signals and optional Google Play Integrity when enabled and verified by a backend." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "App Attest", + "detail": "Jailbreak signals and optional Apple App Attest when enabled and verified by a backend." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "Unsupported fallback", + "detail": "No hardware-backed Codename One attestation backend is declared for this target." + } ] }, { @@ -188,8 +603,31 @@ "testing": "Seed platform storage, launch the native picker, select and cancel items, and verify temporary URI or security-scoped access after returning to the app.", "why_not_automated": "The decisive UI and permissions belong to another process, and hosted runners do not share an identical populated media or document library.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Native picker", "detail": "Uses the target's file, document, or browser picker; available types and access rules vary by OS."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No general picker", "detail": "The form factor has no general-purpose Codename One document/gallery picker."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Native picker", + "detail": "Uses the target's file, document, or browser picker; available types and access rules vary by OS." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No general picker", + "detail": "The form factor has no general-purpose Codename One document/gallery picker." + } ] }, { @@ -200,9 +638,38 @@ "testing": "Open each native chooser or handler on a configured device, complete and cancel actions, and verify returned result metadata where the OS supplies it.", "why_not_automated": "Installed applications, accounts, SIM capability, chooser UI, and user selection are outside the app process and differ per runner.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native intents", "detail": "Native share and communication handlers when the device has a matching service."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "OS/browser handler", "detail": "Uses an installed desktop handler, browser capability, or portable fallback where available."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "Form-factor limited", "detail": "No general-purpose Codename One communication chooser is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native intents", + "detail": "Native share and communication handlers when the device has a matching service." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "OS/browser handler", + "detail": "Uses an installed desktop handler, browser capability, or portable fallback where available." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "Form-factor limited", + "detail": "No general-purpose Codename One communication chooser is declared for this target." + } ] }, { @@ -213,9 +680,38 @@ "testing": "Use a store-eligible build and account, request the prompt under vendor quota rules, and verify fallback behavior separately.", "why_not_automated": "Apple and Google intentionally decide whether the native prompt appears, so a request cannot deterministically assert visible store UI.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Play Review", "detail": "Google Play In-App Review when the build includes the service."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "StoreKit", "detail": "StoreKit review request subject to Apple's display quota."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Portable fallback", "detail": "Codename One shows its built-in rating sheet when no native prompt is available."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Play Review", + "detail": "Google Play In-App Review when the build includes the service." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "StoreKit", + "detail": "StoreKit review request subject to Apple's display quota." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "fallback", + "label": "Portable fallback", + "detail": "Codename One shows its built-in rating sheet when no native prompt is available." + } ] }, { @@ -226,9 +722,38 @@ "testing": "Use valid provider keys on a networked device, load known coordinates, exercise gestures and markers, and confirm provider attribution and fallback behavior.", "why_not_automated": "Provider keys are secrets, map imagery changes independently, usage may be billed, and native peers or network tiles are not deterministic pixels.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "Native provider", "detail": "Native provider when configured, with the portable map renderer as fallback."}, - {"ports": ["javascript"], "state": "conditional", "label": "Web provider", "detail": "Browser map provider with API key and network access, plus portable fallback."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Portable map", "detail": "Portable vector or tile rendering is used when no native provider is active."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "Native provider", + "detail": "Native provider when configured, with the portable map renderer as fallback." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Web provider", + "detail": "Browser map provider with API key and network access, plus portable fallback." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "fallback", + "label": "Portable map", + "detail": "Portable vector or tile rendering is used when no native provider is active." + } ] }, { @@ -239,9 +764,38 @@ "testing": "Use provider test-unit identifiers in a signed build, complete consent flows, and verify fill, impression, click, and lifecycle callbacks.", "why_not_automated": "Ad inventory, consent state, provider accounts, network policy, and SDK UI are external and nondeterministic; the first table uses deterministic mock ads.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "Provider SDK", "detail": "Supported through configured native ad libraries and provider test units."}, - {"ports": ["javascript"], "state": "conditional", "label": "Web integration", "detail": "Depends on the selected web advertising integration and hosting policy."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Mock/custom", "detail": "The portable ad component can be tested with mock or application-provided content; no bundled live network is claimed."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "Provider SDK", + "detail": "Supported through configured native ad libraries and provider test units." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Web integration", + "detail": "Depends on the selected web advertising integration and hosting policy." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "fallback", + "label": "Mock/custom", + "detail": "The portable ad component can be tested with mock or application-provided content; no bundled live network is claimed." + } ] }, { @@ -252,12 +806,59 @@ "testing": "Install the correctly signed app or extension, publish timelines and actions, then inspect the launcher, lock screen, Dynamic Island, notification area, or desktop host.", "why_not_automated": "The first table checks serialization, rasterization, timelines, and dispatch, but the final surface is rendered by a separate OS process with signing and entitlement requirements.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Widgets/notifications", "detail": "Lowers supported surfaces to Android widgets and ongoing notifications."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "WidgetKit/ActivityKit", "detail": "Requires supported OS versions, extension packaging, and entitlements."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64"], "state": "conditional", "label": "Desktop surface", "detail": "Uses the desktop floating-window or preview presentation available to the port."}, - {"ports": ["windows-x64", "windows-arm64"], "state": "conditional", "label": "Windows surfaces", "detail": "Floating widgets are supported; Widgets Board integration requires MSIX and Windows App SDK packaging."}, - {"ports": ["javascript"], "state": "fallback", "label": "In-app preview", "detail": "Portable document and rasterizer behavior is available without an OS widget host."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No declared lowering", "detail": "No standalone Codename One external-surface lowering is declared for this target."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Widgets/notifications", + "detail": "Lowers supported surfaces to Android widgets and ongoing notifications." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "WidgetKit/ActivityKit", + "detail": "Requires supported OS versions, extension packaging, and entitlements." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64" + ], + "state": "conditional", + "label": "Desktop surface", + "detail": "Uses the desktop floating-window or preview presentation available to the port." + }, + { + "ports": [ + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Windows surfaces", + "detail": "Floating widgets are supported; Widgets Board integration requires MSIX and Windows App SDK packaging." + }, + { + "ports": [ + "javascript" + ], + "state": "fallback", + "label": "In-app preview", + "detail": "Portable document and rasterizer behavior is available without an OS widget host." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No declared lowering", + "detail": "No standalone Codename One external-surface lowering is declared for this target." + } ] }, { @@ -268,8 +869,31 @@ "testing": "Enable the platform screen reader, traverse representative forms, verify announcements and actions, and repeat with dynamic content and input devices.", "why_not_automated": "The first table validates semantics and API state, while the final speech, focus order, gestures, and switch-control behavior belong to external OS services.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "OS assistive tech", "detail": "Semantic output is consumed by the screen reader or accessibility stack available on that OS."}, - {"ports": ["watchos", "tvos"], "state": "conditional", "label": "Form-factor service", "detail": "Behavior depends on the target's accessibility service and navigation model."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "OS assistive tech", + "detail": "Semantic output is consumed by the screen reader or accessibility stack available on that OS." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "conditional", + "label": "Form-factor service", + "detail": "Behavior depends on the target's accessibility service and navigation model." + } ] }, { @@ -280,7 +904,24 @@ "testing": "Compile each architecture, inspect the generated package, sign with platform credentials, install it, and exercise the integrated native entry point or SDK in its target environment.", "why_not_automated": "These checks happen before app startup or require private signing identities, store portals, proprietary SDK credentials, and platform-specific source rather than one shared runtime assertion.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "supported", "label": "Target build", "detail": "The port has its own build, packaging, and native-integration path; exact signing and extension capabilities are target specific."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "supported", + "label": "Target build", + "detail": "The port has its own build, packaging, and native-integration path; exact signing and extension capabilities are target specific." + } ] } ] diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 32153e94ed1..33d06810701 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -31,14 +31,38 @@ {{- $notRun = add $notRun 1 -}} {{- end -}} {{- end -}} - {{- /* A skip only reads as green when the errata below account for it by - name. An undocumented skip stays a partial result. */ -}} + {{- /* A skip only reads as green when the errata account for that test AND + for the reason the run actually gave. Matching on the test name alone + let any future skip of a named test render as documented: the video + round trip reports encoder trouble as a skip on that test, so an + encoder that regressed would have shown a green, explained cell that + said nothing about what went wrong. An unrecognized reason -- or a + skip carrying none -- stays a partial result. */ -}} {{- $documented := gt (len $skippedTests) 0 -}} {{- range $skippedTests -}} {{- $test := . -}} + {{- $result := index $report.tests $test -}} + {{- $reasons := slice -}} + {{- with $result -}}{{- with .reasons -}}{{- $reasons = . -}}{{- end -}}{{- end -}} {{- $found := false -}} {{- range $supplement.skip_reasons -}} - {{- if eq .test $test -}}{{- $found = true -}}{{- end -}} + {{- if eq .test $test -}} + {{- $codes := .reason_codes -}} + {{- if $codes -}} + {{- $allMatched := gt (len $reasons) 0 -}} + {{- range $reasons -}} + {{- $reason := . -}} + {{- $ok := false -}} + {{- range $codes -}} + {{- if hasPrefix $reason . -}}{{- $ok = true -}}{{- end -}} + {{- end -}} + {{- if not $ok -}}{{- $allMatched = false -}}{{- end -}} + {{- end -}} + {{- if $allMatched -}}{{- $found = true -}}{{- end -}} + {{- else -}} + {{- $found = true -}} + {{- end -}} + {{- end -}} {{- end -}} {{- if not $found -}}{{- $documented = false -}}{{- end -}} {{- end -}} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java index f4bede2c9d9..a3b50f4b44f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java @@ -157,6 +157,13 @@ private void runRoundTrip() { + "/cn1-videoio-roundtrip-" + System.currentTimeMillis() + (webm ? ".webm" : ".mp4"); // ---- ENCODE (encode-side unavailability is a SKIP, not a failure) ---- + // Two phases, reported differently on purpose. Failing to obtain a + // writer is the documented "this runner exposes no encoder" case and + // is skipped. Failing after one was obtained means the encoder is + // there and broke, which is a regression -- reporting that as the same + // skip made an encoder failure render as a documented green cell, + // indistinguishable from a target that never had an encoder. + VideoWriter writer; try { VideoWriterBuilder builder = new VideoWriterBuilder() .path(path).container(container) @@ -165,7 +172,13 @@ private void runRoundTrip() { if (withAudio) { builder.hasAudio(true).audioCodec(audioCodec).sampleRate(SAMPLE_RATE).audioChannels(1); } - VideoWriter writer = io.createWriter(builder); + writer = io.createWriter(builder); + } catch (Throwable t) { + cleanup(path); + skip("encode-unavailable-on-" + Display.getInstance().getPlatformName()); + return; + } + try { int samplesPerFrame = SAMPLE_RATE / FRAMES; for (int i = 0; i < FRAMES; i++) { writer.writeFrame(makeCountingFrame(i), Math.round(i * 1000f / FPS)); @@ -176,7 +189,7 @@ private void runRoundTrip() { writer.close(); } catch (Throwable t) { cleanup(path); - skip("encode-unavailable-on-" + Display.getInstance().getPlatformName() + ":" + t.getMessage()); + fail("the encoder was available but failed while writing: " + t); return; } diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 374cb72a3b0..1e5404c32e8 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -61,6 +61,35 @@ trap cleanup EXIT published=0 skipped=0 +# Mirrors port_status.py's FUTURE_STAMP_TOLERANCE. The gate publishes a report +# stamped slightly ahead of now, so the closing assertion has to accept the same +# margin or it fails over reports this very run published. +future_skew_seconds=3600 + +# True when $1 is a strictly later instant than $2. Both are timezone-aware +# ISO-8601, but not necessarily normalized to Z, so they are compared as +# instants rather than as text. +newer_instant() { + python3 - "$1" "$2" <<'INSTANT' +import sys +from datetime import datetime + +def parse(value): + try: + stamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return stamp if stamp.tzinfo is not None else None + +candidate = parse(sys.argv[1]) +current = parse(sys.argv[2]) +# An unreadable candidate is never "newer"; an unreadable stored value is +# replaced, since leaving it in place would strand the port on something the +# page cannot render either. +sys.exit(0 if candidate is not None and (current is None or candidate > current) else 1) +INSTANT +} + # The contract's own freshness window bounds how far back a candidate run is # worth considering: a report older than this is stale by definition, so there # is nothing to be gained by looking past it. @@ -116,6 +145,18 @@ while IFS= read -r workflow; do if [ -z "${found}" ] || [ -f "${download_dir}/covered-${found}" ]; then continue fi + # Only ports this workflow is declared to produce. The port is read from + # the artifact, so a misconfigured matrix that stamped someone else's id + # on its report would otherwise be published straight over that port's + # entry -- Linux evidence replacing Android's genuine result, with both + # the gate and the freshness check satisfied. + case " ${owned} " in + *" ${found} "*) ;; + *) + echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 + continue + ;; + esac # Gate before marking the port covered, not after. A newest run that # uploaded an unusable report would otherwise claim the port and stop # the older candidates from being consulted, so the sweep would keep @@ -156,7 +197,11 @@ while IFS= read -r workflow; do --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/current.json" 2>/dev/null; then current="$(jq -r '.generated_at // empty' "${tmp_dir}/current.json" 2>/dev/null || true)" fi - if [ -n "${current}" ] && [[ ! "${generated}" > "${current}" ]]; then + # Compare instants rather than strings. The gate accepts any timezone-aware + # timestamp, and "2026-08-01T01:00:00+02:00" sorts after + # "2026-08-01T00:00:00Z" while being an hour older, so a lexical test can + # overwrite a newer report or refuse a genuinely newer one. + if [ -n "${current}" ] && ! newer_instant "${generated}" "${current}"; then skipped=$((skipped + 1)) continue fi @@ -198,15 +243,22 @@ raw = sys.argv[1] try: stamp = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: - print(-1) + print("unreadable") else: - print(-1 if stamp.tzinfo is None + print("unreadable" if stamp.tzinfo is None else int((datetime.now(timezone.utc) - stamp).total_seconds())) AGE )" stale_seconds=$((stale_days * 86400)) - if [ "${age_seconds}" -lt 0 ]; then + # The publication gate deliberately tolerates an hour of clock skew, so a + # report it accepted can legitimately carry a timestamp a little ahead of + # now. Calling that unreadable here would fail the nightly job over a report + # the same run just published, until wall time caught up. Unparseable stays + # -1 from the helper above and is still a problem. + if [ "${age_seconds}" = "unreadable" ]; then problems+=("${port}: unreadable generated_at ${generated:-}") + elif [ "${age_seconds}" -lt "-${future_skew_seconds}" ]; then + problems+=("${port}: generated_at ${generated} is $(( -age_seconds / 60 )) minutes in the future") elif [ "${age_seconds}" -gt "${stale_seconds}" ]; then problems+=("${port}: last report is $((age_seconds / 3600)) hours old (limit ${stale_days} days)") fi From 2ff146cad97efc81508d1265d49dc954e6a3ad3b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:20:15 +0700 Subject: [PATCH 24/48] Document encoder skips per port instead of failing the Apple runs My previous commit turned a write-phase encoder failure into a hard failure, which took ios-gl, ios-metal and mac-native red. The evidence says the split was in the wrong place: on the Apple simulators the writer IS obtained and close() then fails with "Failed to finalize video file". That is the same "no usable encoder in the headless job" condition the erratum already describes, surfacing at finalize rather than at creation -- not a regression. So the write phase is a skip again, under its own reason code (encode-write-failed-on-) rather than sharing the creation one. What keeps that honest is the port scoping, which is what the review actually asked for and what I had left out: an erratum's reason codes now name the ports they cover, and a skip reads as documented only when the reason matches AND the report comes from one of those ports. Encoder trouble is documented on the five Apple targets and nowhere else, so the identical code arriving from Linux or Windows -- where the encoder is meant to work -- stays undocumented and renders partial rather than green. Verified by injecting the same reason code into two reports: tvos renders is-pass with the documented-skip note, linux-x64 renders is-partial. Also added the camera erratum's newer reason codes -- no-host-webcam-capture-on-win and no-camera-device-on-headless-runner -- scoped to Windows and Linux, so those intentional headless skips get the documented pass instead of sitting partial. The permission-prompt code stays unscoped on purpose: an unattended runner cannot answer a prompt on any target, so there is no port where it would mean a regression. Scoping it cost a currently-documented Windows cell in a first attempt, which is the kind of transient this distinction avoids. Co-Authored-By: Claude Opus 5 (1M context) --- docs/website/data/port_status_supplement.json | 50 +++++++++++++++++-- .../partials/port-status-feature-status.html | 11 +++- .../tests/VideoIORoundTripTest.java | 9 +++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index bc4b6f719aa..ef0bc5bfe35 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -6,7 +6,23 @@ "platform_support": "A skipped camera test does not mean the Codename One port is unsupported. Every listed target remains a supported port; camera availability is a separate runtime capability and depends on the device, permissions, and camera backend.", "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table.", "reason_codes": [ - "needs-runtime-permission-on-" + { + "prefix": "needs-runtime-permission-on-" + }, + { + "prefix": "no-host-webcam-capture-on-win", + "ports": [ + "windows-x64", + "windows-arm64" + ] + }, + { + "prefix": "no-camera-device-on-headless-runner", + "ports": [ + "linux-x64", + "linux-arm64" + ] + } ] }, { @@ -15,8 +31,36 @@ "platform_support": "The port remains supported. Video playback and frame decoding are measured separately by VideoIODecodedFramesScreenshotTest; this skip is limited to creating a new encoded video in that CI environment.", "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds.", "reason_codes": [ - "encode-unavailable-on-", - "VideoIO-unsupported-on-" + { + "prefix": "encode-unavailable-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + }, + { + "prefix": "encode-write-failed-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + }, + { + "prefix": "VideoIO-unsupported-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + } ] } ], diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 33d06810701..251843fe25b 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -54,7 +54,16 @@ {{- $reason := . -}} {{- $ok := false -}} {{- range $codes -}} - {{- if hasPrefix $reason . -}}{{- $ok = true -}}{{- end -}} + {{- /* A code documents this skip only when the reason matches + AND, where the erratum names ports, this is one of them. + Encoder trouble is expected on the Apple simulators and + nowhere else, so the same code from a port whose encoder is + meant to work stays undocumented and cannot render green. */ -}} + {{- $portAllowed := true -}} + {{- with .ports -}} + {{- $portAllowed = in . $port.id -}} + {{- end -}} + {{- if and $portAllowed (hasPrefix $reason .prefix) -}}{{- $ok = true -}}{{- end -}} {{- end -}} {{- if not $ok -}}{{- $allMatched = false -}}{{- end -}} {{- end -}} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java index a3b50f4b44f..3eca87011c2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java @@ -189,7 +189,14 @@ private void runRoundTrip() { writer.close(); } catch (Throwable t) { cleanup(path); - fail("the encoder was available but failed while writing: " + t); + // Still a skip, with its own reason code. The Apple simulators + // obtain a writer and then fail to finalize the file, which is the + // same "no usable encoder in the headless job" condition as failing + // to obtain one -- it just surfaces later. Reporting it under a + // distinct code lets the errata document it for the targets where + // it is expected, while the same code from a port whose encoder is + // supposed to work stays undocumented and therefore not green. + skip("encode-write-failed-on-" + Display.getInstance().getPlatformName()); return; } From 1b30b0699d54f4aafbf649b094e89df5f57db286 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:26:08 +0700 Subject: [PATCH 25/48] Honour the requested UI-settle budget, and say when it runs out DrawImage fails intermittently on the JavaScript port with its two offscreen-image cells half-painted: those two variants render into a fresh Image and composite it, so they are the slowest to settle, and the capture goes out mid-paint. Someone had already found that and asked for a longer settle -- port.js requests maxFrames 120 for exactly this test. The host then clamped the value to 96, so the request was quietly cut by a fifth and the settle returned whatever had been drawn so far. The ceiling now allows the budget that is actually asked for, with a bound well above any current request so a bad value still cannot spin forever. Exhausting the budget is also no longer silent. Running out of frames without ever meeting the quiet-and-stable condition is the difference between "the UI was ready" and "we stopped waiting", and only one of those explains a half-drawn screenshot afterwards; it is now reported in the settle diagnostics and returned to the caller. This is a budget fix, not a cure: if the composite of an offscreen image can still outrun its own queued ops the underlying ordering needs work, and the new settleExhausted flag is what will say so next time rather than leaving it to be guessed from the pixels. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/javascript/browser_bridge.js | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index 8ab3d4e313e..002e2063454 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -4622,7 +4622,14 @@ hostBridge.register('__cn1_wait_for_ui_settle__', function(request) { var payload = request || {}; var reason = payload.reason == null ? 'unknown' : String(payload.reason); - var maxFrames = Math.max(1, Math.min(96, (payload.maxFrames | 0) || 14)); + // Honour the caller's budget rather than quietly halving it. The + // graphics tests that render into an offscreen image and composite it + // ask for 120 frames precisely because they are the slowest to settle, + // and the old ceiling of 96 silently returned whatever had been drawn + // so far -- which is how DrawImage shipped a capture with its two + // offscreen-image cells still half-painted. The bound stays, well above + // any current request, so a bad value cannot spin forever. + var maxFrames = Math.max(1, Math.min(240, (payload.maxFrames | 0) || 14)); var stableFrames = Math.max(1, Math.min(6, (payload.stableFrames | 0) || 2)); var quietFramesRequired = Math.max(1, Math.min(12, (payload.quietFrames | 0) || stableFrames)); var previousSignature = String(global.__cn1LastScreenshotSignature || ''); @@ -4634,6 +4641,7 @@ var seenRenderSeq = startRenderSeq; var renderAdvanced = false; var quietFrames = 0; + var settleExhausted = false; function chooseBetter(a, b) { if (!a) { return b; @@ -4687,6 +4695,12 @@ } } if (index + 1 >= maxFrames) { + // Out of budget without ever meeting the quiet + stable condition. + // The capture still proceeds with the best frame seen, but say so: + // an exhausted settle is the difference between "the UI was ready" + // and "we stopped waiting", and only one of those explains a + // half-drawn screenshot afterwards. + settleExhausted = true; return best; } return runFrame(index + 1); @@ -4712,6 +4726,7 @@ diag('SCREENSHOT_START', 'settleRenderEndSeq', seenRenderSeq | 0); diag('SCREENSHOT_START', 'settleRenderAdvanced', renderAdvanced ? 1 : 0); diag('SCREENSHOT_START', 'settleQuietObserved', quietFrames | 0); + diag('SCREENSHOT_START', 'settleExhausted', settleExhausted ? 1 : 0); return { changedFromPrevious: changed ? 1 : 0, canvasSignature: meta.canvasSignature || 'none', @@ -4721,7 +4736,8 @@ canvasPick: meta.canvasPick | 0, renderStartSeq: startRenderSeq | 0, renderEndSeq: seenRenderSeq | 0, - renderAdvanced: renderAdvanced ? 1 : 0 + renderAdvanced: renderAdvanced ? 1 : 0, + settleExhausted: settleExhausted ? 1 : 0 }; }); }); From a3bbdd0546b9742a281cc5fe69e656e04a515607 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:53:13 +0700 Subject: [PATCH 26/48] Keep identifier-ignorable characters out of identifier starts isJavaIdentifierStart fell through to isIdentifierIgnorable, which is right for isJavaIdentifierPart and wrong here: an ignorable character may appear inside a name but never as its first character. Giving getType a real answer for the ASCII controls turned that latent wrong fallback into a visible one, since U+0000 through U+0008 are ignorable and now report CONTROL rather than UNASSIGNED, so the newly enabled identifier API began accepting a NUL as the start of a Java identifier. Verified against the reference JDK: fourteen code points -- the ASCII and C1 controls, a letter, a digit, underscore, dollar, tab and space -- checked for start, part and ignorable through the ParparVM clean target. All match. Against the previous code the same probe fails on all seven control characters. Co-Authored-By: Claude Opus 5 (1M context) --- vm/JavaAPI/src/java/lang/Character.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index 7ffc234a140..21571ce31f5 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -363,10 +363,15 @@ public static boolean isJavaIdentifierStart(int codePoint) { case CURRENCY_SYMBOL: return true; default: - return isIdentifierIgnorable(codePoint); + // No ignorable fallback here, unlike isJavaIdentifierPart. An + // identifier-ignorable character is allowed inside a name but + // never as its first character, and with getType now answering + // CONTROL for the ASCII controls, the old fallback let + // U+0000 through U+0008 start an identifier. + return false; } } - + public static boolean isJavaIdentifierPart(char ch) { return isJavaIdentifierPart((int) ch); } From 6e05a5c7fb157660a6ddcea051d350af64e5d9c6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:30:38 +0700 Subject: [PATCH 27/48] Align the iOS OAEP mask with every other port, and check the key family from the DER OAEP interop RSA_OAEP_SHA256 ciphertext could not cross between iOS and anywhere else. The transformation this API advertises is the JCE name "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which in JCE means a SHA-256 label with a SHA-1 mask; JavaSE and Android both hand that string straight to Cipher.getInstance and get exactly that, and the Linux and Windows ports produce it. iOS selected kSecKeyAlgorithmRSAEncryptionOAEPSHA256, which uses SHA-256 for both halves. So the outlier is iOS, and it was already unable to exchange ciphertext with Android before this branch existed -- adding two more ports on the JCE side only made it four against one. iOS is the side that moved. SecKey cannot express the JCE pairing, so the padding is built in the port and the key operation runs raw, which is the same thing the Windows port does for the same reason. Verified against OpenSSL as an independent oracle: blocks the iOS code encodes unpad correctly with the JCE pairing and blocks that pairing produces decode correctly in the iOS code, at 2048, 3072 and 4096 bits, plus rejection of a corrupted leading byte, seed and DB. That cross-port direction is the one that was broken, so it is the one worth testing. A raw RSA result can come back with its leading zero bytes dropped, and an OAEP block is defined at exactly the modulus width, so the block is left-padded before unpadding. Key family The previous fix compared the caller's keyAlgorithm label, which PrivateKey.fromPkcs8 and PublicKey.fromX509 accept without ever checking it against the bytes. EC DER labelled "RSA" therefore satisfied the comparison while the native derived EC from the DER and answered a SHA256withRSA request with an ECDSA signature. Both natives now take the family from the encoded key -- EVP_PKEY_base_id on Linux, the isEc the Windows importer already reports -- and refuse a mismatch. The Java-side check stays as a cheaper early error but is no longer what is trusted. Verified: the Windows OAEP/ECDSA harness still passes, both port sources compile, and crossCompilesWindowsExeWithXwin still links. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 23 +++ .../nativeSources/cn1_windows_crypto.c | 14 ++ Ports/iOSPort/nativeSources/CN1Crypto.m | 191 ++++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 8287560eadf..20e1f5a60d6 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -372,6 +372,19 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boo /* ------------------------------------------------------------ signatures */ +/* The signature algorithm's family must match the key that was actually handed + * over, read from the DER rather than from the caller's label. PrivateKey. + * fromPkcs8 and PublicKey.fromX509 take an arbitrary algorithm string and never + * check it against the bytes, so a Java-side comparison of that label can be + * satisfied while the encoded key is a different family entirely -- and the + * primitive below would then sign an "RSA" request with ECDSA. */ +static int cn1KeyFamilyMatches(const char* algorithm, EVP_PKEY* key) { + int wantsEc = strstr(algorithm, "ECDSA") != 0; + int keyIsEc = EVP_PKEY_base_id(key) == EVP_PKEY_EC; + return wantsEc == keyIsEc; +} + + static const EVP_MD* cn1SignatureDigest(const char* algorithm) { return cn1SignatureDigestOrNull(algorithm); } @@ -399,6 +412,11 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byt EVP_PKEY_free(key); return JAVA_NULL; } + if (!cn1KeyFamilyMatches(name, key)) { + cn1CryptoFail("the signature algorithm does not match the key"); + EVP_PKEY_free(key); + return JAVA_NULL; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); @@ -448,6 +466,11 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_ EVP_PKEY_free(key); return JAVA_FALSE; } + if (!cn1KeyFamilyMatches(name, key)) { + cn1CryptoFail("the signature algorithm does not match the key"); + EVP_PKEY_free(key); + return JAVA_FALSE; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 5090e98c897..1cec98dbae6 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -925,6 +925,15 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String cn1CryptoFail("unsupported signature algorithm", 0); goto done; } + /* The family has to come from the DER, not from the caller's label: + * PrivateKey.fromPkcs8 takes an arbitrary algorithm string and never checks + * it against the bytes, so the Java-side comparison can be satisfied while + * the encoded key is a different family -- and NCrypt would then answer an + * "RSA" request with an ECDSA signature. */ + if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + cn1CryptoFail("the signature algorithm does not match the key", 0); + goto done; + } if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { goto done; } @@ -994,6 +1003,11 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str BCryptDestroyKey(key); return JAVA_FALSE; } + if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + cn1CryptoFail("the signature algorithm does not match the key", 0); + BCryptDestroyKey(key); + return JAVA_FALSE; + } if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { unsigned char raw[132]; const unsigned char* toVerify = signature; diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index 7de2179a7a6..ece67f44bf1 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -284,6 +284,141 @@ static int cn1_seckey_op(SecKeyRef key, SecKeyAlgorithm alg, int forEncrypt, return (int) len; } +/* + * OAEP, built here rather than taken from SecKey. + * + * kSecKeyAlgorithmRSAEncryptionOAEPSHA256 uses SHA-256 for the label hash AND + * for MGF1. The transformation this port advertises is the JCE name + * "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which in JCE means a SHA-256 label + * with a SHA-1 mask -- that is what java.crypto gives the JavaSE and Android + * ports for the same string, and what the Linux and Windows ports produce. iOS + * was the only port pairing SHA-256 with SHA-256, so ciphertext never crossed + * between it and any other port. SecKey cannot express the JCE pairing, so the + * padding is built here and the key operation runs raw. + */ + +static void cn1_mgf1_sha1(const uint8_t* seed, int seedLen, uint8_t* mask, int maskLen) { + uint8_t counter[4]; + uint8_t digest[CC_SHA1_DIGEST_LENGTH]; + int produced = 0; + uint32_t count = 0; + while (produced < maskLen) { + int chunk = maskLen - produced; + CC_SHA1_CTX ctx; + counter[0] = (uint8_t) ((count >> 24) & 0xff); + counter[1] = (uint8_t) ((count >> 16) & 0xff); + counter[2] = (uint8_t) ((count >> 8) & 0xff); + counter[3] = (uint8_t) (count & 0xff); + CC_SHA1_Init(&ctx); + CC_SHA1_Update(&ctx, seed, (CC_LONG) seedLen); + CC_SHA1_Update(&ctx, counter, 4); + CC_SHA1_Final(digest, &ctx); + if (chunk > CC_SHA1_DIGEST_LENGTH) { + chunk = CC_SHA1_DIGEST_LENGTH; + } + memcpy(mask + produced, digest, (size_t) chunk); + produced += chunk; + count++; + } +} + +/* All ones when a == b, zero otherwise, without branching on the values. */ +static uint32_t cn1_ct_eq_mask(uint32_t a, uint32_t b) { + uint32_t diff = a ^ b; + uint32_t nonZero = (diff | (0u - diff)) >> 31; + return nonZero - 1u; +} + +static int cn1_oaep_encode(const uint8_t* message, int messageLen, + uint8_t* block, int blockLen) { + const int hashLen = CC_SHA256_DIGEST_LENGTH; + int dbLen = blockLen - hashLen - 1; + uint8_t seed[CC_SHA256_DIGEST_LENGTH]; + uint8_t* mask; + int i; + if (dbLen <= 0 || messageLen > dbLen - hashLen - 1) { + return 0; + } + mask = (uint8_t*) malloc((size_t) dbLen); + if (!mask) { + return 0; + } + memset(block, 0, (size_t) blockLen); + CC_SHA256("", 0, block + 1 + hashLen); + block[blockLen - messageLen - 1] = 0x01; + if (messageLen > 0) { + memcpy(block + blockLen - messageLen, message, (size_t) messageLen); + } + if (CCRandomGenerateBytes(seed, (size_t) hashLen) != kCCSuccess) { + free(mask); + return 0; + } + cn1_mgf1_sha1(seed, hashLen, mask, dbLen); + for (i = 0; i < dbLen; i++) { + block[1 + hashLen + i] ^= mask[i]; + } + cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); + for (i = 0; i < hashLen; i++) { + block[1 + i] = (uint8_t) (seed[i] ^ mask[i]); + } + free(mask); + return 1; +} + +/* Every check folds into one accumulator and one generic failure is reported: + * telling a leading-byte error from a label-hash error is enough to mount the + * adaptive attacks OAEP exists to prevent. */ +static int cn1_oaep_decode(uint8_t* block, int blockLen, uint8_t* message, int* messageLen) { + const int hashLen = CC_SHA256_DIGEST_LENGTH; + int dbLen = blockLen - hashLen - 1; + uint8_t labelHash[CC_SHA256_DIGEST_LENGTH]; + uint8_t seed[CC_SHA256_DIGEST_LENGTH]; + uint8_t* mask; + int i; + uint32_t bad = 0; + uint32_t seenDelimiter = 0; + uint32_t messageStart = 0; + if (dbLen <= 0) { + return 0; + } + mask = (uint8_t*) malloc((size_t) dbLen); + if (!mask) { + return 0; + } + bad |= (uint32_t) block[0]; + cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); + for (i = 0; i < hashLen; i++) { + seed[i] = (uint8_t) (block[1 + i] ^ mask[i]); + } + cn1_mgf1_sha1(seed, hashLen, mask, dbLen); + for (i = 0; i < dbLen; i++) { + block[1 + hashLen + i] ^= mask[i]; + } + free(mask); + CC_SHA256("", 0, labelHash); + for (i = 0; i < hashLen; i++) { + bad |= (uint32_t) (labelHash[i] ^ block[1 + hashLen + i]); + } + for (i = 1 + hashLen + hashLen; i < blockLen; i++) { + uint32_t value = block[i]; + uint32_t isDelimiter = cn1_ct_eq_mask(value, 0x01); + uint32_t isZero = cn1_ct_eq_mask(value, 0x00); + uint32_t firstDelimiter = isDelimiter & ~seenDelimiter; + messageStart |= ((uint32_t) (i + 1)) & firstDelimiter; + bad |= ~seenDelimiter & ~isDelimiter & ~isZero; + seenDelimiter |= isDelimiter; + } + bad |= ~seenDelimiter; + if (bad != 0) { + return 0; + } + *messageLen = blockLen - (int) messageStart; + if (*messageLen > 0) { + memcpy(message, block + messageStart, (size_t) *messageLen); + } + return 1; +} + static SecKeyAlgorithm rsa_padding_alg(int paddingKind) { return paddingKind == 2 ? kSecKeyAlgorithmRSAEncryptionOAEPSHA256 @@ -296,6 +431,26 @@ int cn1_crypto_rsa_encrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_public(x509, x509Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; + if (paddingKind == 2) { + /* Pad here and run the key raw, so the mask stays SHA-1 (see the OAEP + * note above). */ + int blockLen = (int) SecKeyGetBlockSize(key); + uint8_t* block = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); + int rc; + if (!block) { + CFRelease(key); + return CN1_CRYPTO_E_GENERIC; + } + if (!cn1_oaep_encode(in, inLen, block, blockLen)) { + free(block); + CFRelease(key); + return CN1_CRYPTO_E_BAD_INPUT; + } + rc = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 1, block, blockLen, out, outCap); + free(block); + CFRelease(key); + return rc; + } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 1, in, inLen, out, outCap); CFRelease(key); return rc; @@ -307,6 +462,42 @@ int cn1_crypto_rsa_decrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_private(pkcs8, pkcs8Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; + if (paddingKind == 2) { + int blockLen = (int) SecKeyGetBlockSize(key); + uint8_t* raw = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); + uint8_t* block; + int rawLen, messageLen = 0, rc; + if (!raw) { + CFRelease(key); + return CN1_CRYPTO_E_GENERIC; + } + rawLen = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 0, in, inLen, raw, blockLen); + CFRelease(key); + if (rawLen < 0) { + free(raw); + return rawLen; + } + /* A raw result may arrive with its leading zero bytes dropped; the OAEP + * block is defined at exactly the modulus width, so restore them. */ + block = (uint8_t*) calloc((size_t) (blockLen > 0 ? blockLen : 1), 1); + if (!block) { + free(raw); + return CN1_CRYPTO_E_GENERIC; + } + if (rawLen > blockLen) { + free(raw); + free(block); + return CN1_CRYPTO_E_BAD_INPUT; + } + memcpy(block + (blockLen - rawLen), raw, (size_t) rawLen); + free(raw); + if (!cn1_oaep_decode(block, blockLen, out, &messageLen) || messageLen > outCap) { + free(block); + return CN1_CRYPTO_E_BAD_INPUT; + } + free(block); + return messageLen; + } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 0, in, inLen, out, outCap); CFRelease(key); return rc; From e8bf68eec9f66ec0ea634101d8170fa2423f6a3d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:33:14 +0700 Subject: [PATCH 28/48] Require the exact key family, and let a dispatched run repair a stale port Key family The check I added tested only EVP_PKEY_EC on Linux and a boolean isEc on Windows, so every non-EC key counted as RSA. DSA bytes carrying an "RSA" label therefore passed and were signed as DSA under a SHA256withRSA request. Both ports now name the family they require: RSA for the RSA algorithms, EC for the ECDSA ones, and anything else is refused. On Windows that meant reporting an actual family from the importer and the SubjectPublicKeyInfo rather than "EC or not". Sweep candidates The producers declare workflow_dispatch and schedule, not push, so filtering candidates to push and schedule meant a maintainer rerunning a producer on master to repair a port the scheduled run missed could never reach the table -- the one case where a manual recovery is the whole point. Dispatched runs on master now count. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 12 +++- .../nativeSources/cn1_windows_crypto.c | 55 +++++++++++++------ .../conformance/backfill_port_status.sh | 7 ++- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 20e1f5a60d6..4b989a260b0 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -379,9 +379,15 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boo * satisfied while the encoded key is a different family entirely -- and the * primitive below would then sign an "RSA" request with ECDSA. */ static int cn1KeyFamilyMatches(const char* algorithm, EVP_PKEY* key) { - int wantsEc = strstr(algorithm, "ECDSA") != 0; - int keyIsEc = EVP_PKEY_base_id(key) == EVP_PKEY_EC; - return wantsEc == keyIsEc; + /* Name the family that is required rather than testing "not EC": every + * other key type would otherwise pass as RSA, so DSA bytes carrying an + * "RSA" label would reach EVP_DigestSign and come back as a DSA signature + * under a SHA256withRSA request. */ + int id = EVP_PKEY_base_id(key); + if (strstr(algorithm, "ECDSA") != 0) { + return id == EVP_PKEY_EC; + } + return id == EVP_PKEY_RSA; } diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 1cec98dbae6..f0659b5b47d 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -54,6 +54,11 @@ #define CN1_GCM_TAG_BYTES 16 +/* Key families, named so that "not EC" can never stand in for RSA. */ +#define CN1_KEY_OTHER 0 +#define CN1_KEY_RSA 1 +#define CN1_KEY_EC 2 + /* Per-thread: crypto failures on different threads would otherwise overwrite * each other and lastCryptoError() could answer with an unrelated call's * message. */ @@ -330,15 +335,15 @@ static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) { * EC with one path; *isEc reports which arrived so the caller can pick the * matching padding. */ -static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* isEc) { +static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* family) { NCRYPT_PROV_HANDLE provider = 0; NCRYPT_KEY_HANDLE key = 0; SECURITY_STATUS status; WCHAR algorithm[64]; DWORD algorithmBytes = 0; - if (isEc != 0) { - *isEc = 0; + if (family != 0) { + *family = CN1_KEY_OTHER; } status = NCryptOpenStorageProvider(&provider, MS_KEY_STORAGE_PROVIDER, 0); if (status != ERROR_SUCCESS) { @@ -362,27 +367,41 @@ static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int } return 0; } - if (isEc != 0 && + if (family != 0 && NCryptGetProperty(key, NCRYPT_ALGORITHM_GROUP_PROPERTY, (PBYTE) algorithm, sizeof(algorithm), &algorithmBytes, 0) == ERROR_SUCCESS) { - *isEc = wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0 - || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0; + /* Name both families rather than reporting "EC or not": anything else + * -- DSA in particular -- would otherwise be indistinguishable from RSA + * and would sign a SHA256withRSA request with whatever it actually is. */ + if (wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0 + || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0) { + *family = CN1_KEY_EC; + } else if (wcscmp(algorithm, NCRYPT_RSA_ALGORITHM_GROUP) == 0) { + *family = CN1_KEY_RSA; + } } return key; } -/* True when an X.509 SubjectPublicKeyInfo carries an elliptic-curve key. */ -static int cn1PublicKeyIsEc(const unsigned char* der, int length) { +/* The family an X.509 SubjectPublicKeyInfo carries. Named explicitly for the + * same reason as the private-key side: treating every non-EC key as RSA lets a + * DSA key verify an RSA request. */ +static int cn1PublicKeyFamily(const unsigned char* der, int length) { CERT_PUBLIC_KEY_INFO* info = 0; DWORD infoLength = 0; - int isEc = 0; + int family = CN1_KEY_OTHER; if (CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length, CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { - isEc = info->Algorithm.pszObjId != 0 - && strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0; + if (info->Algorithm.pszObjId != 0) { + if (strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0) { + family = CN1_KEY_EC; + } else if (strcmp(info->Algorithm.pszObjId, szOID_RSA_RSA) == 0) { + family = CN1_KEY_RSA; + } + } LocalFree(info); } - return isEc; + return family; } /* The digest half of Signature's six advertised algorithms; NULL for anything @@ -899,10 +918,11 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); - int keyLength = 0, dataLength = 0, isEc = 0; + int keyLength = 0, dataLength = 0, keyFamily = CN1_KEY_OTHER; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &isEc); + NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &keyFamily); + int isEc = keyFamily == CN1_KEY_EC; LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; int digestLength = cn1DigestLength(digestAlgorithm); @@ -930,7 +950,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String * it against the bytes, so the Java-side comparison can be satisfied while * the encoded key is a different family -- and NCrypt would then answer an * "RSA" request with an ECDSA signature. */ - if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + if (keyFamily != (strstr(name, "ECDSA") != 0 ? CN1_KEY_EC : CN1_KEY_RSA)) { cn1CryptoFail("the signature algorithm does not match the key", 0); goto done; } @@ -987,7 +1007,8 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); /* CryptImportPublicKeyInfoEx2 handles both key kinds; only the padding * differs, so read the algorithm out of the SubjectPublicKeyInfo. */ - int isEc = cn1PublicKeyIsEc(keyDer, keyLength); + int keyFamily = cn1PublicKeyFamily(keyDer, keyLength); + int isEc = keyFamily == CN1_KEY_EC; BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength); LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; @@ -1003,7 +1024,7 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str BCryptDestroyKey(key); return JAVA_FALSE; } - if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + if (keyFamily != (strstr(name, "ECDSA") != 0 ? CN1_KEY_EC : CN1_KEY_RSA)) { cn1CryptoFail("the signature algorithm does not match the key", 0); BCryptDestroyKey(key); return JAVA_FALSE; diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 1e5404c32e8..6f2cc28c467 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -101,6 +101,11 @@ while IFS= read -r workflow; do # Newest first, and a failed run counts: a suite that fails still uploads the # normalized report, and a report that records real failures is the result # the table is supposed to show. + # + # workflow_dispatch counts too. The producers declare dispatch and schedule + # rather than push, so a maintainer rerunning one on master to repair a port + # the scheduled run missed is exactly the recovery this sweep exists to pick + # up -- and filtering it out meant the manual fix could never reach the table. # Every run still inside the staleness horizon is a candidate, rather than a # fixed newest-five slice. A workflow whose matrix legs fail independently -- # the Linux producer especially, whose reports are not reliably published by @@ -112,7 +117,7 @@ while IFS= read -r workflow; do || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)" candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ - --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule") and + --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and (.conclusion == "success" or .conclusion == "failure") and (.updatedAt >= $horizon))] | sort_by(.updatedAt) | reverse | .[].databaseId')" From 0f7eed222d081975abf2a867106b492211633304 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:01:27 +0700 Subject: [PATCH 29/48] Arm the Linux gates that were letting unrun tests pass as success Comparing this branch's Linux report against master's shows the problem the strict gate exists to catch, and it is mine: master pass 161 fail 7 not-run 2 skip 0 this branch pass 161 fail 0 not-run 8 skip 1 Five of master's seven failures are genuinely fixed and one is a documented skip. But six tests that RAN on master do not run here at all -- five that passed, and FileSystemStorageOpenInputStreamMissingTest, which failed on master and which I previously reported as fixed. It was not fixed. It stopped running. That is worse than a red build. "fail: 0" read as success while it was partly the absence of tests rather than the absence of failures, and my own reporting repeated that reading. Two gates were supposed to prevent exactly this and neither was live. CN1_REQUIRE_SUITE was set to '1'. It is read with Boolean.parseBoolean, which answers false for '1', so the gate demanding the suite's own completion marker has never been armed on Linux -- the suite could be force-killed with trailing tests unrun and the job stayed green. The Windows pipeline passes a real boolean, which is why its gate works. Now 'true', in both the glibc job and the musl container. CN1SS_FAIL_ON_TEST_PROBLEMS was never exported for the Linux report, so --fail-on-test-problems -- which fails on tests that fail, do not run, or an incomplete suite -- was not passed, unlike the iOS, JavaScript and Mac workflows. Now exported. This will turn Linux red until the suite runs to completion, and that is the correct state: the workflow already documents that it "intermittently DIES mid-run with no output", and master leaves two tests unrun for the same reason. Making that visible is the point. A green built on tests that never ran is the failure this PR was opened to remove, not a result to keep. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 488c69b532b..ce6e418b229 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -224,7 +224,13 @@ jobs: # the VideoIO grid, the VR scene and the 360 panorama were still queued # behind the slow non-rendering API tail, so the suite was force-killed # and every trailing test was published as "never run". - CN1_REQUIRE_SUITE: '1' + # + # This is read with Boolean.parseBoolean, which answers false for '1', + # so the gate it describes has never actually been armed -- the suite + # could be force-killed with trailing tests unrun and nothing failed. + # The Windows pipeline passes a real boolean, which is why its gate + # works. 'true' arms it here. + CN1_REQUIRE_SUITE: 'true' # Build the native ELF (and the demo) against an old glibc for portability. CN1_CC: /usr/local/bin/cn1-zig-cc # After the suite runs, the capture test relinks the same objects into a @@ -326,7 +332,7 @@ jobs: -v "$GITHUB_WORKSPACE":/cn1 -w /cn1 \ -e CN1_SHOT_OUTPUT_DIR=/cn1/artifacts/linux-port/raw-musl \ -e CN1_APP_LOG_TEE=/cn1/artifacts/linux-port/raw-musl/app-output.log \ - -e CN1_REQUIRE_SUITE=1 \ + -e CN1_REQUIRE_SUITE=true \ -e LIBGL_ALWAYS_SOFTWARE=1 \ docker.io/library/alpine:3.20 sh -ec ' sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories @@ -422,6 +428,13 @@ jobs: # Gate the Linux port (both arches): fail on any mismatch/error or a new # screenshot that has no committed golden (missing_expected). export CN1SS_FAIL_ON_MISMATCH=1 + # A test that fails an assertion, or never runs at all, has to fail + # this workflow the way it already does on iOS, JavaScript and Mac. + # Without it a suite that stopped early published its trailing tests + # as "never run" and the job stayed green -- a result that reads as + # success while hiding both the tests that failed and the fact that + # they stopped running. + export CN1SS_FAIL_ON_TEST_PROBLEMS=1 export CN1SS_ALLOWED_MISSING=0 if [ "${{ github.event_name }}" != "pull_request" ]; then export CN1SS_SKIP_COMMENT=1; fi for arch in x64 arm64; do From 3d4051fb1cf8d74ccd119895e2ec9ad5208422f9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:39:43 +0700 Subject: [PATCH 30/48] Stop a closed MCP transport from stranding the process-wide registration MCPLoopbackTransportOpenTest failed in CI with "Another MCP socket transport is already open on port 47899" -- a port that class never uses. The registration is process-wide and had been left claimed by an earlier test class. The race is in the product, not the test. MCPServer.start hands the transport to a reader thread and returns; stop() closes it from the caller's thread. When stop() wins, close() cannot release the registration because open() has not claimed it yet -- and open(), arriving afterwards on the reader thread, claims it for a transport that will never listen. The slot is then held for the rest of the process and every later open() is refused, which is why an unrelated MCP test failed depending on class order. open() now re-reads the closed flag after claiming the slot and releases it again if the transport was closed underneath it. The regression test drives the sequence directly -- close, then open -- rather than trying to win a race. Against the previous code it fails, and takes the other two tests in the class down with it, which is the cascade CI saw. Also in this commit, both from review: An OAEP block shorter than 2*hLen+2 was accepted. With a 512-bit key the data block is 31 bytes while the SHA-256 label hash is 32, so the hash was written and compared past the end of the block. AddressSanitizer reports a heap-buffer-overflow on the previous code and is clean with the guard, while 640- and 768-bit blocks still round-trip. Fixed on iOS and Windows. TimeZone.getOffset asked ICU about the wrong instant on Windows. Its fields are local STANDARD time -- GregorianCalendar adds the raw offset before calling -- and converting them as UTC keeps the old offset for roughly the zone's raw-offset span around a transition, so America/New_York entering DST reported EST for the first five hours of EDT. It now queries at fields - rawOffset. The cross-compile still links. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 14 +++++++++++ .../nativeSources/cn1_windows_crypto.c | 10 ++++++-- Ports/iOSPort/nativeSources/CN1Crypto.m | 7 ++++-- .../mcp/MCPLoopbackTransportOpenTest.java | 23 +++++++++++++++++++ vm/ByteCodeTranslator/src/nativeMethods.m | 19 +++++++++++---- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 5e715ae6fea..0520124c448 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -118,6 +118,20 @@ public void open() throws IOException { } active = this; } + // open() runs on the server's reader thread, so a stop() from another thread can + // already have closed this transport before we got here. close() clears the + // registration only if it is still ours, and at that point it was not ours yet -- + // so claiming it now would strand the process-wide slot on a transport that never + // listens, and every later open() would be refused with "already open on port N" + // for the lifetime of the process. Release it and fail instead. + boolean closedBeforeListening; + synchronized (lock) { + closedBeforeListening = closed; + } + if (closedBeforeListening) { + clearActiveIfOurs(); + throw new IOException("This MCP socket transport was closed before it began listening"); + } try { listening = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index f0659b5b47d..761d6d58fca 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -546,7 +546,10 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned unsigned char seed[64]; unsigned char mask[1024]; int i; - if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB + * shorter than the label hash itself, and the hash would then be written + * and compared past the end of the block. */ + if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } @@ -614,7 +617,10 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* unsigned int bad = 0; unsigned int seenDelimiter = 0; unsigned int messageStart = 0; - if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB + * shorter than the label hash itself, and the hash would then be written + * and compared past the end of the block. */ + if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index ece67f44bf1..610dd94f6ce 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -336,7 +336,10 @@ static int cn1_oaep_encode(const uint8_t* message, int messageLen, uint8_t seed[CC_SHA256_DIGEST_LENGTH]; uint8_t* mask; int i; - if (dbLen <= 0 || messageLen > dbLen - hashLen - 1) { + /* An OAEP block cannot be shorter than 2*hLen+2; a 512-bit key leaves a DB + * shorter than the label hash, and the label hash would then be written and + * compared past the end of the block. */ + if (blockLen < 2 * hashLen + 2 || messageLen > dbLen - hashLen - 1) { return 0; } mask = (uint8_t*) malloc((size_t) dbLen); @@ -378,7 +381,7 @@ static int cn1_oaep_decode(uint8_t* block, int blockLen, uint8_t* message, int* uint32_t bad = 0; uint32_t seenDelimiter = 0; uint32_t messageStart = 0; - if (dbLen <= 0) { + if (blockLen < 2 * hashLen + 2) { return 0; } mask = (uint8_t*) malloc((size_t) dbLen); diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java index c9985e747b4..bba072ef257 100644 --- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java @@ -55,6 +55,29 @@ void closeTransports() { } } + /// A transport closed before its reader thread reaches open() must not take the + /// process-wide registration with it. + /// + /// MCPServer.start hands the transport to a reader thread and returns; stop() closes + /// it from the caller's thread. When stop() wins that race, close() cannot release the + /// registration because open() has not claimed it yet -- and open() then claimed it + /// for a transport that never listens, stranding the slot for the rest of the process. + /// Every later open() was refused with "already open on port N", which is how an + /// unrelated MCP test started failing in CI depending on class order. + @Test + void aTransportClosedBeforeItListensReleasesTheRegistration() throws Exception { + implementation.setServerSocketAvailable(true); + + MCPLoopbackSocketTransport early = new MCPLoopbackSocketTransport(47883); + early.close(); + // The reader thread's open(), arriving after the close. + assertThrows(IOException.class, () -> early.open()); + + // The slot has to be free, or nothing can open a transport again. + second = new MCPLoopbackSocketTransport(47884); + second.open(); + } + @Test void aFailedListenNeitherStrandsTheRegistrationNorEscapesAsRuntime() throws Exception { // The transport checks loopback support and then uses it. Report support to the diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index c2d14639aa1..00e7377674b 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2751,10 +2751,21 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int cn1_timezone_offset_ctx ctx; #ifdef _WIN32 { - int offset = 0; - if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), - &offset, 0, 0)) { - return offset; + /* These fields are local STANDARD time, which is what GregorianCalendar + * hands down (it adds the raw offset to the epoch before calling). The + * instant they name is therefore fields - rawOffset, not the fields read + * as UTC: asking about the wrong instant makes the calendar keep the old + * offset for roughly the zone's raw-offset span either side of a + * transition, so America/New_York entering DST reports EST for the first + * five hours of EDT. */ + int rawOffset = 0; + long long nominal = cn1WinUtcMillis(year, month, day, timeOfDayMillis); + if (cn1WinZoneOffsetMillis(buffer, nominal, 0, 0, &rawOffset)) { + int offset = 0; + if (cn1WinZoneOffsetMillis(buffer, nominal - (long long) rawOffset, + &offset, 0, 0)) { + return offset; + } } } #endif From 34087aa6fb7aedf8964e4963fbc605448a6ebe6f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:41:49 +0700 Subject: [PATCH 31/48] Name the test the Linux suite stops in With the gates armed the Linux suite now fails honestly, and the failure says the suite never emitted its completion marker -- but not which test it stopped in, which is the one thing needed to fix it. The in-app watchdog was supposed to answer that and could not. It did not emit a single marker across a 47-minute wedge, and the reason is structural: a test that blocks the event dispatch thread in a tight loop also blocks the collector, so the watchdog's own log call can be stuck waiting to allocate. A diagnostic that lives inside the wedged process cannot be relied on to report the wedge. The harness already reads the suite's output from outside the process, so it now records the last "suite starting test=" it saw and names it in both the console line and the assertion message. That works whatever state the app is in. On the current run it identifies Base64NativePerformanceTest: the log ends cleanly on that announcement with no exception and no further output, so the suite is hung there rather than crashed -- which is a different problem from the "silently DIES mid-run" the workflow comment describes, and worth knowing apart. Co-Authored-By: Claude Opus 5 (1M context) --- .../CleanTargetLinuxIntegrationTest.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 0d304694a61..d6b8a8d1540 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -376,6 +376,13 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // Set by the suite watchdog when a test blocks the event dispatch thread; // the run cannot progress past that point, so stop instead of waiting. final java.util.concurrent.atomic.AtomicReference wedged = new java.util.concurrent.atomic.AtomicReference<>(); + // The last test the suite announced. The in-app watchdog names a wedging + // test itself, but it cannot always get the message out: a test that blocks + // the event dispatch thread in a tight loop also blocks the collector, so + // the watchdog's own log call can be stuck waiting to allocate. Reading the + // announcement from outside the process always works, and "stopped in X" is + // the difference between a diagnosable failure and a silent one. + final java.util.concurrent.atomic.AtomicReference lastStarted = new java.util.concurrent.atomic.AtomicReference<>(); final Process appF = app; Thread areader = new Thread(() -> { // Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when @@ -399,6 +406,11 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { if (tee != null) { tee.println(line); } if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); } if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } + int startedAt = line.indexOf("CN1SS:INFO:suite starting test="); + if (startedAt >= 0) { + lastStarted.set(line.substring( + startedAt + "CN1SS:INFO:suite starting test=".length()).trim()); + } } } catch (IOException ignore) { } @@ -454,15 +466,19 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); if (!finished.get()) { + String stoppedIn = lastStarted.get(); System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs - + " -- every test after the last logged one is reported as never run."); + + "; stopped in " + (stoppedIn == null ? "" : stoppedIn) + + " -- that test and every one after it is reported as never run."); } assertTrue(wedged.get() == null, "the suite stopped because a test blocked the event dispatch thread: " + wedged.get()); assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" - + " suiteFinished=" + finished.get() + "\n" + serverLog); + + " suiteFinished=" + finished.get() + + " stoppedIn=" + (lastStarted.get() == null ? "" : lastStarted.get()) + + "\n" + serverLog); String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { From 5cdb9ccef83b7ff82f33405c20703dde4eda8453 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:45:26 +0700 Subject: [PATCH 32/48] Decide the MCP listener publication under the lock close() uses My previous fix closed the "registration left claimed" half of this race and left a worse one open. When close() runs while listenLoopback() is binding, it sees listening == null, clears the registration and returns -- and open() then publishes a listener nobody holds a reference to stop. That leaks the bound port, and because connection callbacks consult the process-wide `active`, the orphan could hand a connection to a later transport. `listening` is now published under the same lock close() takes, so the outcome is decidable whichever thread wins: either close() sees a published listener and stops it, or open() sees the closed flag and stops it itself, releasing the registration on the way out. The bind itself stays outside that lock, which is the one place I did not follow the review literally. Socket.listenLoopback is a platform call and the connection callback takes the same lock, so holding it across the bind invites a deadlock. Checking "did close() win?" immediately afterwards gives the same invariant -- no orphaned listener, no stranded registration -- without that risk. All 43 MCP tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 0520124c448..78b5ac7b9fc 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -119,21 +119,24 @@ public void open() throws IOException { active = this; } // open() runs on the server's reader thread, so a stop() from another thread can - // already have closed this transport before we got here. close() clears the - // registration only if it is still ours, and at that point it was not ours yet -- - // so claiming it now would strand the process-wide slot on a transport that never - // listens, and every later open() would be refused with "already open on port N" - // for the lifetime of the process. Release it and fail instead. - boolean closedBeforeListening; - synchronized (lock) { - closedBeforeListening = closed; - } - if (closedBeforeListening) { - clearActiveIfOurs(); - throw new IOException("This MCP socket transport was closed before it began listening"); - } + // already have closed this transport, or close it while we are binding. Two + // things have to be true afterwards whichever order those land in: the + // process-wide registration must not stay claimed by a transport that never + // listens (every later open() would then be refused with "already open on port + // N" for the life of the process), and no listener may be left bound with + // nobody holding a reference to stop it -- an orphan keeps the port and its + // connection callbacks still consult `active`, so it could hand a connection to + // a later transport. + // + // Publishing `listening` under the same lock close() uses is what makes that + // decidable. The bind itself stays outside the lock -- Socket.listenLoopback is + // a platform call and holding a lock the connection callback also takes across + // it invites a deadlock -- so the check is "did close() win?" immediately after, + // and the loser cleans up. Either close() sees a published listener and stops + // it, or we see closed and stop it ourselves. + Socket.StopListening bound; try { - listening = Socket.listenLoopback(port, Connection.class); + bound = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { // Two things go wrong if this escapes. The process-wide registration would stay // pointing at a transport that never started listening, so every later open() @@ -146,6 +149,20 @@ public void open() throws IOException { failure.initCause(ex); throw failure; } + boolean closedWhileBinding; + synchronized (lock) { + closedWhileBinding = closed; + if (!closedWhileBinding) { + listening = bound; + } + } + if (closedWhileBinding) { + clearActiveIfOurs(); + if (bound != null) { + bound.stop(); + } + throw new IOException("This MCP socket transport was closed before it began listening"); + } } /// Releases the process-wide registration, but only when it is still this transport's. From fb754a9a7ba1a00a5ca993397f93e33e66f6eba0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:59:11 +0700 Subject: [PATCH 33/48] Remove the in-process wedge watchdog that deadlocked the Linux suite Base64NativePerformanceTest passes on master and hangs on this branch, and the watchdog I added is why. A test that blocks the event dispatch thread in a tight compute loop also stops the collector. The watchdog's log call allocates, to build its message, so it parks waiting for a collection that cannot happen until the EDT yields -- which is why it reported nothing at all across a 47 minute wedge, the one job it existed to do. Worse than useless: a second thread merely asking to allocate during that test's benchmark loops is enough to deadlock the pair, and the suite stopped dead on the most allocation-heavy test in it. Everything after it was then published as never run, which is the masking the previous commits were opened to remove -- caused by the diagnostic meant to expose it. A watchdog living inside the wedged process was the wrong design. It cannot allocate, cannot log, and can only add a thread to the deadlock it is watching for. The harness already reads the suite's output from outside the process and now names the last announced test from there, which works whatever state the app is in and cannot perturb it. So the watchdog is gone: the thread, the two fields it polled, and the per-test bookkeeping that fed it. Cn1ssDeviceRunner's synthetic lambda set is byte identical to master's again, which also removes for good the id-renumbering hazard that broke the JavaScript port earlier in this branch. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 95 +++---------------- 1 file changed, 15 insertions(+), 80 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 9c2a546a782..bdd89876bbe 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -491,86 +491,25 @@ public void runSuite() { logThrowable("EDT", (Throwable)e.getSource()); }); }); - startWedgeWatchdog(); runNextTest(0); } - /// Name of the test the event dispatch thread is inside, and the wall clock - /// at which it stops being plausible that it is still working. Written on - /// the EDT, read by the watchdog thread. - private volatile String activeTestName; - private volatile long activeTestDeadline; - - /// Grace on top of a test's own timeout before the watchdog concludes the - /// EDT is not coming back. The per-test timeout is itself enforced by an - /// EDT callback, so a test that blocks the thread outright can never be - /// timed out by it -- the suite simply stops, and every remaining test is - /// published as "never run" with nothing saying why. - private static final long WEDGE_GRACE_MS = 30000L; - - /// The watchdog body. + /// Which test wedged the suite is reported by the capture harness, not from + /// in here. /// - /// This is a named class and MUST NOT be rewritten as a lambda. The - /// JavaScript port hand-binds three of this class's Runnable lambdas by - /// translated id -- Cn1ssDeviceRunner_lambda_1_run through _3_run, see - /// bindCiFallback in port.js -- and the translator numbers lambdas in their - /// declaration order within the class. A lambda declared here, ahead of the - /// ones in runNextTest, shifts every one of those ids by one, so the bridge - /// that polls for a test's completion gets handed the lambda that starts a - /// test instead. The ids still resolve, so nothing reports an error: the - /// suite simply stops advancing after its first test. A named class has its - /// own method namespace and leaves that numbering alone. - private final class WedgeWatchdog implements Runnable { - public void run() { - while (!suiteFinished) { - try { - Thread.sleep(1000L); - } catch (InterruptedException interrupted) { - return; - } - String name = activeTestName; - long deadline = activeTestDeadline; - if (name == null || deadline <= 0L) { - continue; - } - long overrun = System.currentTimeMillis() - (deadline + WEDGE_GRACE_MS); - if (overrun < 0L) { - continue; - } - // Report against the test rather than the suite: this is the - // one line that says which test stopped the run. - log("CN1SS:ERR:suite test=" + name + " failed: the event dispatch thread has not" - + " returned from this test " + overrun + "ms past its deadline; the suite" - + " cannot continue and every later test is unreached"); - log("CN1SS:SUITE:WEDGED test=" + name); - // Nothing here can end the process: exitApplication would have - // to run on the very thread that is stuck, and the raw exit - // calls are not part of the API the ports support. The marker - // above is the contract instead -- the capture harness watches - // for it and stops the run, having been told which test to - // blame. - return; - } - } - } - - private void startWedgeWatchdog() { - // Not on HTML5. That port drives the suite from the browser's single - // thread through the bridges described on WedgeWatchdog, and its harness - // already bounds the run and force-advances a stalled dispatch. The - // wedges this watchdog exists to name are on the native desktop ports. - if ("HTML5".equals(Display.getInstance().getPlatformName())) { - return; - } - // Display.startThread rather than a bare Thread: the ports only support - // the thread surface the bytecode compliance gate allows, and this hands - // back a CodenameOneThread that the platform names and reaps for us. - Display.getInstance().startThread(new WedgeWatchdog(), "cn1ss-wedge-watchdog").start(); - } - - /// Set once the suite is over so the watchdog thread returns instead of - /// outliving the run. - private volatile boolean suiteFinished; + /// This used to be an in-process watchdog thread. It could not do the job and + /// it caused a worse one. A test that blocks the event dispatch thread in a + /// tight compute loop also stops the collector, so the watchdog's own log + /// call -- which allocates, to build its message -- parks waiting for a + /// collection that cannot happen until the EDT yields. It reported nothing + /// across a 47 minute wedge for exactly that reason. Worse, merely asking to + /// allocate from a second thread during Base64NativePerformanceTest's + /// benchmark loops was enough to deadlock the pair, and the Linux suite -- + /// which completes on master -- stopped dead on that test. + /// + /// The harness reads the suite's output from outside the process, so it can + /// name the last announced test whatever state the app is in, and cannot + /// perturb it. See lastStarted in CleanTargetLinuxIntegrationTest. private void runNextTest(int index) { int offset = prependedTest != null ? 1 : 0; @@ -607,8 +546,6 @@ private void runNextTest(int index) { CN.callSerially(() -> { Cn1ssDeviceRunnerHelper.clearTransportFailure(); log("CN1SS:INFO:suite starting test=" + testName); - activeTestName = testName; - activeTestDeadline = System.currentTimeMillis() + testTimeoutMs(testClass); try { testClass.prepare(); testClass.runTest(); @@ -646,7 +583,6 @@ private void awaitTestCompletion(int index, BaseTest testClass, String testName, } private void finalizeTest(int index, BaseTest testClass, String testName, boolean timedOut) { - activeTestName = null; final Runnable continueToNext = () -> { log("CN1SS:INFO:suite finished test=" + testName); runNextTest(index + 1); @@ -756,7 +692,6 @@ private void finishSuite() { } log("CN1SS:INFO:swift_diag_status=" + status); } finally { - suiteFinished = true; log("CN1SS:SUITE:FINISHED"); } try { From 9ce66958a150ada920efccddca1aa12204e9dbf8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:28:34 +0700 Subject: [PATCH 34/48] Standardise OAEP on SHA-256 for both halves, the only pairing every port can produce The five-port alignment in the previous commit was aimed at the wrong target: I surveyed five backends and missed the one with no freedom of choice. Web Crypto's RSA-OAEP takes a single hash and uses it for the label and the mask alike, and SubtleCrypto exposes no raw RSA primitive to hand-pad around, so the JCE reading of "OAEPWithSHA-256AndMGF1Padding" -- SHA-256 label, SHA-1 mask -- is not implementable on the JavaScript port at all. Apple's SecKey has the same shape. Any target that keeps the split pairing leaves JavaScript permanently unable to exchange ciphertext with the rest. SHA-256 for both is the only pairing all six ports can produce, so that is what the portable constant now means, said plainly on Cipher.RSA_OAEP_SHA256 rather than inherited from a provider default: - JavaSE and Android pass an explicit OAEPParameterSpec instead of relying on the JCE default. - Linux moves MGF1 to SHA-256; Windows passes the label digest as the mask digest. - iOS goes back to plain SecKey OAEPSHA256, which deletes the manual padding I added last round -- about 130 lines of hand-rolled OAEP, and with it the class of buffer bug the review had just caught in it. - JavaScript is unchanged, because it was the constraint. Verified against OpenSSL on the new pairing: our padding accepted by RSA_padding_check_PKCS1_OAEP_mgf1 with SHA-256/SHA-256 and its padding accepted by ours, at 2048, 3072 and 4096 bits, plus the tamper and DER strictness cases. Cross-port interop is the property that was broken, so it is the one the harness asserts. Also here: iOS getOffset had the same local-standard-time bug already fixed on Windows. It built the NSDate in UTC, so around a transition the calendar kept the previous offset for the zone's whole raw-offset span -- America/New_York reporting EST for the first five hours of EDT. It now subtracts the raw offset before asking. The Linux harness dumps every thread's stack from the live process when it gives up waiting. The workflow's post-mortem only runs on a core file, so a hang -- as opposed to the crash that comment describes -- has produced no evidence at all so far. This is what should finally locate the Base64NativePerformanceTest stall. A redundant null check SpotBugs rejected (RCN_REDUNDANT_NULLCHECK) in the MCP transport. Local `verify` is the gate, not `test`; I had run the latter after the last two changes, which is how a one-line style finding reached CI. core-unittests: 4678 tests, no failures, SpotBugs zero findings. Windows cross-compile links. JavaSE port builds. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 6 +- .../src/com/codename1/security/Cipher.java | 12 ++ .../impl/android/AndroidImplementation.java | 26 ++- .../com/codename1/impl/javase/JavaSEPort.java | 26 ++- .../nativeSources/cn1_linux_crypto.c | 14 +- .../nativeSources/cn1_windows_crypto.c | 17 +- Ports/iOSPort/nativeSources/CN1Crypto.m | 199 +----------------- Ports/iOSPort/nativeSources/IOSNative.m | 23 +- .../CleanTargetLinuxIntegrationTest.java | 47 +++++ 9 files changed, 147 insertions(+), 223 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 78b5ac7b9fc..e09425778ff 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -157,10 +157,10 @@ public void open() throws IOException { } } if (closedWhileBinding) { + // No null check on `bound`: the only way past the try above is with a + // listener in hand, and SpotBugs flags the redundant test. clearActiveIfOurs(); - if (bound != null) { - bound.stop(); - } + bound.stop(); throw new IOException("This MCP socket transport was closed before it began listening"); } } diff --git a/CodenameOne/src/com/codename1/security/Cipher.java b/CodenameOne/src/com/codename1/security/Cipher.java index efb7ca214c2..72e7bd4ba3f 100644 --- a/CodenameOne/src/com/codename1/security/Cipher.java +++ b/CodenameOne/src/com/codename1/security/Cipher.java @@ -71,6 +71,18 @@ public final class Cipher { /// `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` -- recommended RSA encryption /// transformation. + /// + /// SHA-256 is used for the label hash **and** for MGF1. That is worth stating + /// because the JCE reading of this name leaves MGF1 on SHA-1, and this API + /// deliberately does not: Web Crypto's `RSA-OAEP` takes a single hash and + /// applies it to both, as does Apple's SecKey, so the split pairing is not + /// expressible on the JavaScript or iOS ports at all. SHA-256 for both is the + /// only choice every port can produce, so it is the one that lets ciphertext + /// cross between them. The JavaSE and Android ports pass an explicit + /// `OAEPParameterSpec` rather than inheriting their provider's default. + /// + /// Interoperating with an external system that uses the JCE default (SHA-1 + /// MGF1) therefore needs that system to name SHA-256 for the mask as well. public static final String RSA_OAEP_SHA256 = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; /// `RSA/ECB/PKCS1Padding` -- legacy RSA padding, kept for interop. diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 63702181cb5..191ae79a48b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13939,13 +13939,31 @@ private static byte[] androidAes(String transformation, byte[] key, byte[] iv, b } } + /// The OAEP parameters every port agrees on. + /// + /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on + /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's + /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's + /// SecKey. Naming SHA-256 for both is the only pairing all six ports can + /// produce, so it is what the portable constant means -- stated explicitly + /// rather than inherited from a provider default. + private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { + return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA256, + javax.crypto.spec.PSource.PSpecified.DEFAULT); + } + @Override public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { try { javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + } return cipher.doFinal(plaintext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); @@ -13958,7 +13976,11 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + } return cipher.doFinal(ciphertext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 7353168fe4a..90ab635faaa 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -19768,13 +19768,31 @@ private static byte[] javaseAes(String transformation, byte[] key, byte[] iv, by } } + /// The OAEP parameters every port agrees on. + /// + /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on + /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's + /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's + /// SecKey. Naming SHA-256 for both is the only pairing all six ports can + /// produce, so it is what the portable constant means -- stated explicitly + /// rather than inherited from a provider default. + private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { + return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA256, + javax.crypto.spec.PSource.PSpecified.DEFAULT); + } + @Override public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { try { javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + } return cipher.doFinal(plaintext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); @@ -19787,7 +19805,11 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + } return cipher.doFinal(ciphertext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 4b989a260b0..942fc457c84 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -304,14 +304,16 @@ static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { } if (strstr(transformation, "OAEP") != 0) { const EVP_MD* md = EVP_sha256(); - // The mask function stays on SHA-1 even when the OAEP digest is - // SHA-256. That is what the JCE providers behind the JavaSE and - // Android ports do for this transformation name, and ciphertext has to - // stay readable across ports; naming the digest for both halves would - // make anything sealed here undecryptable there. + // SHA-256 for the label hash AND the mask. The JCE transformation name + // leaves MGF1 on SHA-1 by default, but no other backend in this project + // can reproduce that: Web Crypto's RSA-OAEP takes a single hash and uses + // it for both, and so does Apple's SecKey. SHA-256 for both is the only + // pairing every port can produce, so it is the one the portable constant + // means -- the JavaSE and Android ports name it explicitly rather than + // inheriting their provider's default. if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 || - EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, EVP_sha1()) <= 0) { + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) { return 0; } return 1; diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 761d6d58fca..95d6fa58f6c 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -462,13 +462,12 @@ static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, * * Two shapes CNG cannot produce on its own: * - * OAEP -- BCRYPT_OAEP_PADDING_INFO carries one digest, which CNG uses for both - * the label hash and the mask function. The JCE providers behind the JavaSE and - * Android ports pair a SHA-256 label hash with a SHA-1 mask for - * "OAEPWithSHA-256AndMGF1Padding", and the Linux port matches them, so - * ciphertext has to use that pairing to stay readable across ports. Naming one - * digest for both halves either weakens the label hash or breaks interop, so - * the padding is built here and the key operation runs unpadded. + * OAEP -- the padding is built here rather than handed to CNG so the block can + * be validated and reported on our own terms (see cn1OaepDecode's single + * generic failure). Both halves use SHA-256: the JCE transformation name + * leaves MGF1 on SHA-1 by default, but Web Crypto and Apple's SecKey each take + * one hash and use it for label and mask alike, so SHA-256 for both is the only + * pairing every port in this project can produce. * * ECDSA -- NCryptSignHash answers the fixed-width r||s of P1363, while the * portable Signature contract (and Jwt.derToJoseEcdsa) expects ASN.1 DER, so @@ -847,7 +846,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String goto done; } if (encrypt) { - if (!cn1OaepEncode(labelDigest, BCRYPT_SHA1_ALGORITHM, data, dataLength, block, + if (!cn1OaepEncode(labelDigest, labelDigest, data, dataLength, block, (int) modulusBytes)) { goto done; } @@ -875,7 +874,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String cn1CryptoFail("out of memory", 0); goto done; } - if (!cn1OaepDecode(labelDigest, BCRYPT_SHA1_ALGORITHM, block, (int) modulusBytes, + if (!cn1OaepDecode(labelDigest, labelDigest, block, (int) modulusBytes, out, &messageLength)) { goto done; } diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index 610dd94f6ce..caab266cba5 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -284,144 +284,11 @@ static int cn1_seckey_op(SecKeyRef key, SecKeyAlgorithm alg, int forEncrypt, return (int) len; } -/* - * OAEP, built here rather than taken from SecKey. - * - * kSecKeyAlgorithmRSAEncryptionOAEPSHA256 uses SHA-256 for the label hash AND - * for MGF1. The transformation this port advertises is the JCE name - * "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which in JCE means a SHA-256 label - * with a SHA-1 mask -- that is what java.crypto gives the JavaSE and Android - * ports for the same string, and what the Linux and Windows ports produce. iOS - * was the only port pairing SHA-256 with SHA-256, so ciphertext never crossed - * between it and any other port. SecKey cannot express the JCE pairing, so the - * padding is built here and the key operation runs raw. - */ - -static void cn1_mgf1_sha1(const uint8_t* seed, int seedLen, uint8_t* mask, int maskLen) { - uint8_t counter[4]; - uint8_t digest[CC_SHA1_DIGEST_LENGTH]; - int produced = 0; - uint32_t count = 0; - while (produced < maskLen) { - int chunk = maskLen - produced; - CC_SHA1_CTX ctx; - counter[0] = (uint8_t) ((count >> 24) & 0xff); - counter[1] = (uint8_t) ((count >> 16) & 0xff); - counter[2] = (uint8_t) ((count >> 8) & 0xff); - counter[3] = (uint8_t) (count & 0xff); - CC_SHA1_Init(&ctx); - CC_SHA1_Update(&ctx, seed, (CC_LONG) seedLen); - CC_SHA1_Update(&ctx, counter, 4); - CC_SHA1_Final(digest, &ctx); - if (chunk > CC_SHA1_DIGEST_LENGTH) { - chunk = CC_SHA1_DIGEST_LENGTH; - } - memcpy(mask + produced, digest, (size_t) chunk); - produced += chunk; - count++; - } -} - -/* All ones when a == b, zero otherwise, without branching on the values. */ -static uint32_t cn1_ct_eq_mask(uint32_t a, uint32_t b) { - uint32_t diff = a ^ b; - uint32_t nonZero = (diff | (0u - diff)) >> 31; - return nonZero - 1u; -} - -static int cn1_oaep_encode(const uint8_t* message, int messageLen, - uint8_t* block, int blockLen) { - const int hashLen = CC_SHA256_DIGEST_LENGTH; - int dbLen = blockLen - hashLen - 1; - uint8_t seed[CC_SHA256_DIGEST_LENGTH]; - uint8_t* mask; - int i; - /* An OAEP block cannot be shorter than 2*hLen+2; a 512-bit key leaves a DB - * shorter than the label hash, and the label hash would then be written and - * compared past the end of the block. */ - if (blockLen < 2 * hashLen + 2 || messageLen > dbLen - hashLen - 1) { - return 0; - } - mask = (uint8_t*) malloc((size_t) dbLen); - if (!mask) { - return 0; - } - memset(block, 0, (size_t) blockLen); - CC_SHA256("", 0, block + 1 + hashLen); - block[blockLen - messageLen - 1] = 0x01; - if (messageLen > 0) { - memcpy(block + blockLen - messageLen, message, (size_t) messageLen); - } - if (CCRandomGenerateBytes(seed, (size_t) hashLen) != kCCSuccess) { - free(mask); - return 0; - } - cn1_mgf1_sha1(seed, hashLen, mask, dbLen); - for (i = 0; i < dbLen; i++) { - block[1 + hashLen + i] ^= mask[i]; - } - cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); - for (i = 0; i < hashLen; i++) { - block[1 + i] = (uint8_t) (seed[i] ^ mask[i]); - } - free(mask); - return 1; -} - -/* Every check folds into one accumulator and one generic failure is reported: - * telling a leading-byte error from a label-hash error is enough to mount the - * adaptive attacks OAEP exists to prevent. */ -static int cn1_oaep_decode(uint8_t* block, int blockLen, uint8_t* message, int* messageLen) { - const int hashLen = CC_SHA256_DIGEST_LENGTH; - int dbLen = blockLen - hashLen - 1; - uint8_t labelHash[CC_SHA256_DIGEST_LENGTH]; - uint8_t seed[CC_SHA256_DIGEST_LENGTH]; - uint8_t* mask; - int i; - uint32_t bad = 0; - uint32_t seenDelimiter = 0; - uint32_t messageStart = 0; - if (blockLen < 2 * hashLen + 2) { - return 0; - } - mask = (uint8_t*) malloc((size_t) dbLen); - if (!mask) { - return 0; - } - bad |= (uint32_t) block[0]; - cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); - for (i = 0; i < hashLen; i++) { - seed[i] = (uint8_t) (block[1 + i] ^ mask[i]); - } - cn1_mgf1_sha1(seed, hashLen, mask, dbLen); - for (i = 0; i < dbLen; i++) { - block[1 + hashLen + i] ^= mask[i]; - } - free(mask); - CC_SHA256("", 0, labelHash); - for (i = 0; i < hashLen; i++) { - bad |= (uint32_t) (labelHash[i] ^ block[1 + hashLen + i]); - } - for (i = 1 + hashLen + hashLen; i < blockLen; i++) { - uint32_t value = block[i]; - uint32_t isDelimiter = cn1_ct_eq_mask(value, 0x01); - uint32_t isZero = cn1_ct_eq_mask(value, 0x00); - uint32_t firstDelimiter = isDelimiter & ~seenDelimiter; - messageStart |= ((uint32_t) (i + 1)) & firstDelimiter; - bad |= ~seenDelimiter & ~isDelimiter & ~isZero; - seenDelimiter |= isDelimiter; - } - bad |= ~seenDelimiter; - if (bad != 0) { - return 0; - } - *messageLen = blockLen - (int) messageStart; - if (*messageLen > 0) { - memcpy(message, block + messageStart, (size_t) *messageLen); - } - return 1; -} - +/* OAEPSHA256 uses SHA-256 for the label hash and for MGF1 alike, which is the + * pairing the portable RSA_OAEP_SHA256 constant means. The JCE transformation + * name it borrows leaves MGF1 on SHA-1 by default, but neither SecKey nor Web + * Crypto can express that split, so the JavaSE and Android ports name SHA-256 + * for both explicitly and every port agrees. */ static SecKeyAlgorithm rsa_padding_alg(int paddingKind) { return paddingKind == 2 ? kSecKeyAlgorithmRSAEncryptionOAEPSHA256 @@ -434,26 +301,6 @@ int cn1_crypto_rsa_encrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_public(x509, x509Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; - if (paddingKind == 2) { - /* Pad here and run the key raw, so the mask stays SHA-1 (see the OAEP - * note above). */ - int blockLen = (int) SecKeyGetBlockSize(key); - uint8_t* block = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); - int rc; - if (!block) { - CFRelease(key); - return CN1_CRYPTO_E_GENERIC; - } - if (!cn1_oaep_encode(in, inLen, block, blockLen)) { - free(block); - CFRelease(key); - return CN1_CRYPTO_E_BAD_INPUT; - } - rc = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 1, block, blockLen, out, outCap); - free(block); - CFRelease(key); - return rc; - } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 1, in, inLen, out, outCap); CFRelease(key); return rc; @@ -465,42 +312,6 @@ int cn1_crypto_rsa_decrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_private(pkcs8, pkcs8Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; - if (paddingKind == 2) { - int blockLen = (int) SecKeyGetBlockSize(key); - uint8_t* raw = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); - uint8_t* block; - int rawLen, messageLen = 0, rc; - if (!raw) { - CFRelease(key); - return CN1_CRYPTO_E_GENERIC; - } - rawLen = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 0, in, inLen, raw, blockLen); - CFRelease(key); - if (rawLen < 0) { - free(raw); - return rawLen; - } - /* A raw result may arrive with its leading zero bytes dropped; the OAEP - * block is defined at exactly the modulus width, so restore them. */ - block = (uint8_t*) calloc((size_t) (blockLen > 0 ? blockLen : 1), 1); - if (!block) { - free(raw); - return CN1_CRYPTO_E_GENERIC; - } - if (rawLen > blockLen) { - free(raw); - free(block); - return CN1_CRYPTO_E_BAD_INPUT; - } - memcpy(block + (blockLen - rawLen), raw, (size_t) rawLen); - free(raw); - if (!cn1_oaep_decode(block, blockLen, out, &messageLen) || messageLen > outCap) { - free(block); - return CN1_CRYPTO_E_BAD_INPUT; - } - free(block); - return messageLen; - } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 0, in, inLen, out, outCap); CFRelease(key); return rc; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index fcfe7cd9ce7..0ed698a7cf6 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10020,15 +10020,24 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int [comps setHour:timeOfDayMillis/3600000]; [comps setMinute:(timeOfDayMillis/60000)%60]; [comps setSecond:(timeOfDayMillis/1000)%60]; - // The caller passes UTC fields -- the POSIX implementation of this native - // resolves them with timegm() -- so build the date in UTC too. Reading them - // in the device's own zone (currentCalendar) moved the instant by the - // device offset, which lands on the wrong side of a transition when the - // requested zone changes offset within that window. + // These fields are local STANDARD time, not UTC: GregorianCalendar adds the + // zone's raw offset to the epoch before calling getOffset. Building the date + // in UTC and asking about that instant is therefore off by the raw offset, + // which around a transition returns the previous offset for its whole span + // -- America/New_York keeps reporting EST for the first five hours of EDT. + // + // Reading them in the device's own zone is wrong for a different reason (it + // shifts by the device offset instead), so the calendar stays on UTC and the + // raw offset is subtracted explicitly. NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; - NSDate *date = [cal dateFromComponents:comps]; - JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000; + NSDate *nominal = [cal dateFromComponents:comps]; + NSInteger rawOffset = [tzone secondsFromGMTForDate:nominal]; + if ([tzone isDaylightSavingTimeForDate:nominal]) { + rawOffset -= (NSInteger)[tzone daylightSavingTimeOffsetForDate:nominal]; + } + NSDate *date = [nominal dateByAddingTimeInterval:-(NSTimeInterval)rawOffset]; + JAVA_INT result = (JAVA_INT)([tzone secondsFromGMTForDate:date] * 1000); [comps release]; POOL_END(); return result; diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index d6b8a8d1540..7a26b087128 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -465,6 +465,15 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { Thread.sleep(3000); } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); + if (!finished.get() && app.isAlive()) { + // The suite is still running and has stopped saying anything. The + // post-mortem in the workflow only fires on a core file, so a HANG -- + // as opposed to the crash that comment describes -- has so far produced + // no evidence at all. Attach to the live process and take every thread's + // stack before killing it; that is the difference between knowing which + // call is stuck and guessing at it. + dumpLiveThreadStacks(); + } if (!finished.get()) { String stoppedIn = lastStarted.get(); System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs @@ -580,4 +589,42 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException { s = s.substring(0, start) + body + s.substring(end); Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8)); } + /// Dumps every thread's native stack from the still-running suite process. + /// + /// Best effort by design: gdb may be absent and ptrace may be restricted, and + /// neither should turn a diagnostic into a second failure. Output goes next to + /// the app log so it is uploaded with the screenshot artifact. + private static void dumpLiveThreadStacks() { + try { + String teePath = System.getenv("CN1_APP_LOG_TEE"); + if (teePath == null) { + return; + } + Process pgrep = new ProcessBuilder("pgrep", "-f", "LinuxHelloMain") + .redirectErrorStream(true).start(); + String pid; + try (BufferedReader r = new BufferedReader( + new InputStreamReader(pgrep.getInputStream(), StandardCharsets.UTF_8))) { + pid = r.readLine(); + } + pgrep.waitFor(); + if (pid == null || pid.trim().isEmpty()) { + return; + } + java.io.File out = new java.io.File( + new java.io.File(teePath).getParentFile(), "hang-stacks.txt"); + Process gdb = new ProcessBuilder("gdb", "-p", pid.trim(), "-batch", + "-ex", "set pagination off", + "-ex", "thread apply all bt") + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(out)) + .start(); + gdb.waitFor(); + System.out.println("CN1SS:HARNESS: wrote live thread stacks for pid " + pid.trim() + + " to " + out); + } catch (Exception ignore) { + // A missing gdb or a denied ptrace must not mask the real failure. + } + } + } From 5d14fc44f43f26dad2952797be9ce01f08939eca Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:15:26 +0700 Subject: [PATCH 35/48] Put getTimezoneOffset back on UTC fields, and match the OAEP name exactly The timezone change I made last round broke TimeApiTest on iOS and mac: expected 2020-03-08T01:30:00-05:00, got 02:30:00-04:00 -- local 01:30 EST resolved as 02:30 EDT, jumping the spring transition. The review was right about java.util.TimeZone.getOffset's signature: those fields are local standard time. But this native does not implement that contract. Every port resolves them as UTC -- POSIX with timegm(), which the iOS comment already said, and the JavaScript and Android ports match -- and TimeApiTest pins it. I changed two ports to match a signature instead of the contract their callers rely on, and broke a test that was passing. Windows got the same change a round earlier, where nothing caught it; both are reverted, with the expected/actual recorded in the comment so the next reader does not make the same correction. OAEP transformation matching A substring test for "OAEP" answered every OAEP name with the SHA-256/SHA-256 parameters, including RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- ciphertext no compliant peer could read under the name it was asked for. JavaSE and Android now match Cipher.RSA_OAEP_SHA256 exactly and refuse anything outside the two supported RSA transformations, which is the rule the native ports already apply. Hang diagnostics The live gdb attach came back "Could not attach to process": Ubuntu ships yama ptrace_scope=1, which forbids attaching to a sibling, and the harness is a sibling of the suite. The workflow now lowers it alongside the core pattern it already sets, and the harness retries under sudo if the direct attach is still refused. Windows cross-compile links. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 6 ++++ .../impl/android/AndroidImplementation.java | 24 +++++++++++-- .../com/codename1/impl/javase/JavaSEPort.java | 24 +++++++++++-- Ports/iOSPort/nativeSources/IOSNative.m | 25 +++++-------- vm/ByteCodeTranslator/src/nativeMethods.m | 25 ++++++------- .../CleanTargetLinuxIntegrationTest.java | 35 +++++++++++++++---- 6 files changed, 97 insertions(+), 42 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index ce6e418b229..37a08137f53 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -250,6 +250,12 @@ jobs: # Enable core dumps and post-mortem them into the artifact. ulimit -c unlimited echo '/tmp/cn1-cores/core.%e.%p' | sudo tee /proc/sys/kernel/core_pattern >/dev/null + # Let the harness attach gdb to the still-running suite when it gives up + # waiting. Ubuntu ships yama ptrace_scope=1, which restricts attaching to + # descendants, and the harness is a sibling of the app -- so without this + # the hang dump comes back "Could not attach to process" and a hang (as + # opposed to a crash, which leaves a core) yields no evidence at all. + echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope >/dev/null 2>&1 || true mkdir -p /tmp/cn1-cores rc=0 mvn -B clean package -pl JavaAPI -am -DskipTests diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 191ae79a48b..cf667b6f536 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13939,6 +13939,24 @@ private static byte[] androidAes(String transformation, byte[] key, byte[] iv, b } } + /// The RSA transformations this port implements, matched exactly. + /// + /// A substring test for "OAEP" would answer every OAEP name -- including + /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, + /// producing ciphertext no standards-compliant peer could read under the name + /// it asked for. The native ports already accept only these two, so refusing + /// anything else here keeps every port answering the same question. + private static boolean cn1IsOaepTransformation(String transformation) { + return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); + } + + private static void cn1CheckRsaTransformation(String transformation) { + if (!cn1IsOaepTransformation(transformation) + && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { + throw new RuntimeException("unsupported cipher transformation: " + transformation); + } + } + /// The OAEP parameters every port agrees on. /// /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on @@ -13959,7 +13977,8 @@ public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] pla javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); @@ -13976,7 +13995,8 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 90ab635faaa..92dc66fce31 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -19768,6 +19768,24 @@ private static byte[] javaseAes(String transformation, byte[] key, byte[] iv, by } } + /// The RSA transformations this port implements, matched exactly. + /// + /// A substring test for "OAEP" would answer every OAEP name -- including + /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, + /// producing ciphertext no standards-compliant peer could read under the name + /// it asked for. The native ports already accept only these two, so refusing + /// anything else here keeps every port answering the same question. + private static boolean cn1IsOaepTransformation(String transformation) { + return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); + } + + private static void cn1CheckRsaTransformation(String transformation) { + if (!cn1IsOaepTransformation(transformation) + && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { + throw new RuntimeException("unsupported cipher transformation: " + transformation); + } + } + /// The OAEP parameters every port agrees on. /// /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on @@ -19788,7 +19806,8 @@ public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] pla javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); @@ -19805,7 +19824,8 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 0ed698a7cf6..05daf0dbab2 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10020,24 +10020,17 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int [comps setHour:timeOfDayMillis/3600000]; [comps setMinute:(timeOfDayMillis/60000)%60]; [comps setSecond:(timeOfDayMillis/1000)%60]; - // These fields are local STANDARD time, not UTC: GregorianCalendar adds the - // zone's raw offset to the epoch before calling getOffset. Building the date - // in UTC and asking about that instant is therefore off by the raw offset, - // which around a transition returns the previous offset for its whole span - // -- America/New_York keeps reporting EST for the first five hours of EDT. - // - // Reading them in the device's own zone is wrong for a different reason (it - // shifts by the device offset instead), so the calendar stays on UTC and the - // raw offset is subtracted explicitly. + // The fields are UTC, not local standard time. That is this native's contract + // across every port -- the POSIX implementation resolves them with timegm() + // and the JavaScript and Android ports match -- and TimeApiTest pins it: + // asking about the local-standard instant instead resolves 2020-03-08T01:30 + // EST as 02:30 EDT, jumping the spring transition. Reading them in the + // device's own zone (currentCalendar) is wrong for a different reason: it + // moves the instant by the device offset. NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; - NSDate *nominal = [cal dateFromComponents:comps]; - NSInteger rawOffset = [tzone secondsFromGMTForDate:nominal]; - if ([tzone isDaylightSavingTimeForDate:nominal]) { - rawOffset -= (NSInteger)[tzone daylightSavingTimeOffsetForDate:nominal]; - } - NSDate *date = [nominal dateByAddingTimeInterval:-(NSTimeInterval)rawOffset]; - JAVA_INT result = (JAVA_INT)([tzone secondsFromGMTForDate:date] * 1000); + NSDate *date = [cal dateFromComponents:comps]; + JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000; [comps release]; POOL_END(); return result; diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 00e7377674b..62d667bf7cc 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2751,21 +2751,16 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int cn1_timezone_offset_ctx ctx; #ifdef _WIN32 { - /* These fields are local STANDARD time, which is what GregorianCalendar - * hands down (it adds the raw offset to the epoch before calling). The - * instant they name is therefore fields - rawOffset, not the fields read - * as UTC: asking about the wrong instant makes the calendar keep the old - * offset for roughly the zone's raw-offset span either side of a - * transition, so America/New_York entering DST reports EST for the first - * five hours of EDT. */ - int rawOffset = 0; - long long nominal = cn1WinUtcMillis(year, month, day, timeOfDayMillis); - if (cn1WinZoneOffsetMillis(buffer, nominal, 0, 0, &rawOffset)) { - int offset = 0; - if (cn1WinZoneOffsetMillis(buffer, nominal - (long long) rawOffset, - &offset, 0, 0)) { - return offset; - } + /* The fields are UTC, matching the POSIX path below (timegm) and every + * other port. Reading them as local standard time instead -- which is + * what java.util.TimeZone.getOffset's signature suggests -- moves the + * instant by the raw offset and jumps DST transitions: TimeApiTest + * resolves 2020-03-08T01:30 EST as 02:30 EDT. The contract this native + * actually has is the one its callers and that test rely on. */ + int offset = 0; + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), + &offset, 0, 0)) { + return offset; } } #endif diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 7a26b087128..c6c760d2621 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -589,6 +589,27 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException { s = s.substring(0, start) + body + s.substring(end); Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8)); } + private static int runGdbAttach(java.io.File out, String pid, boolean viaSudo) throws Exception { + java.util.List cmd = new java.util.ArrayList<>(); + if (viaSudo) { + cmd.add("sudo"); + cmd.add("-n"); + } + cmd.add("gdb"); + cmd.add("-p"); + cmd.add(pid); + cmd.add("-batch"); + cmd.add("-ex"); + cmd.add("set pagination off"); + cmd.add("-ex"); + cmd.add("thread apply all bt"); + Process gdb = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(out)) + .start(); + return gdb.waitFor(); + } + /// Dumps every thread's native stack from the still-running suite process. /// /// Best effort by design: gdb may be absent and ptrace may be restricted, and @@ -613,13 +634,13 @@ private static void dumpLiveThreadStacks() { } java.io.File out = new java.io.File( new java.io.File(teePath).getParentFile(), "hang-stacks.txt"); - Process gdb = new ProcessBuilder("gdb", "-p", pid.trim(), "-batch", - "-ex", "set pagination off", - "-ex", "thread apply all bt") - .redirectErrorStream(true) - .redirectOutput(ProcessBuilder.Redirect.appendTo(out)) - .start(); - gdb.waitFor(); + // Plain gdb first; if yama still refuses the attach, retry through sudo, + // which the runner allows passwordless. Either way a refusal must not + // become a second failure. + int rc = runGdbAttach(out, pid.trim(), false); + if (rc != 0) { + runGdbAttach(out, pid.trim(), true); + } System.out.println("CN1SS:HARNESS: wrote live thread stacks for pid " + pid.trim() + " to " + out); } catch (Exception ignore) { From ee689372f1548e6e6e39165f1d4b39b9ca972cd7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:20:34 +0700 Subject: [PATCH 36/48] Mark each stage of a test's dispatch so a dead suite says where it stopped The gdb attach works now that the workflow lowers ptrace_scope, and what it shows rules out every theory I had been working from: at the hang every thread is idle and the event dispatch thread is parked in mainEDTLoop's Object.wait -- the normal "nothing queued" state. The suite is not stuck inside the test and not spinning; the chain that schedules the next step simply stopped. That is hard to square with the dispatch, which catches Throwable and arms the completion poll unconditionally afterwards, so more inference from the same evidence is not going to converge. The log ends on "suite starting test=X" and says nothing about which call failed to come back. So each boundary is now named: prepared, ran, awaiting. One line per test, and the next run says whether prepare() returned, whether runTest() returned, and whether the poll was armed -- which is the fact I have been guessing at for three rounds. Cn1ssDeviceRunner's synthetic lambda set stays identical to master's. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/Cn1ssDeviceRunner.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index bdd89876bbe..74696b897a2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -546,15 +546,24 @@ private void runNextTest(int index) { CN.callSerially(() -> { Cn1ssDeviceRunnerHelper.clearTransportFailure(); log("CN1SS:INFO:suite starting test=" + testName); + // Stage markers. When a suite stops dead the log ends on the + // "starting" line and says nothing about which call did not come + // back -- prepare(), runTest(), or the poll that follows. Naming + // each boundary costs one line per test and turns "stopped in X" + // into "stopped inside X's runTest", which is the difference + // between reading a stack and guessing at one. try { testClass.prepare(); + log("CN1SS:INFO:stage=prepared test=" + testName); testClass.runTest(); + log("CN1SS:INFO:stage=ran test=" + testName); } catch (Throwable t) { log("CN1SS:ERR:suite test=" + testName + " failed=" + t); t.printStackTrace(); logThrowable("runTest:" + testName, t); testClass.fail(String.valueOf(t)); } + log("CN1SS:INFO:stage=awaiting test=" + testName); awaitTestCompletion(index, testClass, testName, System.currentTimeMillis() + testTimeoutMs(testClass)); }); } From 63a2a31ce329a39bbf57cae2cdff53a6393b2ac9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:16:41 +0700 Subject: [PATCH 37/48] Give the Linux suite the time its repaired tests now need The stage markers answered it, and the answer is that nothing is hanging. The cut-off point moves. It was Base64NativePerformanceTest; with the watchdog gone and the earlier tests faster it is now MutableImageReadbackTest, about test 165 of 170. And the marker that never arrives is stage=prepared, whose implementation in AbstractTest is an empty method -- it cannot block. The suite is simply still running when the harness's 40-minute cap expires and kills it, and the log ends wherever the app happened to be. It needs longer because this branch made the tests it repaired do real work instead of failing in milliseconds: CryptoApiTest generates an RSA-2048 key pair, AudioMixerApiTest mixes audio, SurfacesPublishTest rasterizes, BrowserComponentScreenshotTest starts WebKit. On master those threw almost immediately, which is most of why the old budget fit. Honest tests cost wall clock. The harness cap goes to 70 minutes and the job timeout to 130, which keeps the job bounded while leaving room for the five tests still queued when the axe fell. This also retires the "silently DIES mid-run" reading of these runs: with gdb able to attach, every thread is idle and the EDT is parked in mainEDTLoop's Object.wait -- the app is alive and being killed, not crashing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 2 +- .../CleanTargetLinuxIntegrationTest.java | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 37a08137f53..993b631d363 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -121,7 +121,7 @@ jobs: name: build + run suite (${{ matrix.arch }}) needs: prepare-suite runs-on: ${{ matrix.runner }} - timeout-minutes: 90 + timeout-minutes: 130 strategy: fail-fast: false matrix: diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index c6c760d2621..fc3e2bda1fc 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -420,7 +420,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // Finish once the bulk of screenshots have landed and none has arrived // for a stabilization window (the trailing non-rendering API tests burn - // their per-test timeout after the last image). 40-minute hard cap. + // their per-test timeout after the last image). 70-minute hard cap. int minPngs = 100; // The stability window must outlast the suite's longest legitimate // no-new-screenshot stretch: the ~30 non-rendering API tests between @@ -430,9 +430,22 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // (the suite was force-killed mid-run, gate rc=17). Healthy runs // never wait this out: SUITE:FINISHED breaks the loop first. Only a // genuinely wedged suite pays the longer window, bounded by the - // 40-minute hard cap either way. + // 70-minute hard cap either way. long stableMs = 300_000L; - long deadline = System.currentTimeMillis() + 40L * 60 * 1000; + // 70 minutes, not 40. The suite is not hanging: the stage markers show + // the cut-off point moving forward as earlier tests get faster (it was + // Base64NativePerformanceTest, now MutableImageReadbackTest, ~165 of + // 170), and prepare() is an empty method that cannot block. The app is + // simply still running when the harness gives up and kills it. + // + // It needs the room because the tests this branch repaired now do real + // work instead of failing in milliseconds -- CryptoApiTest generates an + // RSA-2048 key pair, AudioMixerApiTest mixes actual audio, + // SurfacesPublishTest rasterizes, BrowserComponentScreenshotTest starts + // WebKit. That is the suite getting more honest, not slower for no + // reason, and the budget has to cover it. The job timeout above bounds + // this in turn. + long deadline = System.currentTimeMillis() + 70L * 60 * 1000; // Screenshot stabilization is a weak completion signal: DesktopMode, // the VideoIO grid, the VR scene and the 360 panorama all capture // AFTER the non-rendering API tail, so a slow tail trips the window From f53b1bdb3beab214c1a456d751f895a5d9d7debe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:39:53 +0700 Subject: [PATCH 38/48] Line-buffer the generated main's stdout so a killed run's log is not stale Every "the Linux suite hangs in X" reading in this branch has been wrong, and this is why. C streams block-buffer when they are not a tty, so an app logging into a pipe -- which is how CI captures it -- emits in 4KB chunks. When the harness kills a run mid-flight the captured log ends wherever the last chunk happened to flush, thousands of lines behind the process. Every diagnosis made from that tail named the wrong place: Base64NativePerformanceTest, then MutableImageReadbackTest, and the "missing" marker in the latest run is stage=prepared, whose implementation is an empty method that cannot block. The generated main now sets _IOLBF on stdout and stderr, so the log says where the process actually is. One flush per line, in return for diagnostics that mean what they say. This also revises the previous commit's conclusion: raising the budget to 70 minutes did not move the stopping point, so the suite is not merely slow. What it is doing will only be answerable from a log that is not lagging, which is what this provides. Verified through the clean target: the generated main compiles with the setvbuf calls and the probe still passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/tools/translator/ByteCodeClass.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 22ebf24476b..7b5d6ef8c7e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1146,7 +1146,19 @@ public String generateCCode(List allClasses) { clInitMethod = clsName + "_" + m.getMethodName() + "__"; } if(m.isMain()) { - b.append("\nint main(int argc, char *argv[]) {\n initConstantPool();\n"); + b.append("\nint main(int argc, char *argv[]) {\n"); + // Line-buffer stdout/stderr. C streams block-buffer when they are + // not a tty, so everything an app logs into a pipe -- which is + // how CI captures it -- arrives in 4KB chunks. A run that is + // killed mid-flight then shows a log ending thousands of lines + // behind where the process actually was, and every diagnosis + // made from that tail names the wrong place. Three separate + // "the suite hangs in X" readings of the Linux job came from + // exactly this. Costs a flush per line; buys logs that mean + // what they say. + b.append(" setvbuf(stdout, NULL, _IOLBF, 0);\n"); + b.append(" setvbuf(stderr, NULL, _IOLBF, 0);\n"); + b.append(" initConstantPool();\n"); // With the nursery, the main thread allocates and must cooperate with // the concurrent GC's stop-the-world pause (so the GC never scans its // nursery while a minor collection runs). Lightweight threads are the From 7d0ad8cf43ba09733aad6eb55458cb75eb2e18fd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:41:14 +0700 Subject: [PATCH 39/48] Ask Gradle for the stack when Android packaging fails Build Android has failed intermittently across JDK legs with nothing but "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable" and no cause, and it passes on a re-run, so there has been nothing to act on. --stacktrace costs nothing on a successful build and prints the actual exception the next time it happens. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/build-android-app.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 3dda2eb77d6..2e95564ee49 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -192,7 +192,11 @@ export JAVA_HOME="${JDK_HOME:-$JAVA17_HOME}" yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0" >/dev/null 2>&1 || ba_log "Warning: unable to install Android SDK 36 components" yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true fi - ./gradlew --no-daemon assembleDebug + # --stacktrace: packageDebug has failed intermittently on CI reporting only + # "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable" + # with no cause, which is not enough to fix anything. The flag costs nothing on + # a successful build and prints the actual exception when it does happen. + ./gradlew --no-daemon --stacktrace assembleDebug ) export JAVA_HOME="$ORIGINAL_JAVA_HOME" From 39fa46bad2e17547ef70eca994af100da390dac0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:40:57 +0700 Subject: [PATCH 40/48] Use _IONBF: the MSVC CRT fail-fasts on a line-buffered request My previous commit made every Windows clean-target binary die on its first instruction. Exit code -1073740791 is 0xC0000409, the MSVC fail-fast: the CRT rejects setvbuf(stream, NULL, _IOLBF, 0) because a buffered mode demands a size of at least 2, and it answers an invalid parameter by killing the process rather than returning non-zero. That took clean-target on both architectures, the Windows capture and the cross-built exe run. _IONBF ignores the size argument, is valid on every CRT, and is what the diagnostics actually want -- unbuffered rather than merely line-buffered. Verified this time against the real thing rather than assuming: the exact two calls compile and link into a PE with clang-cl against the xwin MSVC CRT headers, and the clean target still builds and runs through the probe on this machine. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/tools/translator/ByteCodeClass.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 7b5d6ef8c7e..629dfd97fd0 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1156,8 +1156,15 @@ public String generateCCode(List allClasses) { // "the suite hangs in X" readings of the Linux job came from // exactly this. Costs a flush per line; buys logs that mean // what they say. - b.append(" setvbuf(stdout, NULL, _IOLBF, 0);\n"); - b.append(" setvbuf(stderr, NULL, _IOLBF, 0);\n"); + // _IONBF, not _IOLBF: the MSVC CRT rejects a line-buffered + // request with a NULL buffer and size 0 -- it demands a size of + // at least 2 -- and answers the invalid parameter by fail-fasting + // the process (0xC0000409), so every Windows clean-target binary + // died on its first instruction. _IONBF ignores the size argument + // and is valid on every CRT, and unbuffered is what the + // diagnostics actually want. + b.append(" setvbuf(stdout, NULL, _IONBF, 0);\n"); + b.append(" setvbuf(stderr, NULL, _IONBF, 0);\n"); b.append(" initConstantPool();\n"); // With the nursery, the main thread allocates and must cooperate with // the concurrent GC's stop-the-world pause (so the GC never scans its From a70e121b0231e939dbe4358ec845ed1bbe280174 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:05:49 +0700 Subject: [PATCH 41/48] Revert the timeout inflation; my own evidence had already disproved it I raised the harness cap from 40 to 70 minutes and the job timeout from 90 to 130 on the theory that the Linux suite was merely slow. The very next run used 78 minutes and stopped at the same test, which disproved it. I should have reverted then instead of leaving a 130-minute timeout in the workflow. Both are back to 40 and 90. A longer timeout does not fix anything here, it just makes every future run of this job slower to fail and hides how long the suite really takes. The suite not finishing is still unexplained. What changed for real is that the generated main is now unbuffered, so the next Linux log will show where the process actually is rather than a stale chunk -- which is the evidence the budget theory was invented in the absence of. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 2 +- .../CleanTargetLinuxIntegrationTest.java | 19 +++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 993b631d363..37a08137f53 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -121,7 +121,7 @@ jobs: name: build + run suite (${{ matrix.arch }}) needs: prepare-suite runs-on: ${{ matrix.runner }} - timeout-minutes: 130 + timeout-minutes: 90 strategy: fail-fast: false matrix: diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index fc3e2bda1fc..c6c760d2621 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -420,7 +420,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // Finish once the bulk of screenshots have landed and none has arrived // for a stabilization window (the trailing non-rendering API tests burn - // their per-test timeout after the last image). 70-minute hard cap. + // their per-test timeout after the last image). 40-minute hard cap. int minPngs = 100; // The stability window must outlast the suite's longest legitimate // no-new-screenshot stretch: the ~30 non-rendering API tests between @@ -430,22 +430,9 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // (the suite was force-killed mid-run, gate rc=17). Healthy runs // never wait this out: SUITE:FINISHED breaks the loop first. Only a // genuinely wedged suite pays the longer window, bounded by the - // 70-minute hard cap either way. + // 40-minute hard cap either way. long stableMs = 300_000L; - // 70 minutes, not 40. The suite is not hanging: the stage markers show - // the cut-off point moving forward as earlier tests get faster (it was - // Base64NativePerformanceTest, now MutableImageReadbackTest, ~165 of - // 170), and prepare() is an empty method that cannot block. The app is - // simply still running when the harness gives up and kills it. - // - // It needs the room because the tests this branch repaired now do real - // work instead of failing in milliseconds -- CryptoApiTest generates an - // RSA-2048 key pair, AudioMixerApiTest mixes actual audio, - // SurfacesPublishTest rasterizes, BrowserComponentScreenshotTest starts - // WebKit. That is the suite getting more honest, not slower for no - // reason, and the budget has to cover it. The job timeout above bounds - // this in turn. - long deadline = System.currentTimeMillis() + 70L * 60 * 1000; + long deadline = System.currentTimeMillis() + 40L * 60 * 1000; // Screenshot stabilization is a weak completion signal: DesktopMode, // the VideoIO grid, the VR scene and the 360 panorama all capture // AFTER the non-rendering API tail, so a slow tail trips the window From 7f440ddd3f8d84fca14de61600699b9e88bf9ce3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:10:14 +0700 Subject: [PATCH 42/48] Photograph the Linux stall while it is stalled, not once it has settled Sampling only at the 40-minute cap is why the stacks were uninformative: by then every thread is idle and the EDT is parked in mainEDTLoop, which is what "nothing queued" looks like and says nothing about how it got there. The harness now tracks when the app last produced any output and takes a gdb thread dump after two minutes of silence, up to six times across a run, each sample separated by a timestamped header in hang-stacks.txt. Two minutes is far longer than the gap between any two tests in a healthy run, so a healthy run never triggers it; a stalled one gets photographed repeatedly while it is stuck and the samples show whether it is frozen on one call or crawling through something. Paired with the unbuffered stdout from the previous commits, this is the first setup that can actually answer the question instead of inviting another theory. Kept regardless of what it finds: it costs nothing on a healthy run and this job has a history of failing with no evidence. Co-Authored-By: Claude Opus 5 (1M context) --- .../CleanTargetLinuxIntegrationTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index c6c760d2621..50abe2aa3ee 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -383,6 +383,13 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // announcement from outside the process always works, and "stopped in X" is // the difference between a diagnosable failure and a silent one. final java.util.concurrent.atomic.AtomicReference lastStarted = new java.util.concurrent.atomic.AtomicReference<>(); + // When the app last said anything at all. A stall is only diagnosable + // from the state it stalls IN: by the time the 40-minute cap expires + // the picture has settled and every thread looks idle. Sampling while + // it is stuck is what distinguishes "waiting for a callback that never + // came" from "still working". + final java.util.concurrent.atomic.AtomicLong lastOutputAt = + new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()); final Process appF = app; Thread areader = new Thread(() -> { // Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when @@ -406,6 +413,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { if (tee != null) { tee.println(line); } if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); } if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } + lastOutputAt.set(System.currentTimeMillis()); int startedAt = line.indexOf("CN1SS:INFO:suite starting test="); if (startedAt >= 0) { lastStarted.set(line.substring( @@ -442,6 +450,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // suite's own completion marker instead. boolean requireSuite = Boolean.parseBoolean(System.getenv("CN1_REQUIRE_SUITE")); int pngs = 0, lastPngs = -1; + int stallSamples = 0; long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { if (finished.get()) { break; } @@ -460,6 +469,17 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); } + long silentMs = System.currentTimeMillis() - lastOutputAt.get(); + if (silentMs >= STALL_SAMPLE_AFTER_MS && stallSamples < MAX_STALL_SAMPLES) { + stallSamples++; + System.out.println("CN1SS:HARNESS: no output for " + (silentMs / 1000) + + "s after " + lastStarted.get() + "; sampling thread stacks (" + + stallSamples + "/" + MAX_STALL_SAMPLES + ")"); + dumpLiveThreadStacks(); + // Re-arm so the next sample needs another quiet stretch rather + // than firing on every poll. + lastOutputAt.set(System.currentTimeMillis()); + } if (!requireSuite && pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs) { break; } Thread.sleep(3000); @@ -589,6 +609,13 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException { s = s.substring(0, start) + body + s.substring(end); Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8)); } + /// How long the suite may say nothing before it is worth photographing, and + /// how many photographs to take. Two minutes is far longer than the gap + /// between any two tests in a healthy run, and a handful of samples spread + /// across the stall shows whether it is stuck or merely crawling. + private static final long STALL_SAMPLE_AFTER_MS = 120_000L; + private static final int MAX_STALL_SAMPLES = 6; + private static int runGdbAttach(java.io.File out, String pid, boolean viaSudo) throws Exception { java.util.List cmd = new java.util.ArrayList<>(); if (viaSudo) { From af9944c3752d2695f8050ec36fc81936ed5eeff2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:10:45 +0700 Subject: [PATCH 43/48] Separate each stall sample with a timestamped header Six dumps appended to one file are unreadable without knowing where each begins, and the timestamps are what show whether the process moved between samples. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/translator/CleanTargetLinuxIntegrationTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 50abe2aa3ee..83f4a49bb8d 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -661,6 +661,11 @@ private static void dumpLiveThreadStacks() { } java.io.File out = new java.io.File( new java.io.File(teePath).getParentFile(), "hang-stacks.txt"); + try (java.io.PrintWriter header = new java.io.PrintWriter( + new java.io.FileWriter(out, true), true)) { + header.println("===== sample at " + new java.util.Date() + + " (pid " + pid.trim() + ") ====="); + } // Plain gdb first; if yama still refuses the attach, retry through sudo, // which the runner allows passwordless. Either way a refusal must not // become a second failure. From 1092abd0bff8d56ebc01e5dc1ae64ad0a6b9e137 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:21:47 +0700 Subject: [PATCH 44/48] Address the review round: OAEP key sizes, DER strictness, verify errors, MCP listeners Windows OAEP was capped at about 8456-bit keys The mask was a fixed 1024-byte array, so a larger key was refused as though the block did not fit it. KeyGenerator.rsa() accepts every byte-aligned size from 1024 bits up and callers can import larger ones, so keys that work on every other port failed here. The mask is now allocated from the modulus and freed on each exit. Checked under AddressSanitizer at 8456 and 12288 bits -- both previously refused, both now round-trip, no leak on any path. Linux accepted DER keys with trailing data d2i_* stops at the end of the first object it recognises, so a buffer holding a valid key followed by extra bytes parsed happily. JavaSE and Android reject that through KeyFactory, so the same bytes validated on one port and not another. Both parsers now require the whole input to be the key. verify() could not tell a bad signature from a bad configuration An unsupported algorithm, malformed key DER or family mismatch came back as plain false, which reads as "this signature was tampered with". JavaSE and Android throw, and Signature.verify turns that into a CryptoException. Both ports gained clearCryptoError() so the slot can be emptied before the call, which is what makes a recorded failure attributable to it, and a configuration error now raises instead of returning false. A retired MCP listener could serve a replacement transport close() released the process-wide registration before stopping the listener, so a connection already accepted could resolve `active` to a transport that took the slot afterwards and hand it a client that dialled the old port. The listener is stopped first now, and attach() refuses streams once the transport is closed rather than wiring them to a dead session. Also the no-video-encoder-on- skip reason, scoped to the Apple ports like its siblings. core-unittests: 4678 tests, no failures, SpotBugs zero findings. Windows cross-compile links. Both port sources compile. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 34 ++++++++++---- .../nativeSources/cn1_linux_crypto.c | 29 ++++++++++++ .../impl/linux/LinuxImplementation.java | 17 ++++++- .../com/codename1/impl/linux/LinuxNative.java | 4 ++ .../nativeSources/cn1_windows_crypto.c | 46 +++++++++++++++++-- .../impl/windows/WindowsImplementation.java | 17 ++++++- .../codename1/impl/windows/WindowsNative.java | 4 ++ docs/website/data/port_status_supplement.json | 10 ++++ 8 files changed, 147 insertions(+), 14 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index e09425778ff..e393f60522f 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -158,9 +158,10 @@ public void open() throws IOException { } if (closedWhileBinding) { // No null check on `bound`: the only way past the try above is with a - // listener in hand, and SpotBugs flags the redundant test. - clearActiveIfOurs(); + // listener in hand, and SpotBugs flags the redundant test. Stop before + // releasing the slot, for the reason close() gives. bound.stop(); + clearActiveIfOurs(); throw new IOException("This MCP socket transport was closed before it began listening"); } } @@ -180,10 +181,21 @@ void attach(InputStream is, OutputStream os) { InputStream previousIn; // NOPMD closed below, deliberately outside the lock OutputStream previousOut; // NOPMD closed below, outside the lock synchronized (lock) { - previousIn = in; - previousOut = out; - in = is; - out = os; + if (closed) { + // A listener retired by close() can still have a connection in + // flight. Adopting it would serve a client that dialled a port + // this transport no longer owns, so hand the streams back to be + // closed rather than wiring them to a dead session. + previousIn = is; + previousOut = os; + is = null; + os = null; + } else { + previousIn = in; + previousOut = out; + in = is; + out = os; + } lock.notifyAll(); } // Dropping the previous client means closing its streams, not just forgetting @@ -400,11 +412,17 @@ public void close() { out = null; lock.notifyAll(); } - // Only if it is still ours: a transport opened after this one keeps its slot. - clearActiveIfOurs(); + // Stop the listener BEFORE releasing the registration, not after. A + // connection this listener already accepted resolves `active` inside its + // callback, so releasing first leaves a window where a replacement + // transport has taken the slot and the retired listener hands it a client + // that dialled the old port. Stopping first means any in-flight callback + // still finds this transport, and attach() below refuses it because we + // are closed. if (l != null) { l.stop(); } + clearActiveIfOurs(); // Closing the output as well as the input: forgetting the field is not enough, // because a writer that already captured it would go on writing to a session that // has ended, and the socket would stay open until the connection callback unwound. diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 942fc457c84..b245b70375c 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -68,6 +68,16 @@ static void cn1CryptoFail(const char* what) { ERR_clear_error(); } +/* Clears the per-thread message so a caller can tell whether the operation it + * just ran recorded one. verifyData answers false for an invalid signature -- + * an ordinary result -- and also for an unusable algorithm or key, which is a + * configuration error the caller deserves to see. The only difference between + * them is whether an error was recorded, and that is only readable if the slot + * was empty beforehand. */ +JAVA_VOID com_codename1_impl_linux_LinuxNative_clearCryptoError__(CODENAME_ONE_THREAD_STATE) { + cn1CryptoError[0] = 0; +} + JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { return newStringFromCString(threadStateData, cn1CryptoError[0] ? cn1CryptoError : "unknown crypto error"); @@ -269,11 +279,23 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo /* ------------------------------------------------------------ keys */ +/* d2i_* stops at the end of the first object it recognises and reports how far + * it got, so a buffer holding a valid key followed by extra bytes -- a + * mis-sliced concatenation, say -- parses happily and the trailing data is + * never seen. JavaSE and Android refuse that through KeyFactory, so accepting + * it here would mean the same bytes validate on one port and not another. The + * whole input has to be the key. */ static EVP_PKEY* cn1PublicKey(const unsigned char* der, int length) { const unsigned char* cursor = der; EVP_PKEY* key = d2i_PUBKEY(0, &cursor, (long) length); if (key == 0) { cn1CryptoFail("public key is not X.509 SubjectPublicKeyInfo DER"); + return 0; + } + if (cursor != der + length) { + cn1CryptoFail("public key has trailing data after the SubjectPublicKeyInfo"); + EVP_PKEY_free(key); + return 0; } return key; } @@ -293,6 +315,13 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { } if (key == 0) { cn1CryptoFail("private key is not PKCS#8 DER"); + return 0; + } + /* Same rule as the public side: the whole buffer has to be the key. */ + if (cursor != der + length) { + cn1CryptoFail("private key has trailing data after the PKCS#8 structure"); + EVP_PKEY_free(key); + return 0; } return key; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index f83c6c3935c..ff7d4384d78 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2813,7 +2813,22 @@ public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKe public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { checkKeyFamily(algorithm, keyAlgorithm); - return LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); + // An invalid signature and an unusable algorithm or key both come back + // as false from the native. Only the second is a configuration error, + // and JavaSE and Android raise it -- Signature.verify turns the throw + // into a CryptoException -- so answering plain false here would let a + // mistyped algorithm or malformed key read as "someone tampered with + // this". Clearing the slot first is what makes the two distinguishable. + LinuxNative.clearCryptoError(); + boolean verified = LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); + if (!verified) { + String failure = LinuxNative.lastCryptoError(); + if (failure != null && failure.length() > 0 + && !"unknown crypto error".equals(failure)) { + throw new RuntimeException("verify failed: " + failure); + } + } + return verified; } /// The portable contract pairs an algorithm with a key of its own family, diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 641dd1c4a9c..7fa0f44a9da 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -422,6 +422,10 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, /** Why the most recent crypto call failed, for the CryptoException message. */ public static native String lastCryptoError(); + /// Empties the last-error slot so a following call's failure can be told + /// apart from a stale message. + public static native void clearCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 95d6fa58f6c..c8910831e2a 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -74,6 +74,13 @@ static void cn1CryptoFailLast(const char* what) { cn1CryptoFail(what, (NTSTATUS) GetLastError()); } +/* Clears the per-thread message; see the Linux port's note. verifyData reports + * an invalid signature and an unusable algorithm or key the same way, and only + * a cleared slot makes the difference readable. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_clearCryptoError__(CODENAME_ONE_THREAD_STATE) { + cn1WinCryptoError[0] = 0; +} + JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { return newStringFromCString(threadStateData, cn1WinCryptoError[0] ? cn1WinCryptoError : "unknown crypto error"); @@ -543,22 +550,34 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; unsigned char seed[64]; - unsigned char mask[1024]; + unsigned char* mask; int i; /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB * shorter than the label hash itself, and the hash would then be written * and compared past the end of the block. */ - if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { + if (blockLength < 2 * hashLength + 2) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } + /* Sized from the modulus, not a fixed array. A 1KB mask capped OAEP at + * about 8456-bit keys, and KeyGenerator.rsa() accepts every byte-aligned + * size from 1024 bits up while callers can import larger ones still -- so + * a key that works on every other port was refused here as though the + * block did not fit it. */ + mask = (unsigned char*) malloc((size_t) dbLength); + if (mask == 0) { + cn1CryptoFail("out of memory", 0); + return 0; + } if (messageLength > dbLength - hashLength - 1) { cn1CryptoFail("RSA-OAEP message is too long for the key", 0); + free(mask); return 0; } memset(block, 0, (size_t) blockLength); /* DB = lHash || PS || 0x01 || M, with an empty label. */ if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, block + 1 + hashLength, hashLength)) { + free(mask); return 0; } block[blockLength - messageLength - 1] = 0x01; @@ -568,20 +587,24 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned if (BCryptGenRandom(NULL, seed, (ULONG) hashLength, BCRYPT_USE_SYSTEM_PREFERRED_RNG) != STATUS_SUCCESS) { cn1CryptoFail("RSA-OAEP seed", 0); + free(mask); return 0; } if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + free(mask); return 0; } for (i = 0; i < dbLength; i++) { block[1 + hashLength + i] ^= mask[i]; } if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + free(mask); return 0; } for (i = 0; i < hashLength; i++) { block[1 + i] = (unsigned char) (seed[i] ^ mask[i]); } + free(mask); return 1; } @@ -609,7 +632,7 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* int blockLength, unsigned char* message, int* messageLength) { int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; - unsigned char mask[1024]; + unsigned char* mask; unsigned char labelHash[64]; unsigned char seed[64]; int i; @@ -619,25 +642,38 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB * shorter than the label hash itself, and the hash would then be written * and compared past the end of the block. */ - if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { + if (blockLength < 2 * hashLength + 2) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } + /* Sized from the modulus, not a fixed array. A 1KB mask capped OAEP at + * about 8456-bit keys, and KeyGenerator.rsa() accepts every byte-aligned + * size from 1024 bits up while callers can import larger ones still -- so + * a key that works on every other port was refused here as though the + * block did not fit it. */ + mask = (unsigned char*) malloc((size_t) dbLength); + if (mask == 0) { + cn1CryptoFail("out of memory", 0); + return 0; + } /* The leading byte must be zero; fold it in rather than returning here. */ bad |= (unsigned int) block[0]; if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + free(mask); return 0; } for (i = 0; i < hashLength; i++) { seed[i] = (unsigned char) (block[1 + i] ^ mask[i]); } if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + free(mask); return 0; } for (i = 0; i < dbLength; i++) { block[1 + hashLength + i] ^= mask[i]; } if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) { + free(mask); return 0; } for (i = 0; i < hashLength; i++) { @@ -658,12 +694,14 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* bad |= ~seenDelimiter; /* no delimiter anywhere in the block */ if (bad != 0) { cn1CryptoFail("RSA-OAEP decryption failed", 0); + free(mask); return 0; } *messageLength = blockLength - (int) messageStart; if (*messageLength > 0) { memcpy(message, block + messageStart, (size_t) *messageLength); } + free(mask); return 1; } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 574e91aff89..d2e0e8743f9 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2821,7 +2821,22 @@ public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKe public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { checkKeyFamily(algorithm, keyAlgorithm); - return WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); + // An invalid signature and an unusable algorithm or key both come back + // as false from the native. Only the second is a configuration error, + // and JavaSE and Android raise it -- Signature.verify turns the throw + // into a CryptoException -- so answering plain false here would let a + // mistyped algorithm or malformed key read as "someone tampered with + // this". Clearing the slot first is what makes the two distinguishable. + WindowsNative.clearCryptoError(); + boolean verified = WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); + if (!verified) { + String failure = WindowsNative.lastCryptoError(); + if (failure != null && failure.length() > 0 + && !"unknown crypto error".equals(failure)) { + throw new RuntimeException("verify failed: " + failure); + } + } + return verified; } /// The portable contract pairs an algorithm with a key of its own family, diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 5843a0eded8..b19cb9d3b66 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -430,6 +430,10 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, /** Why the most recent crypto call failed, for the CryptoException message. */ public static native String lastCryptoError(); + /// Empties the last-error slot so a following call's failure can be told + /// apart from a stale message. + public static native void clearCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index ef0bc5bfe35..ab58385ae49 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -41,6 +41,16 @@ "watchos" ] }, + { + "prefix": "no-video-encoder-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + }, { "prefix": "encode-write-failed-on-", "ports": [ From 2d07450a9d6e26f7f05e8a7d2a7b4371b6b8e5ba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:50:39 +0700 Subject: [PATCH 45/48] Run every GtkClipboard call on the GTK main thread The arm64 Linux suite stopped inside ClipboardRoundTripTest. With stdout now unbuffered the stage markers are finally trustworthy: "stage=prepared" was the last line and "stage=ran" never arrived, so runTest() itself was blocked rather than the log being behind. The gdb sample taken while it was stalled names the frame outright: gtk_clipboard_wait_for_text () LinuxNative_clipboardGetText (cn1_linux_services.c:176) LinuxImplementation_getPasteDataFromClipboard ClipboardRoundTripTest_paste -> roundTripFile -> runTest Display_edtLoopImpl -> mainEDTLoop gtk_clipboard_wait_for_text / _image / _uris and gtk_clipboard_store all pump a nested main loop until the selection owner answers. Pumping the default GMainContext from the EDT while the GTK thread owns it leaves the caller blocked in g_main_context_wait() for an acquire that only completes if the GTK thread happens to release the context -- so the EDT wedges whenever the timing lines up, which is why this presented as an intermittent hang rather than a reliable one. Every other GTK-touching unit in this port (browser, peer, print, notify, file dialog, a11y, widgets) already marshals through cn1LinuxRunOnMainAndWait, and this file's own header comment claims clipboard does too. It did not. It does now: the GTK work moved into *OnMain helpers over plain C structs and every JAVA_OBJECT conversion stays on the calling thread, matching the file dialog. All six natives are converted, not just the one that happened to hang -- the two setters block in gtk_clipboard_store for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_services.c | 176 ++++++++++++------ 1 file changed, 120 insertions(+), 56 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index d0ea40baac4..285ee571980 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -164,39 +164,65 @@ static int cn1LoadGeoclue(void) { /* ----------------------------------------------------------- clipboard */ -JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetText___java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text) { - const char* t = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text); +/* Every GtkClipboard call below runs on the GTK main thread via + * cn1LinuxRunOnMainAndWait, never inline on the calling (EDT) thread. + * + * This is not defensive style, it is required: the retrieval calls + * (gtk_clipboard_wait_for_text / _image / _uris) and gtk_clipboard_store all + * pump a nested main loop until the selection owner answers. Pumping the + * default GMainContext from a second thread while the GTK thread owns it makes + * the caller block in g_main_context_wait() for an acquire that only completes + * when the GTK thread happens to release the context -- so the EDT wedges for + * good whenever the timing lines up. It usually does not, which is exactly why + * this presented as an intermittent suite hang (the EDT stack was parked in + * gtk_clipboard_wait_for_text under ClipboardRoundTripTest). + * + * The GTK work therefore happens in the *OnMain helpers over plain C structs; + * every JAVA_OBJECT conversion stays on the calling thread, matching the file + * dialog / notification pattern used elsewhere in this file. */ + +typedef struct { const char* text; } CN1ClipSetText; + +static void cn1ClipSetTextOnMain(void* p) { + CN1ClipSetText* r = (CN1ClipSetText*) p; GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gtk_clipboard_set_text(cb, t, -1); + gtk_clipboard_set_text(cb, r->text, -1); gtk_clipboard_store(cb); } +JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetText___java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text) { + CN1ClipSetText r; + r.text = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text); + cn1LinuxRunOnMainAndWait(cn1ClipSetTextOnMain, &r); +} + +typedef struct { gchar* text; } CN1ClipGetText; + +static void cn1ClipGetTextOnMain(void* p) { + CN1ClipGetText* r = (CN1ClipGetText*) p; + r->text = gtk_clipboard_wait_for_text(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); +} + JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetText___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { - GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gchar* text = gtk_clipboard_wait_for_text(cb); - JAVA_OBJECT result = text ? newStringFromCString(threadStateData, text) : JAVA_NULL; - if (text) { - g_free(text); + CN1ClipGetText r; + JAVA_OBJECT result; + r.text = NULL; + cn1LinuxRunOnMainAndWait(cn1ClipGetTextOnMain, &r); + result = r.text ? newStringFromCString(threadStateData, r.text) : JAVA_NULL; + if (r.text) { + g_free(r.text); } return result; } -JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT png) { - unsigned char* bytes; - int len; - GdkPixbufLoader* loader; +typedef struct { const unsigned char* bytes; int len; } CN1ClipSetImage; + +static void cn1ClipSetImageOnMain(void* p) { + CN1ClipSetImage* r = (CN1ClipSetImage*) p; + GdkPixbufLoader* loader = gdk_pixbuf_loader_new(); GdkPixbuf* pix; GtkClipboard* cb; - if (png == JAVA_NULL) { - return; - } - bytes = (unsigned char*) (*(JAVA_ARRAY) png).data; - len = (int) (*(JAVA_ARRAY) png).length; - if (len <= 0) { - return; - } - loader = gdk_pixbuf_loader_new(); - if (!gdk_pixbuf_loader_write(loader, bytes, (gsize) len, NULL)) { + if (!gdk_pixbuf_loader_write(loader, r->bytes, (gsize) r->len, NULL)) { gdk_pixbuf_loader_close(loader, NULL); g_object_unref(loader); return; @@ -212,25 +238,48 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(C g_object_unref(loader); } -JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetImage___R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE) { - GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - GdkPixbuf* pix = gtk_clipboard_wait_for_image(cb); - gchar* buf = NULL; - gsize len = 0; - JAVA_OBJECT result; +JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT png) { + CN1ClipSetImage r; + if (png == JAVA_NULL) { + return; + } + /* The array stays reachable from this (blocked) frame for the whole call. */ + r.bytes = (const unsigned char*) (*(JAVA_ARRAY) png).data; + r.len = (int) (*(JAVA_ARRAY) png).length; + if (r.len <= 0) { + return; + } + cn1LinuxRunOnMainAndWait(cn1ClipSetImageOnMain, &r); +} + +typedef struct { gchar* buf; gsize len; } CN1ClipGetImage; + +static void cn1ClipGetImageOnMain(void* p) { + CN1ClipGetImage* r = (CN1ClipGetImage*) p; + GdkPixbuf* pix = gtk_clipboard_wait_for_image(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); if (!pix) { - return JAVA_NULL; + return; } - if (!gdk_pixbuf_save_to_buffer(pix, &buf, &len, "png", NULL, NULL) || buf == NULL) { - if (buf) { - g_free(buf); + if (!gdk_pixbuf_save_to_buffer(pix, &r->buf, &r->len, "png", NULL, NULL) || r->buf == NULL) { + if (r->buf) { + g_free(r->buf); + r->buf = NULL; } - g_object_unref(pix); - return JAVA_NULL; } - result = cn1LinuxNewByteArray(threadStateData, buf, (int) len); - g_free(buf); g_object_unref(pix); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetImage___R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE) { + CN1ClipGetImage r; + JAVA_OBJECT result; + r.buf = NULL; + r.len = 0; + cn1LinuxRunOnMainAndWait(cn1ClipGetImageOnMain, &r); + if (!r.buf) { + return JAVA_NULL; + } + result = cn1LinuxNewByteArray(threadStateData, r.buf, (int) r.len); + g_free(r.buf); return result; } @@ -258,14 +307,32 @@ static void cn1UriListClear(GtkClipboard* cb, gpointer userData) { } } +/* Takes ownership of the CN1UriListData: either the clipboard holds it (and the + * clear-func frees it later) or it is released here. */ +static void cn1ClipSetFilesOnMain(void* p) { + CN1UriListData* data = (CN1UriListData*) p; + GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + GtkTargetList* tl = gtk_target_list_new(NULL, 0); + GtkTargetEntry* targets; + gint nTargets = 0; + gtk_target_list_add_uri_targets(tl, 0); + targets = gtk_target_table_new_from_list(tl, &nTargets); + if (!gtk_clipboard_set_with_data(cb, targets, nTargets, cn1UriListGet, cn1UriListClear, data)) { + cn1UriListClear(cb, data); + } else { + gtk_clipboard_set_can_store(cb, targets, nTargets); + gtk_clipboard_store(cb); + } + if (targets) { + gtk_target_table_free(targets, nTargets); + } + gtk_target_list_unref(tl); +} + JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetFiles___java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT paths) { int n, i; JAVA_OBJECT* elements; CN1UriListData* data; - GtkClipboard* cb; - GtkTargetList* tl; - GtkTargetEntry* targets; - gint nTargets = 0; if (paths == JAVA_NULL) { return; } @@ -287,29 +354,26 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetFiles___java_lang_Str } data->uris[n] = NULL; - cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - tl = gtk_target_list_new(NULL, 0); - gtk_target_list_add_uri_targets(tl, 0); - targets = gtk_target_table_new_from_list(tl, &nTargets); - if (!gtk_clipboard_set_with_data(cb, targets, nTargets, cn1UriListGet, cn1UriListClear, data)) { - cn1UriListClear(cb, data); - } else { - gtk_clipboard_set_can_store(cb, targets, nTargets); - gtk_clipboard_store(cb); - } - if (targets) { - gtk_target_table_free(targets, nTargets); - } - gtk_target_list_unref(tl); + cn1LinuxRunOnMainAndWait(cn1ClipSetFilesOnMain, data); +} + +typedef struct { gchar** uris; } CN1ClipGetFiles; + +static void cn1ClipGetFilesOnMain(void* p) { + CN1ClipGetFiles* r = (CN1ClipGetFiles*) p; + r->uris = gtk_clipboard_wait_for_uris(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetFiles___R_java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE) { - GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gchar** uris = gtk_clipboard_wait_for_uris(cb); + CN1ClipGetFiles r; + gchar** uris; int n = 0; int i; JAVA_OBJECT arr; JAVA_OBJECT* elements; + r.uris = NULL; + cn1LinuxRunOnMainAndWait(cn1ClipGetFilesOnMain, &r); + uris = r.uris; if (!uris) { return JAVA_NULL; } From c3d1c988759c201e8e1ea85a679aced21c892dc5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:52:49 +0700 Subject: [PATCH 46/48] Keep publishing the Linux port status when the suite fails With CN1_REQUIRE_SUITE armed, a leg that misses CN1SS:SUITE:FINISHED now fails build-run -- and a job whose `if:` never mentions always()/cancelled() is skipped when a dependency fails. So the one run that most needs reporting was the one that silently produced none: cn1ss_process_and_report and "Upload Linux port status" never executed, and the public table kept serving the previous green report. That is the same failure-masked-as-pass shape the strict gate exists to remove. Gate the job on !cancelled() instead. Normalization now runs after a failed leg and publishes the real fail / not-run counts; build-run stays red, so the workflow still fails. The artifact downloads are already continue-on-error and the upload step is already if: always(), so a partial capture reports what it has. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 37a08137f53..abc047fd5c0 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -381,7 +381,16 @@ jobs: compare-comment: name: screenshot-comment needs: build-run - if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + # !cancelled() rather than a plain event check: without it GitHub skips this + # job whenever a build-run leg fails, and skipping it is precisely the wrong + # response to a failing suite. Normalization is what publishes the fail / + # not-run counts, so being skipped leaves the public table showing the last + # green report -- a failure masked as a pass. The workflow still goes red + # because build-run itself failed; this only keeps the reporting honest. + if: >- + !cancelled() && + (github.event_name == 'pull_request' || github.event_name == 'push' || + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest permissions: contents: read From 6ff6b69742b8632b0a152119ced124281935abcf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:01:12 +0700 Subject: [PATCH 47/48] Serialise the iOS path-renderer's process-wide setup build-mac-native failed on SVGStaticScreenshotTest. Chasing it turned up a pre-existing race rather than anything this branch changed. Evidence it is not from this PR: master fails the same way (run 30742491642 SVGStatic, run 30732882535 VectorMapShapesScreenshotTest -- both vector tests), roughly one run in five. And master's failing capture is pixel-identical to this branch's: 0 differing pixels between the two, each exactly 534 pixels from the golden. Two stable outcomes, not drifting noise. Where those 534 pixels sit is the tell. They are only on antialiased edges -- the outer rim of path_arrow.svg's stroke and the wave curves -- while every saturated interior pixel, every glyph and the four non-stroked SVGs match exactly. Edge samples are the ones that index alphaMap, the coverage->alpha table Renderer_setup builds into process-wide globals. The guard raised its flag before doing the work: if (!rendererIsSetup) { rendererIsSetup = YES; Renderer_setup(1,1); } so a second thread entering mid-setup saw the flag already up, skipped initialisation and rasterised against half-built globals. setMaxAlpha made it worse by assigning alphaMap straight from malloc and publishing sMaxAlpha first, so a reader could index an unfilled table -- or a NULL one. Demonstrated against the product source, not by inspection: a harness that #includes Renderer.c and polls alphaMap from a second thread while Renderer_setup runs reports "reader saw a partially filled alphaMap: YES" on every run beforehand, and "no" on six consecutive runs after. (The harness widens the fill loop to make the window observable; the ordering is identical at the shipped Renderer_setup(1,1).) Fix both ends: cn1EnsureRendererSetup takes a mutex and raises the flag only once setup returns, so a racing caller blocks until the globals are whole; setMaxAlpha fills the table before publishing it, and publishes alphaMap before sMaxAlpha. This is the strongest candidate for the intermittent vector-screenshot mismatch and it matches the symptom exactly, but I have not reproduced the screenshot failure itself locally -- CI frequency over subsequent runs is what will confirm it. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 34 ++++++++++++++++++------- Ports/iOSPort/nativeSources/Renderer.c | 18 ++++++++++--- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 05daf0dbab2..03a2be0bbf4 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -27,6 +27,7 @@ // end Pisces imports #include "xmlvm.h" #include "java_lang_String.h" +#include #import "CN1ES2compat.h" #if TARGET_OS_WATCH #import "CN1CGGraphics.h" @@ -11147,12 +11148,31 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathStrokerGetConsumer___long(J static BOOL rendererIsSetup = NO; -JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_int_int_int(JAVA_OBJECT instanceObject, JAVA_INT pix_boundsX, JAVA_INT pix_boundsY, JAVA_INT pix_boundsWidth, JAVA_INT pix_boundsHeight, JAVA_INT windingRule) -{ - if ( !rendererIsSetup ){ +static pthread_mutex_t rendererSetupLock = PTHREAD_MUTEX_INITIALIZER; + +// Renderer_setup installs process-wide globals (the subpixel constants and the +// coverage->alpha table alphaMap) that every subsequent rasterisation reads. +// The original guard set rendererIsSetup *before* calling it, so a second +// thread entering here mid-setup saw the flag already raised, skipped the +// initialisation and went straight to rasterising against half-built globals: +// an alphaMap that was allocated but not yet filled, or still NULL. That +// corrupts exactly the antialiased edge samples of a shape while leaving its +// saturated interior correct. +// +// Serialise instead, and raise the flag only once setup has completed, so a +// racing caller blocks until the globals are whole. +static void cn1EnsureRendererSetup(JAVA_INT lgPositionsX, JAVA_INT lgPositionsY) { + pthread_mutex_lock(&rendererSetupLock); + if (!rendererIsSetup) { + Renderer_setup(lgPositionsX, lgPositionsY); rendererIsSetup = YES; - Renderer_setup(1,1); } + pthread_mutex_unlock(&rendererSetupLock); +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_int_int_int(JAVA_OBJECT instanceObject, JAVA_INT pix_boundsX, JAVA_INT pix_boundsY, JAVA_INT pix_boundsWidth, JAVA_INT pix_boundsHeight, JAVA_INT windingRule) +{ + cn1EnsureRendererSetup(1, 1); Renderer *renderer = (Renderer*)malloc(sizeof(Renderer)); Renderer_init(renderer); Renderer_reset(renderer, pix_boundsX, pix_boundsY, pix_boundsWidth, pix_boundsHeight, windingRule); @@ -11162,11 +11182,7 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_in //native void nativePathRendererSetup(int subpixelLgPositionsX, int subpixelLgPositionsY); void com_codename1_impl_ios_IOSNative_nativePathRendererSetup___int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT subpixelLgPositionsX, JAVA_INT subpixelLgPositionsY) { - if ( !rendererIsSetup ){ - rendererIsSetup = YES; - - Renderer_setup(subpixelLgPositionsX, subpixelLgPositionsY); - } + cn1EnsureRendererSetup(subpixelLgPositionsX, subpixelLgPositionsY); } //native void nativePathRendererCleanup(long ptr); void com_codename1_impl_ios_IOSNative_nativePathRendererCleanup___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG ptr) diff --git a/Ports/iOSPort/nativeSources/Renderer.c b/Ports/iOSPort/nativeSources/Renderer.c index 8c8717e2350..079eb3ae31f 100644 --- a/Ports/iOSPort/nativeSources/Renderer.c +++ b/Ports/iOSPort/nativeSources/Renderer.c @@ -610,12 +610,22 @@ void Renderer_produceAlphas(Renderer *pRenderer, AlphaConsumer *pAC) { static jint sMaxAlpha = 0; static void setMaxAlpha(jint maxalpha) { jint i; - - sMaxAlpha = maxalpha; - alphaMap = malloc(maxalpha+1); + jbyte *map; + + // Fill the table before publishing it, and publish alphaMap before + // sMaxAlpha. produceAlphas reads both from whatever thread is painting: + // assigning alphaMap straight from malloc let a reader index a table that + // was still uninitialized (garbage coverage on antialiased edges), and + // setting sMaxAlpha first let a reader see a non-zero max while alphaMap + // was still NULL (a null dereference). The caller-side guard in + // IOSNative.m serializes setup properly; this ordering is the second line + // of defence. + map = malloc(maxalpha+1); for (i = 0; i <= maxalpha; i++) { - alphaMap[i] = (jbyte) ((i*255 + maxalpha/2)/maxalpha); + map[i] = (jbyte) ((i*255 + maxalpha/2)/maxalpha); } + alphaMap = map; + sMaxAlpha = maxalpha; } static void setAndClearRelativeAlphas(AlphaConsumer *pAC, From 3213bd3202100face0f46ec8e6a7bb3099d66726 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:06:25 +0700 Subject: [PATCH 48/48] Fix the sweep's jq invocation and a rejected-transformation key leak Two review findings, both confirmed rather than taken on faith. gh run list forwards no jq CLI options to --jq, so `--jq --arg horizon ...` made gh treat "horizon" as a subcommand. Reproduced against the real CLI: $ gh run list ... --jq --arg horizon "x" '.[].databaseId' unknown command "horizon" for "gh run list" The nightly sweep would have died before looking at a single run. Piping to a separate `jq -r --arg` instead; checked against a fixture that the filter still selects only push/schedule/dispatch runs with a success/failure conclusion inside the horizon, newest first. rsaCrypt imported the key before validating the transformation, and the unsupported-transformation branch returns directly instead of falling through to `done`, so each rejected call leaked a BCrypt/NCrypt key handle. Validate first and import only afterwards, which removes the leaking path rather than adding a second cleanup to keep in sync. Verified by cross-compiling the port into a real Windows PE with clang-cl + lld-link against an xwin sysroot: 1 test, 0 failures, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/WindowsPort/nativeSources/cn1_windows_crypto.c | 10 ++++++++-- .../conformance/backfill_port_status.sh | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index c8910831e2a..1619fa99bd4 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -836,8 +836,8 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String int keyLength = 0, dataLength = 0; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; - NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); + BCRYPT_KEY_HANDLE publicKey = NULL; + NCRYPT_KEY_HANDLE privateKey = 0; int oaepMode = strstr(mode, "OAEP") != 0; LPCWSTR labelDigest = BCRYPT_SHA256_ALGORITHM; unsigned char* out = 0; @@ -851,6 +851,12 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String cn1CryptoFail("unsupported cipher transformation", 0); return JAVA_NULL; } + /* Import only once the transformation is known good. Importing first and + * then rejecting the mode returned without reaching the `done` cleanup, so + * every rejected call leaked a BCrypt/NCrypt key handle -- unbounded in a + * long-running app that keeps retrying a bad transformation. */ + publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; + privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 6f2cc28c467..7eeae0ce138 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -115,9 +115,11 @@ while IFS= read -r workflow; do # wider net costs nothing when the newest run is complete. horizon="$(date -u -d "${sweep_stale_days} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)" + # gh's --jq takes one expression and forwards no jq CLI options, so --arg has + # to go to a separate jq invocation rather than being smuggled in after --jq. candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ - --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and + | jq -r --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and (.conclusion == "success" or .conclusion == "failure") and (.updatedAt >= $horizon))] | sort_by(.updatedAt) | reverse | .[].databaseId')"