diff --git a/CMakeLists.txt b/CMakeLists.txt index e6515f976..a3aed88a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -230,6 +230,7 @@ list(APPEND INCLUDE_FILES ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_runtime.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_binding.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v2_decoder.hpp + ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/encoded_field_transport.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/local_particle_field_feed.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp diff --git a/docs/50_Step记录/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_v3.md b/docs/50_Step记录/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_v3.md new file mode 100644 index 000000000..7f6ad8d8b --- /dev/null +++ b/docs/50_Step记录/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_v3.md @@ -0,0 +1,38 @@ +# STEP-078 — QBIN2 V4 Android / JNI / RN 桥接实现 v3 + +状态:完成 + +日期:2026-09-13 + +## 目标与基线 + +- 基线:`73779b89bcce1094eb344f4727d6d23904f667f3`。 +- 仅关闭 V4 bridge 的 QBIN transport 三层 64 MiB guard 与受限 ID 的 UTF-8 byte-length guard;不进入 STEP-079。 +- 任务开始前 STEP-062 两处 dirty、历史 untracked、`.codex-build-logs/` 与 Test Host generated/android/.expo/tgz 均未清理、改写、暂存或提交。 + +## 完成项 + +- [x] Java `WeatherRuntime.submitEncodedField(...)` 在 map/JNI 前拒绝空及超过 64 MiB QBIN,并以 UTF-8 1..64 bytes 检查 fieldKey、fieldID 和 contract JSON 中 profileID。 +- [x] JNI 在读取 `jbyteArray` 长度后、复制到 `std::vector` 前重复检查 1..64 MiB;无效 transport 直接返回既有 `RejectedDecode`,不产生 oversized vector。 +- [x] Kotlin local-file 既有 1..64 MiB read guard 保持;Kotlin fieldKey、fieldID、profileID 改为 `toByteArray(Charsets.UTF_8).size` 的 1..64 bytes 检查。 +- [x] RN/TS 改用 `TextEncoder` UTF-8 bytes,fieldKey、runtime fieldID 与 profileID 在 33 个 CJK 字符(99 bytes)时 fail-fast。 +- [x] 新增 provider-neutral C++ transport-size helper/test、JDK-only Java guard test 与 TS directed test;未改变 QBIN decoder 的 64 MiB file / 32 MiB decoded-raw 语义、Core 十种 submit result、binding 或 forecast state/speed。 + +## 修改范围 + +- RN:`FieldRuntime.ts` 与 private `EncodedFieldGuards.ts`、其 directed test。 +- Kotlin/Java:`WeatherRuntimeBridgeModule.kt`、`WeatherRuntime.java` 与 JDK-only V4 guard/helper test。 +- JNI:`native_map_view.cpp`;Core only 增加 transport-size header 和 Linux unit test。 +- CMake/test registration、本 Step、本报告、`粒子开发记录.md`。 + +## 验证 + +- [x] 在 `particle-codex` tmux session,经全局 `build-output-throttle`:package `tsc --noEmit` PASS、TS UTF-8 directed test PASS、TurboModule schema combine/codegen PASS。 +- [x] JDK-only Java guard test PASS:64 MiB accepted,0 与 64 MiB+1 rejected,33 CJK 字符 rejected。 +- [x] `mbgl-test-runner` 实际 rebuild PASS;`ParticleFieldProfileContractJson.*`、`Source.RasterArrayV4HostApi*`、`ParticleFieldBinding.*`、`ParticleQbinV2Decoder.*:ParticleEncodedFieldIngress.*`、`ParticleEncodedFieldTransport.*`、`ParticleFieldRuntime.*:ParticleFieldProfileValidator.*`、`Source.ParticleFieldDataAdapter*:Source.RasterArrayTimeline*:Source.HostField*` 合并过滤 PASS。 +- [x] Java declaration ↔ C++ JNI registration V4 name-set 静态核对、Kotlin/Java UTF-8 guard 静态核对、`git diff --check`:PASS。 + +## 未完成、阻塞与下一步 + +- Android Kotlin/Java/JNI 真实编译、Gradle、APK、ADB、设备验证按任务约束未执行;需要后续一次性 Android 验收。iOS/Metal 与 Jest 同样未执行。 +- 无实现阻塞。本 Step 在 scoped commit/push 后立即停止,不进入 STEP-079。 diff --git a/docs/报告/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_Codex完成报告_v3.md b/docs/报告/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_Codex完成报告_v3.md new file mode 100644 index 000000000..bd88f71a3 --- /dev/null +++ b/docs/报告/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_Codex完成报告_v3.md @@ -0,0 +1,22 @@ +# STEP-078 QBIN2 V4 Android / JNI / RN 桥接实现 Codex 完成报告 v3 + +## 结果 + +已按 `73779b89bcce1094eb344f4727d6d23904f667f3` 基线关闭审计发现的两个 production guard 缺口,未重构已通过的 V4 bridge,未进入 STEP-079。 + +QBIN transport 现在在 Kotlin local-file、Java facade、JNI copy 三处均为 1..64 MiB;JNI 的检查在 `std::vector` 构造之前,直接 JNI caller 的空/超限数组 fail-closed。该 transport guard 是独立 helper,未混入 QBIN decoder:decoder 自有的 64 MiB 文件与 32 MiB decoded raw limits 不变。 + +RN/TS 以 `TextEncoder`、Kotlin 以 `toByteArray(Charsets.UTF_8)`、Java 以 `StandardCharsets.UTF_8` 执行相同的 1..64 UTF-8 bytes guard,覆盖 fieldKey、runtime fieldID、profileID。profileID 的 lowercase ASCII structural contract 仍由 Core parser/validator 最终裁决;未统一或放宽其它 ID 字符集。contractJson 仍为既有 256 KiB UTF-8 上限。 + +## 验证结果 + +- `particle-codex` tmux session 内所有 noisy build/test 均经 `build-output-throttle`:package TypeScript、TS 多字节 fail-fast directed test、TurboModule combine/codegen、Linux `mbgl-test-runner` rebuild 与规定 V4/V3 filter 均 PASS。 +- 新 C++ `ParticleEncodedFieldTransport` test 证明 0/64 MiB+1 拒绝、64 MiB 接受,且 helper 在 JNI vector copy 之前调用。 +- 新 JDK-only Java guard test 证明 Java facade 采用的 helper 接受 64 MiB、拒绝 0 和 64 MiB+1,并拒绝 33 CJK 字符(99 UTF-8 bytes)。Kotlin 与 Java source 的 UTF-8 guard 已定向静态核对。 +- Java JNI declaration 与 C++ registration 的 V4 name-set 静态核对 PASS;`git diff --check` PASS。 + +## 未完成与边界 + +Android Kotlin/Java/JNI 的真实编译、Gradle、APK、安装、ADB 和实体机验证均按任务要求未执行,留待后续一次性 Android 验收;iOS/Metal 与 Jest 也未执行。没有读取真实服务器 QBIN,未改 Renderer/GPU/shader、Test Host、NavSea 或 STEP-073~076 冻结 Core 语义。 + +任务开始前的 STEP-062 dirty、历史 untracked、`.codex-build-logs/`、Test Host generated/android/.expo 和 tgz 未纳入本次提交。scoped commit、push result、remote branch 与 Final HEAD 记录于本报告的 Git 收尾更新及 Step 记录;完成后停止,不进入 STEP-079。 diff --git a/include/mbgl/particle/ingress/encoded_field_transport.hpp b/include/mbgl/particle/ingress/encoded_field_transport.hpp new file mode 100644 index 000000000..a38c64190 --- /dev/null +++ b/include/mbgl/particle/ingress/encoded_field_transport.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace mbgl::particle::ingress { + +// Transport-only bound for JNI byte-array copying; decoder limits remain independent. +constexpr std::size_t MaxEncodedFieldTransportBytes = 64u * 1024u * 1024u; + +constexpr bool isValidEncodedFieldTransportSize(const std::size_t size) { + return size >= 1u && size <= MaxEncodedFieldTransportBytes; +} + +} // namespace mbgl::particle::ingress diff --git a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt index a283b90b9..39135ef40 100644 --- a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt +++ b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt @@ -106,7 +106,7 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : } /** V4 transports only a bounded local raw QBIN file plus small primitive control-plane values. */ @ReactMethod override fun submitEncodedField(reactTag: Double, fieldKey: String, fieldID: String, generation: Double, dataVersion: Double, contractJson: String, qbinFileUri: String, promise: Promise) { - if (!validEncodedId(fieldKey) || !validEncodedId(fieldID) || !validSafeInteger(generation, true) || !validSafeInteger(dataVersion, false) || contractJson.isEmpty() || contractJson.toByteArray(Charsets.UTF_8).size > MAX_CONTRACT_UTF8_BYTES) return reject(promise, "INVALID_ARGUMENT", "invalid V4 encoded field control-plane argument") + if (!validEncodedId(fieldKey) || !validEncodedId(fieldID) || !validEncodedContractJson(contractJson) || !validSafeInteger(generation, true) || !validSafeInteger(dataVersion, false)) return reject(promise, "INVALID_ARGUMENT", "invalid V4 encoded field control-plane argument") val payload = try { readLocalPayload(qbinFileUri) } catch (e: Exception) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", e.message ?: "QBIN file is unreadable") } if (payload.isEmpty()) return reject(promise, "PAYLOAD_FILE_UNREADABLE", "QBIN file is empty") withRuntime(reactTag, promise) { runtime -> try { promise.resolve(runtime.submitEncodedField(fieldKey, fieldID, generation.toLong(), dataVersion.toLong(), contractJson, payload).name) } catch (e: IllegalStateException) { reject(promise, if (e.message == "SOURCE_MISSING") "SOURCE_MISSING" else "RUNTIME_DISPOSED", e.message ?: "V4 runtime unavailable") } catch (e: IllegalArgumentException) { reject(promise, "INVALID_ARGUMENT", e.message ?: "invalid encoded field") } } @@ -294,7 +294,8 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : } private fun presence(exists: Boolean) = if (exists) "present" else "missing" - private fun validEncodedId(value: String) = value.isNotEmpty() && value.length <= 64 + private fun validEncodedId(value: String) = value.toByteArray(Charsets.UTF_8).size in 1..MAX_ENCODED_ID_UTF8_BYTES + private fun validEncodedContractJson(value: String) = value.isNotEmpty() && value.toByteArray(Charsets.UTF_8).size <= MAX_CONTRACT_UTF8_BYTES && try { validEncodedId(org.json.JSONObject(value).optString("profileID", "")) } catch (_: org.json.JSONException) { false } private fun validUInt16(value: Double, positive: Boolean) = value.isFinite() && value == kotlin.math.floor(value) && value >= if (positive) 1 else 0 && value <= 0xffff private fun inactiveSelector(semanticID: Double, groupID: Double) = semanticID == 0.0 && groupID == 0.0 private fun validSafeInteger(value: Double, positive: Boolean) = value.isFinite() && value <= MAX_SAFE_INTEGER && value >= if (positive) 1 else 0 && value == kotlin.math.floor(value) @@ -309,6 +310,7 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : const val NAME = "WeatherRuntimeBridge" private const val MAX_METADATA_UTF8_BYTES = 1_048_576 private const val MAX_CONTRACT_UTF8_BYTES = 256 * 1024 + private const val MAX_ENCODED_ID_UTF8_BYTES = 64 private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0 private const val MAX_PAYLOAD_BYTES = 64L * 1024L * 1024L private const val DEFAULT_READ_BUFFER_BYTES = 16 * 1024 diff --git a/packages/maplibre-particle-rn/src/EncodedFieldGuards.ts b/packages/maplibre-particle-rn/src/EncodedFieldGuards.ts new file mode 100644 index 000000000..ed40d1809 --- /dev/null +++ b/packages/maplibre-particle-rn/src/EncodedFieldGuards.ts @@ -0,0 +1,9 @@ +const MAX_ENCODED_ID_UTF8_BYTES = 64; + +/** Transport guard only; Core retains its own field/profile structural rules. */ +export function hasValidEncodedIdUtf8Length(value: unknown): value is string { + return typeof value === "string" && (() => { + const bytes = new TextEncoder().encode(value).length; + return bytes >= 1 && bytes <= MAX_ENCODED_ID_UTF8_BYTES; + })(); +} diff --git a/packages/maplibre-particle-rn/src/FieldRuntime.ts b/packages/maplibre-particle-rn/src/FieldRuntime.ts index d6a5e71b2..edabc07a8 100644 --- a/packages/maplibre-particle-rn/src/FieldRuntime.ts +++ b/packages/maplibre-particle-rn/src/FieldRuntime.ts @@ -1,10 +1,10 @@ import { getNativeFieldRuntimeBridge } from "./NativeWeatherRuntimeBridge"; +import { hasValidEncodedIdUtf8Length } from "./EncodedFieldGuards"; import type { ColorChannel, CommandResult, EncodedColorBindingInput, EncodedFieldRuntimeMetadata, EncodedFieldSubmitResult, EncodedParticleBindingInput, FieldColorStop, FieldPayloadRefs, FieldSubmitResult, FieldValueSource, PlaybackState, ResolvedFieldProfileContractInput, RuntimeSnapshot, TargetFps } from "./types"; const MAX_SAFE = Number.MAX_SAFE_INTEGER; -const MAX_ID_LENGTH = 64; const MAX_CONTRACT_BYTES = 256 * 1024; -const encodedString = (value: string, name: string) => { if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID_LENGTH) throw new Error(`${name} must be a non-empty string of at most ${MAX_ID_LENGTH} characters`); }; +const encodedString = (value: string, name: string) => { if (!hasValidEncodedIdUtf8Length(value)) throw new Error(`${name} must be a non-empty UTF-8 string of at most 64 bytes`); }; const uint = (value: number, name: string, maximum = MAX_SAFE, positive = false) => { if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0) || value > maximum) throw new Error(`${name} must be a ${positive ? "positive" : "non-negative"} safe integer`); }; function encodedContractJson(contract: ResolvedFieldProfileContractInput): string { encodedString(contract.profileID, "profileID"); uint(contract.contractSchemaVersion, "contractSchemaVersion", 0xffff, true); if (contract.contractSchemaVersion !== 1) throw new Error("unsupported contractSchemaVersion"); uint(contract.profileVersion, "profileVersion", 0xffffffff, true); if (!Array.isArray(contract.requiredPlanes) || contract.requiredPlanes.length === 0 || contract.requiredPlanes.length > 64) throw new Error("requiredPlanes must contain 1..64 entries"); for (const p of contract.requiredPlanes) { uint(p.semanticID, "semanticID", 0xffff, true); uint(p.unitID, "unitID", 0xffff); uint(p.groupID, "groupID", 0xffff); if (!p.requiredFlag || !["SCALAR", "VELOCITY_COMPONENT", "DIRECTION_ANGLE", "VALIDITY"].includes(p.valueClass) || !["NONE", "U_EAST", "V_NORTH", "W_UP"].includes(p.component)) throw new Error("invalid required plane contract"); } const json = JSON.stringify(contract); if (new TextEncoder().encode(json).length > MAX_CONTRACT_BYTES) throw new Error("contractJson exceeds 256 KiB"); return json; } diff --git a/packages/maplibre-particle-rn/test/EncodedFieldGuards.test.ts b/packages/maplibre-particle-rn/test/EncodedFieldGuards.test.ts new file mode 100644 index 000000000..a7364fd36 --- /dev/null +++ b/packages/maplibre-particle-rn/test/EncodedFieldGuards.test.ts @@ -0,0 +1,8 @@ +import { hasValidEncodedIdUtf8Length } from "../src/EncodedFieldGuards"; + +const expect = (value: boolean): void => { if (!value) throw new Error("encoded UTF-8 ID guard failed"); }; + +expect(hasValidEncodedIdUtf8Length("a".repeat(64))); +expect(!hasValidEncodedIdUtf8Length("")); +// 33 CJK code points are within a 64-character UI limit but occupy 99 UTF-8 bytes. +expect(!hasValidEncodedIdUtf8Length("风".repeat(33))); diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index 9cf76a58d..e14ea8b82 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -37,6 +37,7 @@ #include #include #include +#include // Java -> C++ conversion #include "style/android_conversion.hpp" @@ -1425,7 +1426,7 @@ jni::jboolean NativeMapView::controlRasterArraySourceTimeline(JNIEnv& env, jni::jint NativeMapView::submitRasterArraySourceEncodedField(JNIEnv& env, const jni::String& sourceId, const jni::String& fieldKey, const jni::String& fieldId, jni::jlong generation, jni::jlong dataVersion, const jni::String& contractJson, const jni::Array& qbin) { const auto id=jni::Make(env,sourceId); auto* source=dynamic_cast(map->getStyle().getSource(id)); if(!source) return 0; // bridge-layer SOURCE_MISSING sentinel, never a Core result - if(generation<=0 || dataVersion<0 || !qbin) return static_cast(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedDecode); + if(generation<=0 || dataVersion<0 || !qbin || !mbgl::particle::ingress::isValidEncodedFieldTransportSize(qbin.Length(env))) return static_cast(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedDecode); const auto contract=mbgl::particle::parseResolvedFieldProfileContractJson(jni::Make(env,contractJson)); if(!contract) return static_cast(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedContract); std::vector bytes(qbin.Length(env)); if(!bytes.empty()) jni::GetArrayRegion(env,*qbin,0,bytes.size(),reinterpret_cast(bytes.data())); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java index eea203b45..57dd325ed 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java @@ -7,6 +7,8 @@ import android.os.Handler; import android.os.Looper; import java.util.List; +import org.json.JSONException; +import org.json.JSONObject; /** * Main-thread, low-frequency Host facade for one raster-array weather runtime. @@ -86,20 +88,28 @@ public final class WeatherRuntime { /** Isolated V4 ingress: it deliberately does not mutate V3 state/cadence/timeline. */ public EncodedFieldSubmitResult submitEncodedField(@NonNull String fieldKey, @NonNull String fieldId, long generation, long dataVersion, @NonNull String contractJson, @NonNull byte[] qbin) { requireAttached(); requireEnabled(); - if (fieldKey.isEmpty() || fieldId.isEmpty() || generation <= 0 || dataVersion < 0 || contractJson.isEmpty() || qbin.length == 0) throw new IllegalArgumentException("invalid encoded field argument"); + if (!WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(fieldKey) || !WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(fieldId) || !WeatherRuntimeV4Guards.hasValidEncodedContractUtf8Length(contractJson) || !hasValidEncodedContractProfileId(contractJson) || generation <= 0 || dataVersion < 0 || !WeatherRuntimeV4Guards.hasValidEncodedQbin(qbin)) throw new IllegalArgumentException("invalid encoded field argument"); int code = map.submitRasterArraySourceEncodedField(config.sourceId, fieldKey, fieldId, generation, dataVersion, contractJson, qbin); if (code == 0) throw new IllegalStateException("SOURCE_MISSING"); return EncodedFieldSubmitResult.fromCode(code); } - public CommandResult clearEncodedField(@NonNull String fieldKey) { requireAttached(); return fieldKey.isEmpty() ? CommandResult.REJECTED : encodedCommand(map.clearRasterArraySourceEncodedField(config.sourceId, fieldKey)); } + private static boolean hasValidEncodedContractProfileId(String contractJson) { + if (contractJson == null) return false; + try { + return WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(new JSONObject(contractJson).optString("profileID", "")); + } catch (JSONException exception) { + return false; + } + } + public CommandResult clearEncodedField(@NonNull String fieldKey) { requireAttached(); return !WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(fieldKey) ? CommandResult.REJECTED : encodedCommand(map.clearRasterArraySourceEncodedField(config.sourceId, fieldKey)); } public CommandResult clearAllEncodedFields() { requireAttached(); return encodedCommand(map.clearAllRasterArraySourceEncodedFields(config.sourceId)); } /** V4 binding intent; unit and all mode selectors reach Core unchanged. */ public CommandResult setEncodedColorBinding(@NonNull String fieldKey, int semanticID, int groupID, int valueClass, int expectedUnitID) { - requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedColorBinding(config.sourceId, fieldKey, semanticID, groupID, valueClass, expectedUnitID)); + requireAttached(); return !WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(fieldKey) ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedColorBinding(config.sourceId, fieldKey, semanticID, groupID, valueClass, expectedUnitID)); } /** ABI reserves all selector slots: inactive slots must be exactly (0, 0). */ public CommandResult setEncodedParticleBinding(@NonNull String fieldKey, int mode, int uSemanticID, int uGroupID, int vSemanticID, int vGroupID, int directionSemanticID, int directionGroupID, int expectedUnitID) { - requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedParticleBinding(config.sourceId, fieldKey, mode, uSemanticID, uGroupID, vSemanticID, vGroupID, directionSemanticID, directionGroupID, expectedUnitID)); + requireAttached(); return !WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(fieldKey) ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedParticleBinding(config.sourceId, fieldKey, mode, uSemanticID, uGroupID, vSemanticID, vGroupID, directionSemanticID, directionGroupID, expectedUnitID)); } public CommandResult setEncodedForecastTime(long timestamp) { requireAttached(); return timestamp < 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastTime(config.sourceId, timestamp)); } /** State and speed intentionally remain independent V4 Core commands. */ diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntimeV4Guards.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntimeV4Guards.java new file mode 100644 index 000000000..d43795e95 --- /dev/null +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntimeV4Guards.java @@ -0,0 +1,27 @@ +package org.maplibre.android.maps; + +import java.nio.charset.StandardCharsets; + +/** Small transport guards shared by Java facade checks; they do not define QBIN decoding. */ +final class WeatherRuntimeV4Guards { + static final int MAX_ENCODED_ID_UTF8_BYTES = 64; + static final int MAX_CONTRACT_UTF8_BYTES = 256 * 1024; + static final int MAX_ENCODED_QBIN_BYTES = 64 * 1024 * 1024; + + private WeatherRuntimeV4Guards() {} + + static boolean hasValidEncodedIdUtf8Length(String value) { + if (value == null) return false; + final int length = value.getBytes(StandardCharsets.UTF_8).length; + return length >= 1 && length <= MAX_ENCODED_ID_UTF8_BYTES; + } + + static boolean hasValidEncodedQbin(byte[] qbin) { + return qbin != null && qbin.length >= 1 && qbin.length <= MAX_ENCODED_QBIN_BYTES; + } + + static boolean hasValidEncodedContractUtf8Length(String contractJson) { + return contractJson != null && !contractJson.isEmpty() + && contractJson.getBytes(StandardCharsets.UTF_8).length <= MAX_CONTRACT_UTF8_BYTES; + } +} diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeV4GuardsTest.java b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeV4GuardsTest.java new file mode 100644 index 000000000..b17abcede --- /dev/null +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeV4GuardsTest.java @@ -0,0 +1,17 @@ +package org.maplibre.android.maps; + +/** JDK-only directed guard test; Android facade compilation remains a later Android validation. */ +public final class WeatherRuntimeV4GuardsTest { + public static void main(String[] args) { + require(WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(repeat("a", 64))); + require(!WeatherRuntimeV4Guards.hasValidEncodedIdUtf8Length(repeat("风", 33))); + require(WeatherRuntimeV4Guards.hasValidEncodedQbin(new byte[64 * 1024 * 1024])); + require(!WeatherRuntimeV4Guards.hasValidEncodedQbin(new byte[0])); + require(!WeatherRuntimeV4Guards.hasValidEncodedQbin(new byte[64 * 1024 * 1024 + 1])); + require(WeatherRuntimeV4Guards.hasValidEncodedContractUtf8Length("{}")); + require(!WeatherRuntimeV4Guards.hasValidEncodedContractUtf8Length("a".repeat(256 * 1024 + 1))); + } + + private static String repeat(String value, int count) { return value.repeat(count); } + private static void require(boolean value) { if (!value) throw new AssertionError("V4 transport guard failed"); } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4424e7a4b..78ee3e7f0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -22,6 +22,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_profile_contract_json.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp + ${PROJECT_SOURCE_DIR}/test/particle/encoded_field_transport.test.cpp ${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/pattern_atlas.test.cpp diff --git a/test/particle/encoded_field_transport.test.cpp b/test/particle/encoded_field_transport.test.cpp new file mode 100644 index 000000000..c697422b8 --- /dev/null +++ b/test/particle/encoded_field_transport.test.cpp @@ -0,0 +1,13 @@ +#include + +#include + +namespace mbgl::particle::ingress { + +TEST(ParticleEncodedFieldTransport, RejectsEmptyAndOversizedBeforeCopy) { + EXPECT_FALSE(isValidEncodedFieldTransportSize(0)); + EXPECT_TRUE(isValidEncodedFieldTransportSize(MaxEncodedFieldTransportBytes)); + EXPECT_FALSE(isValidEncodedFieldTransportSize(MaxEncodedFieldTransportBytes + 1u)); +} + +} // namespace mbgl::particle::ingress diff --git a/粒子开发记录.md b/粒子开发记录.md index f97f968de..4032f30c7 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -779,3 +779,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - frozen contract schema 改为 Core 同名 `contractSchemaVersion` / `requiredFlag` 等字段,并抽取为可 Linux 测试的 provider-neutral C++ parser;新增合法与所有指定 fail-closed case 单测。 - Color binding 端到端传递 expected unit;Particle binding 固化 mode-aware U/V/direction slots,inactive slot 仅 `(0,0)`;forecast state/speed 为完全独立 Core command。 - Linux 限流 runner build、parser/Source V4/Binding/QBIN/Runtime/V3 Source 回归、TypeScript、TurboModule codegen、JNI declaration-registration 静态核对和 `git diff --check` 均 PASS。Android Gradle/APK/ADB/JNI 真实编译、Jest、iOS/Metal 未执行,等待后续一次性 Android 验收;未进入 STEP-079。 + +### 2026-09-13 STEP-078 v3 production guard 审计收口 + +- QBIN transport 的 1..64 MiB 上限现由 Kotlin local-file、Java `WeatherRuntime` facade、JNI copy 三层执行;JNI 在 `std::vector` 分配前 fail-closed。Decoder 的 64 MiB file / 32 MiB decoded-raw 业务限制不变。 +- fieldKey、runtime fieldID、profileID 统一按 UTF-8 byte length 1..64 验证:RN `TextEncoder`、Kotlin `toByteArray(Charsets.UTF_8)`、Java `StandardCharsets.UTF_8`。多字节 33 CJK(99 bytes)输入在 RN/Java guard tests 中拒绝。 +- `particle-codex` 内所有限流验证均 PASS:package tsc、TS directed test、TurboModule schema、JDK-only Java guard、Linux runner rebuild、V4/V3 指定 filter、JNI name-set 与 `git diff --check`。Android Gradle/APK/ADB/JNI 真实编译、设备、Jest、iOS/Metal 仍未运行;未改 Renderer/GPU/shader/Test Host/NavSea,不进入 STEP-079。