diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index 15c18360..719c7a91 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -1,6 +1,16 @@ name: package-publish -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + npm_dist_tag: + description: npm dist-tag + required: true + default: next + type: choice + options: + - next + - latest jobs: package-publish: @@ -9,11 +19,15 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '24.x' - registry-url: 'https://registry.npmjs.org' + node-version: "24.x" + registry-url: "https://registry.npmjs.org" + - name: Validate npm dist-tag + env: + NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} + run: node scripts/validate-npm-dist-tag.mjs "$NPM_DIST_TAG" - name: Install Package run: corepack enable && yarn install - - name: + - name: Publish packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: yarn version:publish + run: yarn version:publish --tag "${{ inputs.npm_dist_tag }}" diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 250a0841..3d70fd90 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index a3be31b4..bef06f40 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -158,5 +158,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 91d1482e..28fb186f 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index f22c9fba..70faa341 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -164,5 +164,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index e058e9a3..52b3af59 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -167,5 +167,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 0decf256..e8fde4b1 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index bde1bac5..7cb99734 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 694d79cd..c2eba719 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 0a54eed0..98a816fc 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -89,5 +89,6 @@ "RNCloudFs": "CloudFs" } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 13a83cc3..50b74f5d 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 72b307e7..d30742c5 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index e2f5d444..e9aaa2fe 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -164,5 +164,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index 1fed74f7..0c915521 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 6c192c5a..14b56d31 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index af147ac7..758e4610 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -168,5 +168,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 575d3ea9..84b0bffe 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -164,5 +164,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 29133d4d..3bc6f1fc 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -142,5 +142,6 @@ } } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index bb70d030..bb238f13 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -158,5 +158,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 269ede92..5d33067c 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 89ccf862..16a5c7b6 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 4402fa8d..f53341b3 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -89,5 +89,6 @@ "RNReactNativePing": "Ping" } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec index c5ed2197..9d60ca17 100644 --- a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec +++ b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec @@ -15,6 +15,7 @@ Pod::Spec.new do |s| s.source_files = [ "ios/**/*.{swift}", + "ios/**/*.{h}", "ios/**/*.{m,mm}", "cpp/**/*.{hpp,cpp}", ] @@ -22,6 +23,12 @@ Pod::Spec.new do |s| s.dependency 'React-jsi' s.dependency 'React-callinvoker' s.dependency 'ReactNativeNativeLogger' + s.public_header_files = "ios/FirmwareArchiveMinizipBridge.h" + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"', + } + + s.dependency 'SSZipArchive', '2.5.5' load 'nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb' add_nitrogen_files(s) diff --git a/native-modules/react-native-range-downloader/SPEC.md b/native-modules/react-native-range-downloader/SPEC.md index 0eca36b4..cbd3d0ca 100644 --- a/native-modules/react-native-range-downloader/SPEC.md +++ b/native-modules/react-native-range-downloader/SPEC.md @@ -1,11 +1,12 @@ # OneKey Concurrent Download Standard (OCDS) -- **Version:** 1.2 +- **Version:** 1.4 - **Status:** Active -- **Last updated:** 2026-06-20 +- **Last updated:** 2026-08-07 - **Applies to:** every implementation of OneKey's concurrent (multi-range) downloader — iOS (Swift), Android (Kotlin), Desktop (Node/Electron), and any - future platform. + future platform — plus the firmware artifact staging APIs described under the + trust boundary. This document defines the behavior a concurrent downloader is expected to provide. It is platform-agnostic: implementations differ in language and @@ -285,6 +286,28 @@ existing obligations made explicit here, not new mechanism inside this module: - Artifacts are stored in an app-private location. - Anti-rollback / version-monotonicity is the consumer's responsibility. +### Firmware artifact staging APIs + +The firmware artifact APIs permit `expectedSize`, `expectedSha256`, and archive +`expectedEntries` to be omitted for legacy inputs. Omitting these fields changes +which checks the module can perform; it does not turn a locally computed digest +into a trusted expectation: + +- `maxBytes`, HTTPS/TLS validation, bounded streaming, and archive structural + safety checks remain mandatory. +- Without `expectedSha256`, the module computes SHA-256 only as a content-addressed + local artifact reference. It does not prove publisher integrity or authenticity. +- Every artifact receipt exposes `expectedSha256Verified`; it is `true` only when + the returned digest was compared with caller-supplied `expectedSha256`. A locally + computed digest returned without that expectation is explicitly marked `false`. +- Without `expectedEntries`, archive names, types, uniqueness, non-emptiness, and + size/expansion bounds are still validated, but entry digests are not compared + with publisher-supplied values. +- A caller may omit trusted expectations only when a downstream consumer performs + an independent authenticity check before use, or when an explicit product + policy accepts that legacy artifact. The native module does not make that + product-policy decision. + --- ## 6. Conformance scenarios @@ -325,6 +348,11 @@ own notes until closed. **This document records no implementation's state.** ## Appendix B. Change log +- **1.4** (2026-08-07) — Added an explicit receipt signal distinguishing a + caller-supplied SHA-256 match from a locally computed content-address digest. +- **1.3** (2026-08-07) — Documented the optional integrity boundary for firmware + artifact staging. Missing expectations retain transport and structural safety + checks but delegate publisher integrity/authenticity acceptance to the caller. - **1.2** (2026-06-22) — Removed the "retry once via single-stream" requirement on a whole-file checksum/signature mismatch (§4 failure table, §6 scenario 6). A mismatch after assembly is now simply Permanent → discard + terminal failure. diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt new file mode 100644 index 00000000..88650c2a --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt @@ -0,0 +1,291 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.text.Normalizer +import java.util.Locale + +internal data class FirmwareArchiveCentralEntry( + val name: String, + val compressedSize: Long, + val uncompressedSize: Long, + val crc32: Long, + val flags: Int, + val compressionMethod: Int, + val versionMadeBy: Int, + val externalAttributes: Long, + val diskNumber: Int, +) + +internal object FirmwareArchiveRules { + private const val MAX_EOCD_SEARCH_BYTES = 65_557 + private const val EOCD_SIGNATURE = 0x06054b50L + private const val CENTRAL_ENTRY_SIGNATURE = 0x02014b50L + private const val MAX_ARCHIVE_ENTRIES = 4096 + private const val MAX_ARCHIVE_ENTRY_BYTES = 128L * 1024 * 1024 + private const val MAX_ARCHIVE_EXPANDED_BYTES = 512L * 1024 * 1024 + private val nestedArchiveExtensions = setOf( + ".zip", + ".7z", + ".rar", + ".tar", + ".gz", + ".tgz", + ) + private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") + + fun validateRequirements( + expectedEntries: Array, + ): List { + require(expectedEntries.isNotEmpty() && expectedEntries.size <= MAX_ARCHIVE_ENTRIES) { + "Firmware archive expected entry count is invalid" + } + val names = mutableSetOf() + val canonicalNames = mutableSetOf() + var totalSize = 0L + expectedEntries.forEach { entry -> + require( + entry.artifactId.isNotEmpty() && + entry.artifactId.length <= 160 && + entry.artifactId.all { it.isLetterOrDigit() || it in "._-" } + ) { + "Firmware archive artifactId is invalid" + } + require( + names.add(entry.entryName) && + validatePortableName(entry.entryName, canonicalNames) + ) { + "Firmware archive expected entry name is invalid" + } + val expectedSize = entry.expectedSize.toExactPositiveLong("entry size") + require(expectedSize <= MAX_ARCHIVE_ENTRY_BYTES) { + "Firmware archive entry exceeds size limits" + } + totalSize = Math.addExact(totalSize, expectedSize) + require(totalSize <= MAX_ARCHIVE_EXPANDED_BYTES) { + "Firmware archive expected entries exceed size limits" + } + require(sha256Pattern.matches(entry.expectedSha256)) { + "Firmware archive entry SHA-256 is invalid" + } + } + return expectedEntries.toList() + } + + fun validateCentralDirectory( + file: File, + requirements: List?, + ): List { + val entries = scanCentralDirectory(file) + require(requirements == null || entries.size == requirements.size) { + "Firmware archive has missing or extra entries" + } + val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty() + val names = mutableSetOf() + val canonicalNames = mutableSetOf() + var totalSize = 0L + entries.forEach { entry -> + val requirement = requirementsByName[entry.name] + require(requirements == null || requirement != null) { + "Firmware archive contains an unexpected entry" + } + val expectedSize = requirement?.expectedSize?.toLong() + ?: entry.uncompressedSize + totalSize = Math.addExact(totalSize, entry.uncompressedSize) + require( + names.add(entry.name) && + validatePortableName(entry.name, canonicalNames) && + entry.uncompressedSize == expectedSize && + entry.uncompressedSize > 0 && + entry.uncompressedSize <= MAX_ARCHIVE_ENTRY_BYTES && + totalSize <= MAX_ARCHIVE_EXPANDED_BYTES && + entry.compressedSize in 0..MAX_ARCHIVE_EXPANDED_BYTES && + entry.uncompressedSize <= + Math.multiplyExact(entry.compressedSize.coerceAtLeast(1), 1000) && + entry.diskNumber == 0 && + entry.flags and 1 == 0 && + (entry.compressionMethod == 0 || entry.compressionMethod == 8) && + isRegularEntry(entry) + ) { + "Firmware archive entry metadata is invalid" + } + } + return entries + } + + private fun scanCentralDirectory(file: File): List { + require(file.isFile && file.length() >= 22) { + "Firmware archive is invalid" + } + RandomAccessFile(file, "r").use { archive -> + val tailSize = minOf(file.length(), MAX_EOCD_SEARCH_BYTES.toLong()).toInt() + val tail = ByteArray(tailSize) + archive.seek(file.length() - tailSize) + archive.readFully(tail) + val eocdIndex = findSignatureBackwards(tail, EOCD_SIGNATURE) + require(eocdIndex >= 0 && eocdIndex + 22 <= tail.size) { + "Firmware archive EOCD is missing" + } + val eocd = ByteBuffer.wrap(tail, eocdIndex, tail.size - eocdIndex) + .order(ByteOrder.LITTLE_ENDIAN) + require(eocd.int.toUnsignedLong() == EOCD_SIGNATURE) { + "Firmware archive EOCD is invalid" + } + val diskNumber = eocd.short.toUnsignedInt() + val centralDisk = eocd.short.toUnsignedInt() + val entriesOnDisk = eocd.short.toUnsignedInt() + val totalEntries = eocd.short.toUnsignedInt() + val centralSize = eocd.int.toUnsignedLong() + val centralOffset = eocd.int.toUnsignedLong() + val commentLength = eocd.short.toUnsignedInt() + require( + diskNumber == 0 && + centralDisk == 0 && + entriesOnDisk == totalEntries && + totalEntries in 1..MAX_ARCHIVE_ENTRIES && + totalEntries != 0xffff && + centralSize != 0xffff_ffffL && + centralOffset != 0xffff_ffffL && + eocdIndex + 22 + commentLength == tail.size && + centralOffset <= file.length() - centralSize && + centralOffset + centralSize <= file.length() - tailSize + eocdIndex + ) { + "Firmware archive is multi-disk, ZIP64, or malformed" + } + + archive.seek(centralOffset) + val entries = ArrayList(totalEntries) + repeat(totalEntries) { + val header = ByteArray(46) + archive.readFully(header) + val buffer = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN) + require(buffer.int.toUnsignedLong() == CENTRAL_ENTRY_SIGNATURE) { + "Firmware archive central entry is malformed" + } + val versionMadeBy = buffer.short.toUnsignedInt() + buffer.short + val flags = buffer.short.toUnsignedInt() + val method = buffer.short.toUnsignedInt() + buffer.position(buffer.position() + 4) + val crc32 = buffer.int.toUnsignedLong() + val compressedSize = buffer.int.toUnsignedLong() + val uncompressedSize = buffer.int.toUnsignedLong() + val nameLength = buffer.short.toUnsignedInt() + val extraLength = buffer.short.toUnsignedInt() + val entryCommentLength = buffer.short.toUnsignedInt() + val entryDisk = buffer.short.toUnsignedInt() + buffer.short + val externalAttributes = buffer.int.toUnsignedLong() + val localHeaderOffset = buffer.int.toUnsignedLong() + require( + nameLength in 1..4096 && + compressedSize != 0xffff_ffffL && + uncompressedSize != 0xffff_ffffL && + entryDisk != 0xffff && + localHeaderOffset != 0xffff_ffffL && + localHeaderOffset < centralOffset + ) { + "Firmware archive ZIP64 entry is not supported" + } + val nameBytes = ByteArray(nameLength) + archive.readFully(nameBytes) + val name = decodeEntryName(nameBytes, flags) + val skipLength = Math.addExact(extraLength, entryCommentLength) + archive.seek(Math.addExact(archive.filePointer, skipLength.toLong())) + entries += FirmwareArchiveCentralEntry( + name = name, + compressedSize = compressedSize, + uncompressedSize = uncompressedSize, + crc32 = crc32, + flags = flags, + compressionMethod = method, + versionMadeBy = versionMadeBy, + externalAttributes = externalAttributes, + diskNumber = entryDisk, + ) + } + require(archive.filePointer == centralOffset + centralSize) { + "Firmware archive central directory size mismatch" + } + return entries + } + } + + private fun decodeEntryName(bytes: ByteArray, flags: Int): String { + if (flags and (1 shl 11) == 0) { + require(bytes.all { it.toInt() and 0xff < 0x80 }) { + "Firmware archive entry name must be UTF-8 or ASCII" + } + return String(bytes, StandardCharsets.US_ASCII) + } + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return decoder.decode(ByteBuffer.wrap(bytes)).toString() + } + + private fun validatePortableName( + name: String, + canonicalNames: MutableSet, + ): Boolean { + val normalized = Normalizer.normalize(name, Normalizer.Form.NFC) + val folded = normalized.lowercase(Locale.ROOT) + val components = name.split('/') + return name.isNotEmpty() && + name.length <= 512 && + name == normalized && + !name.startsWith("/") && + !name.startsWith("\\") && + '\\' !in name && + ':' !in name && + name.none { it.code < 0x20 || it.code == 0x7f } && + components.all { + it.isNotEmpty() && + it != "." && + it != ".." && + !it.endsWith(".") && + !it.endsWith(" ") + } && + nestedArchiveExtensions.none { folded.endsWith(it) } && + canonicalNames.add(folded) + } + + private fun isRegularEntry(entry: FirmwareArchiveCentralEntry): Boolean { + val hostSystem = entry.versionMadeBy ushr 8 + if (hostSystem == 3 || hostSystem == 19) { + val fileType = (entry.externalAttributes ushr 16) and 0xf000 + return fileType == 0L || fileType == 0x8000L + } + return entry.externalAttributes and 0x10 == 0L + } + + private fun findSignatureBackwards(bytes: ByteArray, signature: Long): Int { + for (index in bytes.size - 4 downTo 0) { + val value = (bytes[index].toLong() and 0xff) or + ((bytes[index + 1].toLong() and 0xff) shl 8) or + ((bytes[index + 2].toLong() and 0xff) shl 16) or + ((bytes[index + 3].toLong() and 0xff) shl 24) + if (value == signature) return index + } + return -1 + } + + private fun Int.toUnsignedLong(): Long = toLong() and 0xffff_ffffL + + private fun Short.toUnsignedInt(): Int = toInt() and 0xffff + + private fun Double.toExactPositiveLong(label: String): Long { + require(isFinite() && this > 0 && this <= Long.MAX_VALUE.toDouble()) { + "Invalid firmware archive $label" + } + val converted = toLong() + require(converted.toDouble() == this) { + "Invalid firmware archive $label" + } + return converted + } +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt new file mode 100644 index 00000000..1a92965a --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt @@ -0,0 +1,111 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File + +internal const val FIRMWARE_ARTIFACT_FINAL_GRACE_MS = 24L * 60 * 60 * 1000 +internal const val FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS = 7L * 24 * 60 * 60 * 1000 +internal const val FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS = + FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS + +private val firmwareArtifactSha256Pattern = Regex("^[a-f0-9]{64}$") +private val firmwareArchiveScratchPattern = Regex( + "^archive-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", +) +private val firmwarePromoteScratchPattern = Regex( + "^\\.promote-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", +) + +private fun File.isSymbolicLinkEntry(): Boolean = try { + val canonicalParent = parentFile?.canonicalFile + val entryFromCanonicalParent = canonicalParent?.let { File(it, name) } ?: this + entryFromCanonicalParent.canonicalFile != entryFromCanonicalParent.absoluteFile +} catch (_: Exception) { + true +} + +private fun firmwareArtifactEntrySize(entry: File): Long { + if (entry.isSymbolicLinkEntry()) return 0 + if (entry.isFile) return entry.length() + if (!entry.isDirectory) return 0 + return entry.listFiles()?.sumOf(::firmwareArtifactEntrySize) ?: 0 +} + +private fun deleteFirmwareArtifactEntry(entry: File): Boolean { + if (entry.isSymbolicLinkEntry()) return entry.delete() + if (entry.isDirectory) { + val children = entry.listFiles() ?: return false + if (children.any { !deleteFirmwareArtifactEntry(it) }) return false + } + return entry.delete() +} + +private fun firmwareArtifactEntryExceededGrace( + entry: File, + nowMs: Long, + graceMs: Long, +): Boolean { + val modifiedAt = entry.lastModified() + return modifiedAt > 0 && modifiedAt <= nowMs && nowMs - modifiedAt >= graceMs +} + +internal fun sweepFirmwareArtifactOrphansAtRoot( + root: File, + retainedSha256: Set, + activeSha256: Set, + openPaths: Set, + nowMs: Long = System.currentTimeMillis(), +): Pair { + var deletedFiles = 0 + var deletedBytes = 0L + root.listFiles()?.forEach { entry -> + val isSymbolicLink = entry.isSymbolicLinkEntry() + val isArchiveScratch = firmwareArchiveScratchPattern.matches(entry.name) + val isPromoteScratch = firmwarePromoteScratchPattern.matches(entry.name) + val isScratchCandidate = !isSymbolicLink && + ((isArchiveScratch && entry.isDirectory) || + (isPromoteScratch && entry.isFile)) + if (isScratchCandidate) { + if ( + firmwareArtifactEntryExceededGrace( + entry, + nowMs, + FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS, + ) + ) { + val size = firmwareArtifactEntrySize(entry) + if (deleteFirmwareArtifactEntry(entry)) { + deletedFiles += 1 + deletedBytes += size + } + } + return@forEach + } + + if (!entry.isFile || isSymbolicLink || entry.name.length < 64) return@forEach + val sha256 = entry.name.take(64) + if ( + !firmwareArtifactSha256Pattern.matches(sha256) || + sha256 in retainedSha256 || + sha256 in activeSha256 || + entry.absolutePath in openPaths + ) { + return@forEach + } + val grace = if (entry.name.endsWith(".bin")) { + FIRMWARE_ARTIFACT_FINAL_GRACE_MS + } else if (entry.name.endsWith(".partial")) { + FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS + } else { + return@forEach + } + if (!firmwareArtifactEntryExceededGrace(entry, nowMs, grace)) return@forEach + val size = entry.length() + if (entry.delete()) { + deletedFiles += 1 + deletedBytes += size + } + } + return deletedFiles to deletedBytes +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt new file mode 100644 index 00000000..72a9e355 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -0,0 +1,822 @@ +package com.margelo.nitro.reactnativerangedownloader + +import android.system.Os +import com.margelo.nitro.NitroModules +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.zip.ZipInputStream +import javax.net.ssl.SSLException +import kotlin.math.ceil +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +private const val DEFAULT_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS = 180.0 +private const val MAX_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS = 24.0 * 60 * 60 + +internal fun validateFirmwareDownloadDeadlineSeconds(value: Double?): Double { + val deadline = value ?: DEFAULT_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS + require( + deadline.isFinite() && + deadline > 0 && + deadline <= MAX_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS + ) { + "Invalid firmware download deadline" + } + return deadline +} + +internal data class StoredFirmwareArtifact( + val artifactRef: String, + val size: Long, + val sha256: String, + val file: File, +) + +internal data class StoredFirmwareArchiveEntry( + val entryName: String, + val artifact: StoredFirmwareArtifact, +) + +private data class StagedFirmwareArchiveEntry( + val entryName: String, + val size: Long, + val sha256: String, + val file: File, +) + +private data class FirmwareDownloadKey( + val transactionId: String, + val taskId: String, + val downloadToken: String, +) + +private class FirmwareDownloadLock { + val monitor = Any() + var references = 0 +} + +internal object FirmwareArtifactStore { + const val MAX_READ_BYTES = 256 * 1024 + + const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024 + private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") + private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$") + private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$") + private val identifierPattern = Regex("^[A-Za-z0-9._:-]{1,160}$") + private val downloadLocks = + ConcurrentHashMap() + private val activeCalls = + ConcurrentHashMap>() + private val cancelledTransactions = + ConcurrentHashMap.newKeySet() + private val activeDownloadLock = Any() + private val activeDownloadCounts = mutableMapOf() + private val leaseLock = Any() + private val readerLock = Any() + private val readers = mutableMapOf() + private val leases = mutableMapOf() + + private data class LeaseState( + val transactionId: String, + val artifactRefs: MutableSet, + ) + + private data class OpenReader( + val file: File, + val handle: RandomAccessFile, + val size: Long, + ) + + private val root: File + get() { + val context = NitroModules.applicationContext + ?: error("Application context is not available") + return File(context.filesDir, "onekey-firmware-artifacts").also { + check(it.exists() || it.mkdirs()) { + "Firmware artifact directory cannot be created" + } + } + } + + fun download(params: FirmwareArtifactDownloadParams): StoredFirmwareArtifact { + val validated = validateDownloadParams(params) + check(!cancelledTransactions.contains(params.transactionId)) { + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + } + validated.expectedSha256?.let { + retainExpectedArtifact( + leaseRef = params.leaseRef, + transactionId = params.transactionId, + artifactRef = "fw:$it", + ) + } ?: requireLeaseTransaction(params.leaseRef, params.transactionId) + val lockKey = FirmwareDownloadKey( + params.transactionId, + params.taskId, + validated.downloadToken, + ) + val downloadLock = downloadLocks.compute(lockKey) { _, current -> + (current ?: FirmwareDownloadLock()).also { + it.references += 1 + } + } ?: error("Firmware artifact lock is unavailable") + markDownloadActive(validated.downloadToken, 1) + try { + val artifact = synchronized(downloadLock.monitor) { + check(!cancelledTransactions.contains(params.transactionId)) { + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + } + downloadLocked(params, validated) + } + retainExpectedArtifact( + leaseRef = params.leaseRef, + transactionId = params.transactionId, + artifactRef = artifact.artifactRef, + ) + return artifact + } finally { + markDownloadActive(validated.downloadToken, -1) + downloadLocks.compute(lockKey) { _, current -> + if (current !== downloadLock) { + current + } else { + current.references -= 1 + current.takeIf { it.references > 0 } + } + } + } + } + + fun cancelDownloads(transactionId: String) { + require(identifierPattern.matches(transactionId)) { + "Invalid firmware transactionId" + } + cancelledTransactions.add(transactionId) + activeCalls[transactionId] + ?.toList() + ?.forEach { it.cancel() } + } + + fun discard(artifactRef: String) { + val file = resolveArtifactFile(artifactRef) + synchronized(leaseLock) { + require(leases.values.none { artifactRef in it.artifactRefs }) { + "ARTIFACT_LEASED: firmware artifact is retained" + } + } + synchronized(readerLock) { + require(readers.none { it.value.file == file }) { + "ARTIFACT_BUSY: firmware artifact has an open reader" + } + } + if (file.exists() && !file.delete()) { + error("Firmware artifact cannot be discarded") + } + } + + fun open(artifactRef: String): Pair { + val file = resolveArtifactFile(artifactRef) + val size = file.length() + require(size > 0) { "Firmware artifact is empty" } + require(hashFile(file) == artifactRef.removePrefix("fw:")) { + "Firmware artifact SHA-256 mismatch" + } + val readerId = UUID.randomUUID().toString() + synchronized(readerLock) { + readers[readerId] = OpenReader(file, RandomAccessFile(file, "r"), size) + } + return readerId to size + } + + fun read(readerId: String, offset: Long, length: Int): ByteArray { + require(offset >= 0 && length in 1..MAX_READ_BYTES) { + "Invalid firmware artifact read" + } + return synchronized(readerLock) { + val reader = readers[readerId] ?: error("Firmware artifact reader is invalid") + require(offset <= reader.size - length) { + "Firmware artifact read is out of bounds" + } + val data = ByteArray(length) + reader.handle.seek(offset) + reader.handle.readFully(data) + data + } + } + + fun close(readerId: String) { + synchronized(readerLock) { + readers.remove(readerId)?.handle?.close() + } + } + + fun materializeArchive( + leaseRef: String, + artifactRef: String, + expectedEntries: Array?, + ): List { + requireLease(leaseRef) + val archiveFile = resolveArtifactFile(artifactRef) + val requirements = expectedEntries?.let { + FirmwareArchiveRules.validateRequirements(it) + } + val centralEntries = FirmwareArchiveRules.validateCentralDirectory( + archiveFile, + requirements, + ) + val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty() + val centralEntriesByName = centralEntries.associateBy { it.name } + val centralNames = centralEntries.mapTo(mutableSetOf()) { it.name } + val scratchDir = File(root, "archive-${UUID.randomUUID()}") + check(scratchDir.mkdirs()) { "Firmware archive scratch directory cannot be created" } + try { + val staged = mutableListOf() + val entryNames = mutableSetOf() + ZipInputStream(BufferedInputStream(FileInputStream(archiveFile))).use { zip -> + while (true) { + val zipEntry = zip.nextEntry ?: break + require(!zipEntry.isDirectory) { + "Firmware archive contains an unexpected directory" + } + val centralEntry = centralEntriesByName[zipEntry.name] + ?: error("Firmware archive contains an unexpected entry") + val requirement = requirementsByName[zipEntry.name] + require( + centralNames.contains(zipEntry.name) && + entryNames.add(zipEntry.name) + ) { + "Firmware archive contains a duplicate or mismatched entry" + } + val expectedSize = requirement?.expectedSize?.toLong() + ?: centralEntry.uncompressedSize + val expectedSha256 = requirement?.expectedSha256?.lowercase() + val scratchFile = File(scratchDir, "${staged.size}.entry") + val digest = MessageDigest.getInstance("SHA-256") + var entrySize = 0L + RandomAccessFile(scratchFile, "rw").use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = zip.read(buffer) + if (count < 0) break + if (count == 0) continue + entrySize = Math.addExact(entrySize, count.toLong()) + require(entrySize <= expectedSize) { + "Firmware archive entry exceeds its expected size" + } + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + } + output.fd.sync() + } + val sha256 = digest.digest().toHex() + require( + entrySize == expectedSize && + (expectedSha256 == null || sha256 == expectedSha256) + ) { + "Firmware archive entry integrity mismatch" + } + staged += StagedFirmwareArchiveEntry( + entryName = zipEntry.name, + size = entrySize, + sha256 = sha256, + file = scratchFile, + ) + zip.closeEntry() + } + } + require(entryNames == centralNames) { + "Firmware archive has missing or extra entries" + } + return staged.map { entry -> + val destination = artifactFile(entry.sha256) + val stored = validateStoredArtifactOrNull( + destination, + entry.size, + entry.sha256, + ) ?: run { + promoteAtomically(entry.file, destination) + StoredFirmwareArtifact( + "fw:${entry.sha256}", + entry.size, + entry.sha256, + destination, + ) + } + retainExpectedArtifact( + leaseRef = leaseRef, + transactionId = null, + artifactRef = stored.artifactRef, + ) + StoredFirmwareArchiveEntry(entry.entryName, stored) + } + } finally { + scratchDir.deleteRecursively() + } + } + + private data class ValidatedDownload( + val expectedSize: Long?, + val maxBytes: Long, + val expectedSha256: String?, + val downloadToken: String, + val overallDeadlineSeconds: Double, + ) + + private fun validateDownloadParams( + params: FirmwareArtifactDownloadParams, + ): ValidatedDownload { + require( + params.taskId.isNotEmpty() && + params.taskId.length <= 100 && + params.taskId.all { it.isLetterOrDigit() || it in "._-" } + ) { + "Invalid firmware taskId" + } + require(identifierPattern.matches(params.transactionId)) { + "Invalid firmware transactionId" + } + require(identifierPattern.matches(params.artifactId)) { + "Invalid firmware artifactId" + } + require(leaseRefPattern.matches(params.leaseRef)) { + "Invalid firmware leaseRef" + } + val url = params.url.toHttpUrlOrNull() + ?: throw IllegalArgumentException("Invalid firmware URL") + require( + url.isHttps && + url.port == 443 && + url.username.isEmpty() && + url.password.isEmpty() && + url.fragment == null + ) { + "Firmware URL must use HTTPS port 443" + } + val expectedSize = params.expectedSize?.toExactPositiveLong("expectedSize") + val maxBytes = params.maxBytes.toExactPositiveLong("maxBytes") + require( + maxBytes <= MAX_ARTIFACT_BYTES && + (expectedSize == null || expectedSize <= maxBytes) + ) { + "Invalid firmware maxBytes" + } + val expectedSha256 = params.expectedSha256?.lowercase() + require(expectedSha256 == null || sha256Pattern.matches(expectedSha256)) { + "Invalid firmware artifact SHA-256" + } + val overallDeadlineSeconds = + validateFirmwareDownloadDeadlineSeconds(params.overallDeadlineSeconds) + require(params.routeType == "domain") { + "Invalid firmware route type" + } + require(params.resolvedIp == null) { + "Domain route must not include resolvedIp" + } + return ValidatedDownload( + expectedSize = expectedSize, + maxBytes = maxBytes, + expectedSha256 = expectedSha256, + downloadToken = expectedSha256 ?: sha256(params.url), + overallDeadlineSeconds = overallDeadlineSeconds, + ) + } + + private fun downloadLocked( + params: FirmwareArtifactDownloadParams, + validated: ValidatedDownload, + ): StoredFirmwareArtifact { + validated.expectedSha256?.let { expectedSha256 -> + val finalFile = artifactFile(expectedSha256) + validateDownloadedArtifactOrNull( + finalFile, + validated.expectedSize, + expectedSha256, + validated.maxBytes, + )?.let { return it } + } + + val transactionToken = sha256(params.transactionId).take(16) + val partialFile = File( + root, + "${validated.downloadToken}.${params.taskId}.$transactionToken.partial", + ) + if (validated.expectedSha256 == null && partialFile.length() > 0) { + check(partialFile.delete()) { "Unverified firmware partial cannot be removed" } + } else if (partialFile.length() > validated.maxBytes) { + check(partialFile.delete()) { "Invalid firmware partial cannot be removed" } + } + if ( + validated.expectedSize != null && + partialFile.length() == validated.expectedSize + ) { + validateDownloadedArtifactOrNull( + partialFile, + validated.expectedSize, + validated.expectedSha256, + validated.maxBytes, + )?.let { + val finalFile = artifactFile(it.sha256) + promoteAtomically(partialFile, finalFile) + return StoredFirmwareArtifact( + it.artifactRef, + it.size, + it.sha256, + finalFile, + ) + } + check(partialFile.delete()) { "Invalid firmware partial cannot be removed" } + } + + val resumeOffset = partialFile.length() + val requestBuilder = Request.Builder() + .url(params.url) + .header("Accept-Encoding", "identity") + if (resumeOffset > 0) { + requestBuilder.header("Range", "bytes=$resumeOffset-") + } + + val client = OkHttpClient.Builder() + .protocols(listOf(Protocol.HTTP_1_1)) + .followRedirects(false) + .followSslRedirects(false) + .build() + val call = client.newCall(requestBuilder.build()) + call.timeout().timeout( + ceil(validated.overallDeadlineSeconds * 1000).toLong(), + TimeUnit.MILLISECONDS, + ) + + registerCall(params.transactionId, call) + try { + call.execute().use { + writeResponseToPartial( + it, + partialFile, + resumeOffset, + validated.expectedSize, + validated.maxBytes, + ) + } + } catch (error: IOException) { + if ( + call.isCanceled() || + cancelledTransactions.contains(params.transactionId) + ) { + throw IllegalStateException( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled", + error, + ) + } + if (generateSequence(error) { it.cause }.any { it is SSLException }) { + throw IllegalStateException( + "ARTIFACT_TLS_FAILED: firmware TLS validation failed", + error, + ) + } + throw IllegalStateException( + "ARTIFACT_NETWORK_FAILED: firmware request failed", + error, + ) + } finally { + unregisterCall(params.transactionId, call) + } + check(!cancelledTransactions.contains(params.transactionId)) { + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + } + + val artifact = try { + validateDownloadedArtifact( + partialFile, + validated.expectedSize, + validated.expectedSha256, + validated.maxBytes, + ) + } catch (error: Throwable) { + partialFile.delete() + throw error + } + val finalFile = artifactFile(artifact.sha256) + promoteAtomically(partialFile, finalFile) + return StoredFirmwareArtifact( + artifact.artifactRef, + artifact.size, + artifact.sha256, + finalFile, + ) + } + + private fun writeResponseToPartial( + response: Response, + partialFile: File, + resumeOffset: Long, + expectedSize: Long?, + maxBytes: Long, + ) { + require(response.code == 200 || response.code == 206) { + "ARTIFACT_HTTP_${response.code}: firmware request failed" + } + val append = resumeOffset > 0 && response.code == 206 + if (response.code == 206) { + require( + validateContentRange( + response.header("Content-Range"), + if (append) resumeOffset else 0, + expectedSize, + maxBytes, + ) + ) { + "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" + } + } + val body = response.body ?: error("Firmware response has no body") + RandomAccessFile(partialFile, "rw").use { output -> + if (append) { + output.seek(resumeOffset) + } else { + output.setLength(0) + } + var written = if (append) resumeOffset else 0L + val source = body.source() + val buffer = ByteArray(64 * 1024) + while (true) { + val count = source.read(buffer) + if (count < 0) break + if (count == 0) continue + written += count + require(written <= maxBytes) { + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + } + output.write(buffer, 0, count) + } + output.fd.sync() + } + } + + private fun validateContentRange( + value: String?, + expectedStart: Long, + expectedTotal: Long?, + maxBytes: Long, + ): Boolean { + val match = value + ?.lowercase() + ?.let { Regex("^bytes ([0-9]+)-([0-9]+)/([0-9]+)$").matchEntire(it) } + ?: return false + val start = match.groupValues[1].toLongOrNull() ?: return false + val end = match.groupValues[2].toLongOrNull() ?: return false + val total = match.groupValues[3].toLongOrNull() ?: return false + return start == expectedStart && + end >= start && + end < total && + (expectedTotal?.let { total == it } ?: (total in 1..maxBytes)) + } + + private fun validateDownloadedArtifactOrNull( + file: File, + expectedSize: Long?, + expectedSha256: String?, + maxBytes: Long, + ): StoredFirmwareArtifact? = try { + validateDownloadedArtifact(file, expectedSize, expectedSha256, maxBytes) + } catch (_: Throwable) { + null + } + + private fun validateDownloadedArtifact( + file: File, + expectedSize: Long?, + expectedSha256: String?, + maxBytes: Long, + ): StoredFirmwareArtifact { + val size = file.length() + require( + file.isFile && + size in 1..maxBytes && + (expectedSize == null || size == expectedSize) + ) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + } + val sha256 = hashFile(file) + require(expectedSha256 == null || sha256 == expectedSha256) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + } + return StoredFirmwareArtifact("fw:$sha256", size, sha256, file) + } + + private fun validateStoredArtifactOrNull( + file: File, + expectedSize: Long, + expectedSha256: String, + ): StoredFirmwareArtifact? = try { + validateStoredArtifact(file, expectedSize, expectedSha256) + } catch (_: Throwable) { + null + } + + private fun validateStoredArtifact( + file: File, + expectedSize: Long, + expectedSha256: String, + ): StoredFirmwareArtifact { + require(file.isFile && file.length() == expectedSize) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + } + val sha256 = hashFile(file) + require(sha256 == expectedSha256) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + } + return StoredFirmwareArtifact("fw:$sha256", expectedSize, sha256, file) + } + + private fun resolveArtifactFile(artifactRef: String): File { + require(artifactRefPattern.matches(artifactRef)) { + "Invalid firmware artifactRef" + } + val file = artifactFile(artifactRef.removePrefix("fw:")) + require(file.isFile) { "Firmware artifact not found" } + return file + } + + private fun artifactFile(sha256: String): File = File(root, "$sha256.bin") + + fun createLease(transactionId: String): String { + require(identifierPattern.matches(transactionId)) { + "Invalid firmware transactionId" + } + return synchronized(leaseLock) { + require(leases.size < 32) { + "Too many firmware artifact leases" + } + val leaseRef = "fwlease:${UUID.randomUUID()}" + leases[leaseRef] = LeaseState(transactionId, mutableSetOf()) + leaseRef + } + } + + fun retain(leaseRef: String, artifactRef: String) { + resolveArtifactFile(artifactRef) + retainExpectedArtifact( + leaseRef = leaseRef, + transactionId = null, + artifactRef = artifactRef, + ) + } + + fun releaseLease(leaseRef: String, disposition: String) { + require( + disposition == "completed" || + disposition == "safeCancelled" || + disposition == "safeAbandoned" + ) { + "Invalid firmware lease disposition" + } + val transactionId = synchronized(leaseLock) { + val removed = leases.remove(validateLeaseRef(leaseRef)) + require(removed != null) { + "Firmware artifact lease is unavailable" + } + removed.transactionId + } + cancelledTransactions.remove(transactionId) + } + + fun sweepOrphans(): Pair { + val retainedSha256 = synchronized(leaseLock) { + leases.values + .flatMap { it.artifactRefs } + .mapTo(mutableSetOf()) { it.removePrefix("fw:") } + } + val activeSha256 = synchronized(activeDownloadLock) { + activeDownloadCounts.filterValues { it > 0 }.keys.toSet() + } + val openFiles = synchronized(readerLock) { + readers.values.mapTo(mutableSetOf()) { it.file.absolutePath } + } + return sweepFirmwareArtifactOrphansAtRoot( + root = root, + retainedSha256 = retainedSha256, + activeSha256 = activeSha256, + openPaths = openFiles, + ) + } + + private fun requireLease(leaseRef: String) { + synchronized(leaseLock) { + require(leases.containsKey(validateLeaseRef(leaseRef))) { + "Firmware artifact lease is unavailable" + } + } + } + + private fun requireLeaseTransaction(leaseRef: String, transactionId: String) { + synchronized(leaseLock) { + val lease = leases[validateLeaseRef(leaseRef)] + require(lease?.transactionId == transactionId) { + "Firmware artifact lease transaction mismatch" + } + } + } + + private fun retainExpectedArtifact( + leaseRef: String, + transactionId: String?, + artifactRef: String, + ) { + require(artifactRefPattern.matches(artifactRef)) { + "Invalid firmware artifactRef" + } + synchronized(leaseLock) { + val lease = leases[validateLeaseRef(leaseRef)] + ?: error("Firmware artifact lease is unavailable") + if (transactionId != null) { + require(lease.transactionId == transactionId) { + "Firmware artifact lease transaction mismatch" + } + } + lease.artifactRefs.add(artifactRef) + } + } + + private fun validateLeaseRef(leaseRef: String): String { + require(leaseRefPattern.matches(leaseRef)) { + "Invalid firmware leaseRef" + } + return leaseRef + } + + private fun markDownloadActive(sha256: String, delta: Int) { + synchronized(activeDownloadLock) { + val count = (activeDownloadCounts[sha256] ?: 0) + delta + if (count <= 0) { + activeDownloadCounts.remove(sha256) + } else { + activeDownloadCounts[sha256] = count + } + } + } + + private fun hashFile(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + BufferedInputStream(FileInputStream(file)).use { input -> + val buffer = ByteArray(MAX_READ_BYTES) + while (true) { + val count = input.read(buffer) + if (count < 0) break + if (count > 0) digest.update(buffer, 0, count) + } + } + return digest.digest().toHex() + } + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .toHex() + + private fun promoteAtomically(source: File, destination: File) { + destination.parentFile?.mkdirs() + Os.rename(source.absolutePath, destination.absolutePath) + } + + private fun Double.toExactPositiveLong(label: String): Long { + require(isFinite() && this > 0 && this <= Long.MAX_VALUE.toDouble()) { + "Invalid firmware $label" + } + val converted = toLong() + require(converted.toDouble() == this) { "Invalid firmware $label" } + return converted + } + + private fun ByteArray.toHex(): String = joinToString("") { + "%02x".format(it) + } + + private fun registerCall(transactionId: String, call: Call) { + val calls = activeCalls.computeIfAbsent(transactionId) { + ConcurrentHashMap.newKeySet() + } + calls.add(call) + if (cancelledTransactions.contains(transactionId)) { + call.cancel() + } + } + + private fun unregisterCall(transactionId: String, call: Call) { + val calls = activeCalls[transactionId] ?: return + calls.remove(call) + if (calls.isEmpty()) { + activeCalls.remove(transactionId, calls) + } + } +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index 8835e4c2..a814d68d 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -2,12 +2,16 @@ package com.margelo.nitro.reactnativerangedownloader import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.ArrayBuffer import com.margelo.nitro.core.Promise import com.margelo.nitro.nativelogger.OneKeyLog import java.io.File import java.security.MessageDigest import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob // P1: Nitro adapter for the Android concurrent multi-range downloader. // @@ -33,6 +37,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { private val listeners = CopyOnWriteArrayList() private val nextListenerId = AtomicLong(1) + private val firmwareArtifactScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // Active downloads keyed by "channel|taskId" so cancel/discardArtifacts can // flip the abort flag + stop the worker pool BEFORE deleting files, instead of @@ -265,6 +270,149 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { return ctx.cacheDir.absolutePath } + override fun getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities { + return FirmwareArtifactCapabilities( + firmwareArtifactProtocolVersion = 4.0, + supportedRouteTypes = arrayOf("domain"), + supportsArchiveMaterialization = true, + maxReadBytes = FirmwareArtifactStore.MAX_READ_BYTES.toDouble(), + ) + } + + override fun downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + val artifact = FirmwareArtifactStore.download(params) + FirmwareArtifactReceipt( + artifactRef = artifact.artifactRef, + size = artifact.size.toDouble(), + sha256 = artifact.sha256, + expectedSha256Verified = params.expectedSha256 != null, + ) + } + } + + override fun cancelFirmwareArtifactDownloads( + params: FirmwareArtifactCancelParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + FirmwareArtifactStore.cancelDownloads(params.transactionId) + } + } + + override fun discardFirmwareArtifact( + params: FirmwareArtifactRefParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + FirmwareArtifactStore.discard(params.artifactRef) + } + } + + override fun openFirmwareArtifact( + params: FirmwareArtifactRefParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + val (readerId, size) = FirmwareArtifactStore.open(params.artifactRef) + FirmwareArtifactReaderInfo(readerId = readerId, size = size.toDouble()) + } + } + + override fun readFirmwareArtifact( + params: FirmwareArtifactReaderReadParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + require( + params.offset.isFinite() && + params.offset >= 0 && + params.offset <= FirmwareArtifactStore.MAX_ARTIFACT_BYTES.toDouble() && + params.offset.toLong().toDouble() == params.offset && + params.length.isFinite() && + params.length > 0 && + params.length <= FirmwareArtifactStore.MAX_READ_BYTES.toDouble() && + params.length.toInt().toDouble() == params.length + ) { + "Invalid firmware artifact read" + } + ArrayBuffer.copy( + FirmwareArtifactStore.read( + readerId = params.readerId, + offset = params.offset.toLong(), + length = params.length.toInt(), + ), + ) + } + } + + override fun closeFirmwareArtifact( + params: FirmwareArtifactReaderCloseParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + FirmwareArtifactStore.close(params.readerId) + } + } + + override fun materializeFirmwareArchive( + params: FirmwareArchiveMaterializeParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + val artifacts = FirmwareArtifactStore.materializeArchive( + params.leaseRef, + params.archiveArtifactRef, + params.expectedEntries, + ) + FirmwareArchiveMaterializeResult( + artifacts = artifacts.map { entry -> + FirmwareArchiveMaterializedArtifact( + entryName = entry.entryName, + receipt = FirmwareArtifactReceipt( + artifactRef = entry.artifact.artifactRef, + size = entry.artifact.size.toDouble(), + sha256 = entry.artifact.sha256, + expectedSha256Verified = params.expectedEntries != null, + ), + ) + }.toTypedArray(), + ) + } + } + + override fun createFirmwareArtifactLease( + params: FirmwareArtifactLeaseCreateParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + FirmwareArtifactLease( + leaseRef = FirmwareArtifactStore.createLease(params.transactionId), + ) + } + } + + override fun retainFirmwareArtifact( + params: FirmwareArtifactLeaseRetainParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + FirmwareArtifactStore.retain(params.leaseRef, params.artifactRef) + } + } + + override fun releaseFirmwareArtifactLease( + params: FirmwareArtifactLeaseReleaseParams, + ): Promise { + return Promise.async(firmwareArtifactScope) { + FirmwareArtifactStore.releaseLease(params.leaseRef, params.disposition) + } + } + + override fun sweepFirmwareArtifactOrphans(): Promise { + return Promise.async(firmwareArtifactScope) { + val (deletedFiles, deletedBytes) = FirmwareArtifactStore.sweepOrphans() + FirmwareArtifactSweepResult( + deletedFiles = deletedFiles.toDouble(), + deletedBytes = deletedBytes.toDouble(), + ) + } + } + // Broadcast one event to every registered listener. Listeners filter by // channel/taskId on their side (shared registry, per the design). private fun sendEvent( diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt new file mode 100644 index 00000000..d9adfc9f --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt @@ -0,0 +1,114 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FirmwareArchiveRulesTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private fun entry( + artifactId: String = "resource-entry", + entryName: String = "assets/icon.png", + expectedSize: Double = 128.0, + expectedSha256: String = "a".repeat(64), + ) = FirmwareArchiveExpectedEntry( + artifactId = artifactId, + entryName = entryName, + expectedSize = expectedSize, + expectedSha256 = expectedSha256, + ) + + @Test + fun acceptsAnExactPortableAllowlist() { + val requirements = FirmwareArchiveRules.validateRequirements( + arrayOf( + entry(), + entry( + artifactId = "resource-entry-2", + entryName = "assets/sub/icon-2.png", + expectedSha256 = "b".repeat(64), + ), + ), + ) + + assertEquals(2, requirements.size) + } + + @Test + fun rejectsTraversalAndNestedArchives() { + for (entryName in listOf("../icon.png", "assets/../icon.png", "assets.zip")) { + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf(entry(entryName = entryName)), + ) + } + } + } + + @Test + fun rejectsCaseFoldedDuplicateNames() { + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf( + entry(entryName = "assets/Icon.png"), + entry( + artifactId = "resource-entry-2", + entryName = "assets/icon.png", + ), + ), + ) + } + } + + @Test + fun rejectsNonIntegralSizesAndInvalidDigests() { + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf(entry(expectedSize = 1.5)), + ) + } + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf(entry(expectedSha256 = "not-a-digest")), + ) + } + } + + @Test + fun acceptsPortableEntriesWithoutExpectedIntegrityMetadata() { + val archive = createArchive("assets/icon.png", byteArrayOf(1, 2, 3)) + + val entries = FirmwareArchiveRules.validateCentralDirectory(archive, null) + + assertEquals(1, entries.size) + assertEquals("assets/icon.png", entries.single().name) + assertEquals(3, entries.single().uncompressedSize) + } + + @Test + fun rejectsTraversalWithoutExpectedIntegrityMetadata() { + val archive = createArchive("../icon.png", byteArrayOf(1)) + + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateCentralDirectory(archive, null) + } + } + + private fun createArchive(entryName: String, content: ByteArray): File { + val archive = temporaryFolder.newFile("firmware.zip") + ZipOutputStream(FileOutputStream(archive)).use { zip -> + zip.putNextEntry(ZipEntry(entryName)) + zip.write(content) + zip.closeEntry() + } + return archive + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt new file mode 100644 index 00000000..d9f74f8a --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt @@ -0,0 +1,26 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class FirmwareArtifactDeadlineTest { + @Test + fun defaultsToABoundedThreeMinuteDeadline() { + assertEquals(180.0, validateFirmwareDownloadDeadlineSeconds(null), 0.0) + } + + @Test + fun preservesFractionalDeadlines() { + assertEquals(2.75, validateFirmwareDownloadDeadlineSeconds(2.75), 0.0) + } + + @Test + fun rejectsUnboundedOrInvalidDeadlines() { + listOf(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY, 86_400.1).forEach { + assertThrows(IllegalArgumentException::class.java) { + validateFirmwareDownloadDeadlineSeconds(it) + } + } + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt new file mode 100644 index 00000000..37d570c5 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt @@ -0,0 +1,107 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FirmwareArtifactOrphanSweepTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun removesOnlyStaleRootScratchEntriesWithExactNamesAndTypes() { + val root = temporaryFolder.newFolder("firmware-artifact-sweep") + val nowMs = 2_000_000_000_000L + val staleAt = nowMs - FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS - 1 + val freshAt = nowMs - FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS + 1 + val staleArchive = File( + root, + "archive-00000000-0000-4000-8000-000000000001", + ).apply { + assertTrue(mkdirs()) + File(this, "0.entry").writeText("archive") + } + val stalePromote = File( + root, + ".promote-00000000-0000-4000-8000-000000000002", + ).apply { writeText("promote") } + val freshArchive = File( + root, + "archive-00000000-0000-4000-8000-000000000003", + ).apply { assertTrue(mkdirs()) } + val freshPromote = File( + root, + ".promote-00000000-0000-4000-8000-000000000004", + ).apply { writeText("promote") } + val malformedArchive = File( + root, + "archive-00000000-0000-4000-8000-000000000005.extra", + ).apply { assertTrue(mkdirs()) } + val malformedPromote = File( + root, + ".promote-00000000-0000-4000-8000-000000000006.tmp", + ).apply { writeText("promote") } + val archiveNamedFile = File( + root, + "archive-00000000-0000-4000-8000-000000000007", + ).apply { writeText("promote") } + val promoteNamedDirectory = File( + root, + ".promote-00000000-0000-4000-8000-000000000008", + ).apply { assertTrue(mkdirs()) } + val nestedArchive = File( + File(root, "nested"), + "archive-00000000-0000-4000-8000-000000000009", + ).apply { assertTrue(mkdirs()) } + + for ( + entry in listOf( + staleArchive, + stalePromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ) + ) { + assertTrue(entry.setLastModified(staleAt)) + } + for (entry in listOf(freshArchive, freshPromote)) { + assertTrue(entry.setLastModified(freshAt)) + } + + val result = sweepFirmwareArtifactOrphansAtRoot( + root = root, + retainedSha256 = emptySet(), + activeSha256 = emptySet(), + openPaths = emptySet(), + nowMs = nowMs, + ) + + assertEquals(2, result.first) + assertEquals(14L, result.second) + assertFalse(staleArchive.exists()) + assertFalse(stalePromote.exists()) + for ( + retainedEntry in listOf( + freshArchive, + freshPromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ) + ) { + assertTrue( + "Unexpectedly removed ${retainedEntry.name}", + retainedEntry.exists(), + ) + } + } +} diff --git a/native-modules/react-native-range-downloader/eslint.config.mjs b/native-modules/react-native-range-downloader/eslint.config.mjs index 3416cf5c..4494c016 100644 --- a/native-modules/react-native-range-downloader/eslint.config.mjs +++ b/native-modules/react-native-range-downloader/eslint.config.mjs @@ -1,5 +1,29 @@ -import { createEslintConfig } from '@react-native/eslint-config'; +import { fixupConfigRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -export default createEslintConfig({ - extends: ['@react-native/eslint-config'], +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, }); + +export default [ + { + ignores: [ + '**/node_modules', + '**/android/build', + '**/conformance', + '**/ios/build', + '**/lib', + '**/nitrogen', + '**/*.config.js', + '**/*.config.mjs', + ], + }, + ...fixupConfigRules(compat.extends('@react-native', 'prettier')), +]; diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h new file mode 100644 index 00000000..a5cc3d38 --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h @@ -0,0 +1,33 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FirmwareArchiveEntryInfo : NSObject + +@property(nonatomic, readonly) NSString *name; +@property(nonatomic, readonly) int64_t compressedSize; +@property(nonatomic, readonly) int64_t uncompressedSize; +@property(nonatomic, readonly) uint32_t crc32; +@property(nonatomic, readonly) uint16_t flags; +@property(nonatomic, readonly) uint16_t compressionMethod; +@property(nonatomic, readonly) uint16_t versionMadeBy; +@property(nonatomic, readonly) uint32_t externalAttributes; +@property(nonatomic, readonly) uint32_t diskNumber; +@property(nonatomic, readonly, nullable) NSString *linkName; + +@end + +@interface FirmwareArchiveMinizipBridge : NSObject + ++ (nullable NSArray *)scanArchiveAtPath: + (NSString *)path + error:(NSError * _Nullable * _Nullable)error; + ++ (BOOL)extractEntryNamed:(NSString *)entryName + archivePath:(NSString *)archivePath + consumer:(BOOL (^)(NSData *chunk))consumer + error:(NSError * _Nullable * _Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm new file mode 100644 index 00000000..7351f520 --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm @@ -0,0 +1,213 @@ +#import "FirmwareArchiveMinizipBridge.h" + +#import "mz.h" +#import "mz_strm.h" +#import "mz_zip.h" +#import "mz_zip_rw.h" + +#include + +static NSString *const FirmwareArchiveBridgeErrorDomain = + @"so.onekey.firmware.archive.minizip"; + +static NSError *FirmwareArchiveBridgeError(NSString *message, int32_t code) { + return [NSError errorWithDomain:FirmwareArchiveBridgeErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey : message}]; +} + +@interface FirmwareArchiveEntryInfo () + +@property(nonatomic, readwrite) NSString *name; +@property(nonatomic, readwrite) int64_t compressedSize; +@property(nonatomic, readwrite) int64_t uncompressedSize; +@property(nonatomic, readwrite) uint32_t crc32; +@property(nonatomic, readwrite) uint16_t flags; +@property(nonatomic, readwrite) uint16_t compressionMethod; +@property(nonatomic, readwrite) uint16_t versionMadeBy; +@property(nonatomic, readwrite) uint32_t externalAttributes; +@property(nonatomic, readwrite) uint32_t diskNumber; +@property(nonatomic, readwrite, nullable) NSString *linkName; + +@end + +@implementation FirmwareArchiveEntryInfo +@end + +@implementation FirmwareArchiveMinizipBridge + ++ (nullable NSArray *)scanArchiveAtPath: + (NSString *)path + error:(NSError * _Nullable * _Nullable)error { + void *reader = nullptr; + mz_zip_reader_create(&reader); + if (reader == nullptr) { + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive reader allocation failed", + MZ_MEM_ERROR); + } + return nil; + } + + int32_t result = mz_zip_reader_open_file(reader, path.fileSystemRepresentation); + if (result != MZ_OK) { + mz_zip_reader_delete(&reader); + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive open failed", result); + } + return nil; + } + + NSMutableArray *entries = + [NSMutableArray array]; + result = mz_zip_reader_goto_first_entry(reader); + while (result == MZ_OK) { + mz_zip_file *fileInfo = nullptr; + result = mz_zip_reader_entry_get_info(reader, &fileInfo); + if (result != MZ_OK || fileInfo == nullptr || + fileInfo->filename == nullptr) { + result = result == MZ_OK ? MZ_FORMAT_ERROR : result; + break; + } + const size_t filenameLength = std::strlen(fileInfo->filename); + if (filenameLength == 0 || filenameLength > 4096) { + result = MZ_FORMAT_ERROR; + break; + } + if ((fileInfo->flag & (1 << 11)) == 0) { + const unsigned char *filenameBytes = + reinterpret_cast(fileInfo->filename); + bool isAscii = true; + for (size_t index = 0; index < filenameLength; index += 1) { + if (filenameBytes[index] >= 0x80) { + isAscii = false; + break; + } + } + if (!isAscii) { + result = MZ_FORMAT_ERROR; + break; + } + } + NSString *name = [NSString stringWithUTF8String:fileInfo->filename]; + if (name == nil) { + result = MZ_FORMAT_ERROR; + break; + } + NSString *linkName = nil; + if (fileInfo->linkname != nullptr) { + linkName = [NSString stringWithUTF8String:fileInfo->linkname]; + if (linkName == nil) { + result = MZ_FORMAT_ERROR; + break; + } + } + + FirmwareArchiveEntryInfo *entry = + [[FirmwareArchiveEntryInfo alloc] init]; + entry.name = name; + entry.compressedSize = fileInfo->compressed_size; + entry.uncompressedSize = fileInfo->uncompressed_size; + entry.crc32 = fileInfo->crc; + entry.flags = fileInfo->flag; + entry.compressionMethod = fileInfo->compression_method; + entry.versionMadeBy = fileInfo->version_madeby; + entry.externalAttributes = fileInfo->external_fa; + entry.diskNumber = fileInfo->disk_number; + entry.linkName = linkName; + [entries addObject:entry]; + if (entries.count > 4096) { + result = MZ_FORMAT_ERROR; + break; + } + result = mz_zip_reader_goto_next_entry(reader); + } + + if (result == MZ_END_OF_LIST) { + result = MZ_OK; + } + int32_t closeResult = mz_zip_reader_close(reader); + mz_zip_reader_delete(&reader); + if (result != MZ_OK || closeResult != MZ_OK) { + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive scan failed", + result != MZ_OK ? result + : closeResult); + } + return nil; + } + return entries; +} + ++ (BOOL)extractEntryNamed:(NSString *)entryName + archivePath:(NSString *)archivePath + consumer:(BOOL (^)(NSData *chunk))consumer + error:(NSError * _Nullable * _Nullable)error { + void *reader = nullptr; + bool archiveOpened = false; + bool entryOpened = false; + mz_zip_reader_create(&reader); + if (reader == nullptr) { + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive reader allocation failed", + MZ_MEM_ERROR); + } + return NO; + } + + int32_t result = + mz_zip_reader_open_file(reader, archivePath.fileSystemRepresentation); + if (result == MZ_OK) { + archiveOpened = true; + } + if (result == MZ_OK) { + result = mz_zip_reader_locate_entry( + reader, entryName.UTF8String, 0); + } + if (result == MZ_OK) { + result = mz_zip_reader_entry_open(reader); + if (result == MZ_OK) { + entryOpened = true; + } + } + + uint8_t buffer[64 * 1024]; + while (result == MZ_OK) { + int32_t count = + mz_zip_reader_entry_read(reader, buffer, (int32_t)sizeof(buffer)); + if (count < 0) { + result = count; + break; + } + if (count == 0) { + break; + } + NSData *chunk = [NSData dataWithBytes:buffer length:(NSUInteger)count]; + if (!consumer(chunk)) { + result = MZ_WRITE_ERROR; + break; + } + } + + int32_t entryCloseResult = + entryOpened ? mz_zip_reader_entry_close(reader) : MZ_OK; + int32_t archiveCloseResult = + archiveOpened ? mz_zip_reader_close(reader) : MZ_OK; + mz_zip_reader_delete(&reader); + if (result != MZ_OK || entryCloseResult != MZ_OK || + archiveCloseResult != MZ_OK) { + if (error != nullptr) { + int32_t failure = result != MZ_OK + ? result + : (entryCloseResult != MZ_OK + ? entryCloseResult + : archiveCloseResult); + *error = FirmwareArchiveBridgeError( + @"Archive entry extraction or CRC validation failed", failure); + } + return NO; + } + return YES; +} + +@end diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift new file mode 100644 index 00000000..b3a1f36e --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -0,0 +1,1508 @@ +import CryptoKit +import Foundation +import ReactNativeNativeLogger + +func isFirmwareArtifactTLSError(_ error: Error) -> Bool { + var current: NSError? = error as NSError + var visited = Set() + while let candidate = current { + let identifier = ObjectIdentifier(candidate) + guard visited.insert(identifier).inserted else { + break + } + if candidate.domain == NSURLErrorDomain, + [ + NSURLErrorSecureConnectionFailed, + NSURLErrorServerCertificateHasBadDate, + NSURLErrorServerCertificateUntrusted, + NSURLErrorServerCertificateHasUnknownRoot, + NSURLErrorServerCertificateNotYetValid, + NSURLErrorClientCertificateRejected, + NSURLErrorClientCertificateRequired, + NSURLErrorAppTransportSecurityRequiresSecureConnection, + ].contains(candidate.code) + { + return true + } + current = candidate.userInfo[NSUnderlyingErrorKey] as? NSError + } + return false +} + +struct StoredFirmwareArtifact: Sendable { + let artifactRef: String + let size: Int64 + let sha256: String + let fileURL: URL +} + +struct StoredFirmwareArchiveEntry { + let entryName: String + let artifact: StoredFirmwareArtifact +} + +private struct StagedFirmwareArchiveEntry { + let entryName: String + let size: Int64 + let sha256: String + let stagingURL: URL +} + +private struct FirmwareArchiveRequirement { + let entryName: String + let expectedSize: Int64 + let expectedSha256: String? +} + +private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDelegate { + private let partialURL: URL + private let hostname: String + private let resumeOffset: Int64 + private let expectedSize: Int64? + private let maxBytes: Int64 + private let isCancelled: () -> Bool + private let stateLock = NSLock() + private var continuation: CheckedContinuation? + private var dataTask: URLSessionDataTask? + private var cancellationRequested = false + private var completed = false + private var responseAccepted = false + private var handle: FileHandle? + private var written: Int64 = 0 + + init( + partialURL: URL, + hostname: String, + resumeOffset: Int64, + expectedSize: Int64?, + maxBytes: Int64, + isCancelled: @escaping () -> Bool + ) { + self.partialURL = partialURL + self.hostname = hostname + self.resumeOffset = resumeOffset + self.expectedSize = expectedSize + self.maxBytes = maxBytes + self.isCancelled = isCancelled + } + + func run(session: URLSession, request: URLRequest) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + let task = session.dataTask(with: request) + stateLock.lock() + if cancellationRequested || completed { + stateLock.unlock() + task.cancel() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + dataTask = task + stateLock.unlock() + task.resume() + } + } onCancel: { + self.cancel() + } + } + + private func cancel() { + stateLock.lock() + cancellationRequested = true + let task = dataTask + stateLock.unlock() + task?.cancel() + } + + private func finish(_ result: Result) { + stateLock.lock() + guard !completed else { + stateLock.unlock() + return + } + completed = true + let continuation = continuation + self.continuation = nil + dataTask = nil + let handle = handle + self.handle = nil + stateLock.unlock() + + try? handle?.close() + continuation?.resume(with: result) + } + + private func reject( + _ dataTask: URLSessionDataTask, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void, + message: String + ) { + completionHandler(.cancel) + fail(dataTask, message: message) + } + + private func fail(_ task: URLSessionTask, message: String) { + task.cancel() + finish(.failure(FirmwareArtifactStoreError.downloadFailed(message))) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + fail( + task, + message: + "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity" + ) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard !isCancelled() else { + reject( + dataTask, + completionHandler: completionHandler, + message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + reject( + dataTask, + completionHandler: completionHandler, + message: "Firmware response is not HTTP" + ) + return + } + guard + let responseURL = httpResponse.url, + responseURL.scheme?.lowercased() == "https", + responseURL.host?.lowercased() == hostname.lowercased(), + responseURL.port == nil || responseURL.port == 443 + else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity" + ) + return + } + guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed" + ) + return + } + + let append = resumeOffset > 0 && httpResponse.statusCode == 206 + if httpResponse.statusCode == 206 { + guard + let contentRange = httpResponse.value( + forHTTPHeaderField: "Content-Range" + ), + firmwareArtifactContentRangeIsValid( + contentRange, + expectedStart: append ? resumeOffset : 0, + expectedTotal: expectedSize, + maxBytes: maxBytes + ) + else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" + ) + return + } + } + + let baseOffset = append ? resumeOffset : 0 + guard + firmwareArtifactResponseFits( + expectedContentLength: httpResponse.expectedContentLength, + baseOffset: baseOffset, + maxBytes: maxBytes + ) + else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + return + } + + do { + let handle = try FileHandle(forWritingTo: partialURL) + if append { + try handle.seekToEnd() + } else { + try handle.truncate(atOffset: 0) + } + self.handle = handle + written = baseOffset + responseAccepted = true + completionHandler(.allow) + } catch { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_NETWORK_FAILED: firmware partial file could not be opened" + ) + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + guard responseAccepted, let handle else { + fail( + dataTask, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted" + ) + return + } + guard !isCancelled() else { + fail( + dataTask, + message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + return + } + guard + firmwareArtifactResponseFits( + expectedContentLength: Int64(data.count), + baseOffset: written, + maxBytes: maxBytes + ) + else { + fail( + dataTask, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + return + } + do { + try handle.write(contentsOf: data) + written += Int64(data.count) + } catch { + fail( + dataTask, + message: + "ARTIFACT_NETWORK_FAILED: firmware response stream could not be persisted" + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error { + finish(.failure(error)) + return + } + guard responseAccepted, let handle else { + finish( + .failure(FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted" + )) + ) + return + } + do { + try handle.synchronize() + finish(.success(())) + } catch { + finish( + .failure(FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_NETWORK_FAILED: firmware partial file could not be synchronized" + )) + ) + } + } +} + +private actor FirmwareArtifactDownloadCoordinator { + private struct ActiveDownload { + let transactionId: String + let task: Task + } + + private var downloads: [String: ActiveDownload] = [:] + + func run( + key: String, + transactionId: String, + operation: @escaping () async throws -> StoredFirmwareArtifact + ) async throws -> StoredFirmwareArtifact { + if let download = downloads[key] { + return try await download.task.value + } + let task = Task { + try await operation() + } + downloads[key] = ActiveDownload( + transactionId: transactionId, + task: task + ) + defer { downloads[key] = nil } + return try await task.value + } + + func cancel(transactionId: String) { + for download in downloads.values + where download.transactionId == transactionId { + download.task.cancel() + } + } +} + +final class FirmwareArtifactStore { + static let shared = FirmwareArtifactStore() + static let maxReadBytes = Int(firmwareArtifactMaxReadBytes) + static let maxArtifactBytes = firmwareArtifactMaxBytes + private static let defaultDownloadDeadline: TimeInterval = 180 + private static let maxDownloadDeadline: TimeInterval = 24 * 60 * 60 + + private struct OpenReader { + let handle: FileHandle + let fileURL: URL + let size: Int64 + } + + private struct LeaseState { + let transactionId: String + var artifactRefs: Set + } + + private let fileManager = FileManager.default + private let downloadCoordinator = FirmwareArtifactDownloadCoordinator() + private let leaseLock = NSLock() + private let activeDownloadLock = NSLock() + private var activeDownloadCounts: [String: Int] = [:] + private let cancellationLock = NSLock() + private var cancelledTransactions: Set = [] + private let readerLock = NSLock() + private var readers: [String: OpenReader] = [:] + private var leases: [String: LeaseState] = [:] + + private lazy var rootURL: URL = { + let base = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + let root = base.appendingPathComponent("onekey-firmware-artifacts", isDirectory: true) + try? fileManager.createDirectory(at: root, withIntermediateDirectories: true) + return root + }() + + private init() {} + + private struct ValidatedDownload { + let expectedSize: Int64? + let expectedSha256: String? + let maxBytes: Int64 + let downloadToken: String + } + + private static func validateDownloadParams( + _ params: FirmwareArtifactDownloadParams + ) throws -> ValidatedDownload { + guard + !params.taskId.isEmpty, + params.taskId.count <= 100, + params.taskId.allSatisfy({ $0.isLetter || $0.isNumber || "._-".contains($0) }) + else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware taskId") + } + guard + isSafeIdentifier(params.transactionId), + isSafeIdentifier(params.artifactId), + isValidLeaseRef(params.leaseRef) + else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transaction or lease identity" + ) + } + guard + let url = URL(string: params.url), + url.scheme?.lowercased() == "https", + url.host?.isEmpty == false, + url.user == nil, + url.password == nil, + url.fragment == nil, + url.port == nil || url.port == 443 + else { + throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443") + } + let expectedSize: Int64? + if let value = params.expectedSize { + guard let converted = firmwareArtifactExactInt64( + value, + minimum: 1, + maximum: Self.maxArtifactBytes + ) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware artifact expected size" + ) + } + expectedSize = converted + } else { + expectedSize = nil + } + guard let maxBytes = firmwareArtifactExactInt64( + params.maxBytes, + minimum: 1, + maximum: Self.maxArtifactBytes + ) else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size") + } + guard expectedSize.map({ $0 <= maxBytes }) ?? true else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact expected size exceeds maxBytes" + ) + } + let expectedSha256 = params.expectedSha256?.lowercased() + if let expectedSha256 { + guard expectedSha256.range( + of: "^[a-f0-9]{64}$", + options: .regularExpression + ) != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware artifact SHA-256" + ) + } + } + guard params.routeType == "domain" else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type") + } + let deadline = params.overallDeadlineSeconds ?? defaultDownloadDeadline + guard + deadline.isFinite, + deadline > 0, + deadline <= maxDownloadDeadline + else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware download deadline") + } + if params.resolvedIp != nil { + throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp") + } + return ValidatedDownload( + expectedSize: expectedSize, + expectedSha256: expectedSha256, + maxBytes: maxBytes, + downloadToken: firmwareArtifactDownloadToken( + expectedSha256: expectedSha256, + url: params.url + ) + ) + } + + func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact { + let validated = try Self.validateDownloadParams(params) + try rejectIfCancelled(transactionId: params.transactionId) + if let expectedSha256 = validated.expectedSha256 { + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: "fw:\(expectedSha256)" + ) + } else { + try requireLeaseTransaction( + leaseRef: params.leaseRef, + transactionId: params.transactionId + ) + } + let key = firmwareArtifactDownloadKey( + transactionId: params.transactionId, + taskId: params.taskId, + expectedSize: validated.expectedSize, + expectedSha256: validated.expectedSha256, + downloadToken: validated.downloadToken + ) + markDownloadActive(validated.downloadToken, delta: 1) + do { + let artifact = try await downloadCoordinator.run( + key: key, + transactionId: params.transactionId + ) { [self] in + try rejectIfCancelled(transactionId: params.transactionId) + return try await downloadLocked( + params, + validated: validated + ) + } + try rejectIfCancelled(transactionId: params.transactionId) + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: artifact.artifactRef + ) + markDownloadActive(validated.downloadToken, delta: -1) + return artifact + } catch { + markDownloadActive(validated.downloadToken, delta: -1) + OneKeyLog.error( + "FirmwareArtifact", + "event=download_failed transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) errorType=\(String(describing: type(of: error)))" + ) + if error is CancellationError || + isTransactionCancelled(params.transactionId) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } + throw error + } + } + + func cachedArtifact( + _ params: FirmwareArtifactDownloadParams + ) throws -> StoredFirmwareArtifact? { + let validated = try Self.validateDownloadParams(params) + try rejectIfCancelled(transactionId: params.transactionId) + guard let expectedSha256 = validated.expectedSha256 else { + return nil + } + let finalURL = artifactURL(sha256: expectedSha256) + guard let artifact = try? validateDownloadedArtifact( + fileURL: finalURL, + expectedSize: validated.expectedSize, + expectedSha256: expectedSha256, + maxBytes: validated.maxBytes + ) else { + return nil + } + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: artifact.artifactRef + ) + try rejectIfCancelled(transactionId: params.transactionId) + return artifact + } + + func cancelDownloads(transactionId: String) async throws { + guard Self.isSafeIdentifier(transactionId) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transactionId" + ) + } + cancellationLock.withFirmwareArtifactLock { + cancelledTransactions.insert(transactionId) + } + await downloadCoordinator.cancel(transactionId: transactionId) + } + + private func downloadLocked( + _ params: FirmwareArtifactDownloadParams, + validated: ValidatedDownload + ) async throws -> StoredFirmwareArtifact { + if let expectedSha256 = validated.expectedSha256 { + let finalURL = artifactURL(sha256: expectedSha256) + if let existing = try? validateDownloadedArtifact( + fileURL: finalURL, + expectedSize: validated.expectedSize, + expectedSha256: expectedSha256, + maxBytes: validated.maxBytes + ) { + return existing + } + } + + // Firmware preflight is foreground-bound, so both routes use the same + // cancellable stream instead of waiting on process-owned background tasks. + let partialURL = rootURL.appendingPathComponent( + firmwareArtifactPartialFileName( + transactionId: params.transactionId, + taskId: params.taskId, + downloadToken: validated.downloadToken + ), + isDirectory: false + ) + if !fileManager.fileExists(atPath: partialURL.path) { + fileManager.createFile(atPath: partialURL.path, contents: nil) + } + var currentSize = try fileSize(partialURL) + if validated.expectedSha256 == nil && currentSize > 0 { + try fileManager.removeItem(at: partialURL) + fileManager.createFile(atPath: partialURL.path, contents: nil) + currentSize = 0 + } else if currentSize > validated.maxBytes { + try fileManager.removeItem(at: partialURL) + fileManager.createFile(atPath: partialURL.path, contents: nil) + currentSize = 0 + } + if let expectedSize = validated.expectedSize, currentSize == expectedSize { + if let completed = try? validateDownloadedArtifact( + fileURL: partialURL, + expectedSize: expectedSize, + expectedSha256: validated.expectedSha256, + maxBytes: validated.maxBytes + ) { + let finalURL = artifactURL(sha256: completed.sha256) + try promote(source: partialURL, destination: finalURL) + return StoredFirmwareArtifact( + artifactRef: completed.artifactRef, + size: completed.size, + sha256: completed.sha256, + fileURL: finalURL + ) + } + try fileManager.removeItem(at: partialURL) + fileManager.createFile(atPath: partialURL.path, contents: nil) + currentSize = 0 + } + + OneKeyLog.info( + "FirmwareArtifact", + "event=stream_start transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1) resumeBytes=\(currentSize)" + ) + try await streamDownload( + params, + partialURL: partialURL, + resumeOffset: min(currentSize, validated.maxBytes), + validated: validated + ) + OneKeyLog.info( + "FirmwareArtifact", + "event=stream_complete transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1)" + ) + let artifact: StoredFirmwareArtifact + do { + artifact = try validateDownloadedArtifact( + fileURL: partialURL, + expectedSize: validated.expectedSize, + expectedSha256: validated.expectedSha256, + maxBytes: validated.maxBytes + ) + } catch { + try? fileManager.removeItem(at: partialURL) + throw error + } + let finalURL = artifactURL(sha256: artifact.sha256) + try promote(source: partialURL, destination: finalURL) + return StoredFirmwareArtifact( + artifactRef: artifact.artifactRef, + size: artifact.size, + sha256: artifact.sha256, + fileURL: finalURL + ) + } + + func discard(artifactRef: String) throws { + let fileURL = try resolveArtifactURL(artifactRef) + leaseLock.lock() + let isRetained = leases.values.contains { + $0.artifactRefs.contains(artifactRef) + } + leaseLock.unlock() + guard !isRetained else { + throw FirmwareArtifactStoreError.invalidInput( + "ARTIFACT_LEASED: firmware artifact is retained" + ) + } + readerLock.lock() + let hasMatchingReader = readers.values.contains { + $0.fileURL == fileURL + } + readerLock.unlock() + guard !hasMatchingReader else { + throw FirmwareArtifactStoreError.readerInvalid( + "ARTIFACT_BUSY: firmware artifact has an open reader" + ) + } + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + } + + func open(artifactRef: String) throws -> (readerId: String, size: Int64) { + let fileURL = try resolveArtifactURL(artifactRef) + let size = try fileSize(fileURL) + guard size > 0 else { + throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact is empty") + } + let expectedSha256 = String(artifactRef.dropFirst(3)) + guard try hashFile(fileURL) == expectedSha256 else { + throw FirmwareArtifactStoreError.integrityMismatch( + "Firmware artifact SHA-256 mismatch" + ) + } + let readerId = UUID().uuidString + let reader = OpenReader( + handle: try FileHandle(forReadingFrom: fileURL), + fileURL: fileURL, + size: size + ) + readerLock.lock() + readers[readerId] = reader + readerLock.unlock() + return (readerId, size) + } + + func read(readerId: String, offset: Int64, length: Int) throws -> Data { + guard length > 0, length <= Self.maxReadBytes, offset >= 0 else { + throw FirmwareArtifactStoreError.readerInvalid("Invalid firmware artifact read") + } + readerLock.lock() + defer { readerLock.unlock() } + guard + let reader = readers[readerId], + offset <= reader.size - Int64(length) + else { + throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact read is out of bounds") + } + try reader.handle.seek(toOffset: UInt64(offset)) + let data = try reader.handle.read(upToCount: length) ?? Data() + guard data.count == length else { + throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact returned a short read") + } + return data + } + + func close(readerId: String) throws { + readerLock.lock() + let reader = readers.removeValue(forKey: readerId) + readerLock.unlock() + guard let reader else { + return + } + try reader.handle.close() + } + + func materializeArchive( + leaseRef: String, + artifactRef: String, + expectedEntries: [FirmwareArchiveExpectedEntry]? + ) throws -> [StoredFirmwareArchiveEntry] { + try requireLease(leaseRef) + let archiveURL = try resolveArtifactURL(artifactRef) + let scratchURL = rootURL.appendingPathComponent( + "archive-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: scratchURL) } + + let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive( + atPath: archiveURL.path + ) + let requirements = try resolveArchiveRequirements( + expectedEntries, + archiveEntries: archiveEntries + ) + try validateArchiveEntries( + archiveEntries, + requirements: requirements + ) + + var staged: [StagedFirmwareArchiveEntry] = [] + for (index, requirement) in requirements.enumerated() { + let stagingURL = scratchURL.appendingPathComponent( + "\(index).entry", + isDirectory: false + ) + fileManager.createFile(atPath: stagingURL.path, contents: nil) + let handle = try FileHandle(forWritingTo: stagingURL) + var hasher = SHA256() + var actualSize: Int64 = 0 + var consumerError: Error? + do { + try FirmwareArchiveMinizipBridge.extractEntryNamed( + requirement.entryName, + archivePath: archiveURL.path + ) { chunk in + guard consumerError == nil else { + return false + } + do { + actualSize += Int64(chunk.count) + guard actualSize <= requirement.expectedSize else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive entry exceeds its expected size" + ) + } + hasher.update(data: chunk) + try handle.write(contentsOf: chunk) + return true + } catch { + consumerError = error + return false + } + } + if let consumerError { + throw consumerError + } + try handle.synchronize() + try handle.close() + } catch { + try? handle.close() + throw error + } + let sha256 = hasher.finalize().map { + String(format: "%02x", $0) + }.joined() + guard + actualSize == requirement.expectedSize, + requirement.expectedSha256.map({ sha256 == $0 }) ?? true + else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive entry integrity mismatch" + ) + } + staged.append( + StagedFirmwareArchiveEntry( + entryName: requirement.entryName, + size: actualSize, + sha256: sha256, + stagingURL: stagingURL + ) + ) + } + + return try staged.map { entry in + let destination = artifactURL(sha256: entry.sha256) + if (try? validateStoredArtifact( + fileURL: destination, + expectedSize: entry.size, + expectedSha256: entry.sha256 + )) == nil { + try promote(source: entry.stagingURL, destination: destination) + } + let stored = StoredFirmwareArtifact( + artifactRef: "fw:\(entry.sha256)", + size: entry.size, + sha256: entry.sha256, + fileURL: destination + ) + try retainExpectedArtifact( + leaseRef: leaseRef, + transactionId: nil, + artifactRef: stored.artifactRef + ) + return StoredFirmwareArchiveEntry( + entryName: entry.entryName, + artifact: stored + ) + } + } + + func createLease(transactionId: String) throws -> String { + guard Self.isSafeIdentifier(transactionId) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transactionId" + ) + } + leaseLock.lock() + defer { leaseLock.unlock() } + guard leases.count < 32 else { + throw FirmwareArtifactStoreError.invalidInput( + "Too many firmware artifact leases" + ) + } + let leaseRef = "fwlease:\(UUID().uuidString.lowercased())" + leases[leaseRef] = LeaseState( + transactionId: transactionId, + artifactRefs: [] + ) + return leaseRef + } + + func retain(leaseRef: String, artifactRef: String) throws { + _ = try resolveArtifactURL(artifactRef) + try retainExpectedArtifact( + leaseRef: leaseRef, + transactionId: nil, + artifactRef: artifactRef + ) + } + + func releaseLease(leaseRef: String, disposition: String) throws { + guard + disposition == "completed" || + disposition == "safeCancelled" || + disposition == "safeAbandoned" + else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware lease disposition" + ) + } + let transactionId: String = try { + leaseLock.lock() + defer { leaseLock.unlock() } + guard + let lease = leases.removeValue( + forKey: try validateLeaseRef(leaseRef) + ) + else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + return lease.transactionId + }() + cancellationLock.withFirmwareArtifactLock { + cancelledTransactions.remove(transactionId) + } + } + + func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) { + leaseLock.lock() + let retained = Set( + leases.values + .flatMap(\.artifactRefs) + .map { String($0.dropFirst(3)) } + ) + leaseLock.unlock() + activeDownloadLock.lock() + let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys) + activeDownloadLock.unlock() + readerLock.lock() + let openPaths = Set(readers.values.map(\.fileURL.path)) + readerLock.unlock() + + return try sweepFirmwareArtifactOrphansAtRoot( + rootURL, + retainedSha256: retained, + activeSha256: active, + openPaths: openPaths, + fileManager: fileManager + ) + } + + private func requireLease(_ leaseRef: String) throws { + leaseLock.lock() + defer { leaseLock.unlock() } + guard leases[try validateLeaseRef(leaseRef)] != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + } + + private func requireLeaseTransaction( + leaseRef: String, + transactionId: String + ) throws { + leaseLock.lock() + defer { leaseLock.unlock() } + guard + let lease = leases[try validateLeaseRef(leaseRef)], + lease.transactionId == transactionId + else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease transaction mismatch" + ) + } + } + + private func retainExpectedArtifact( + leaseRef: String, + transactionId: String?, + artifactRef: String + ) throws { + guard artifactRef.range( + of: "^fw:[a-f0-9]{64}$", + options: .regularExpression + ) != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware artifactRef" + ) + } + leaseLock.lock() + defer { leaseLock.unlock() } + let validatedLeaseRef = try validateLeaseRef(leaseRef) + guard var lease = leases[validatedLeaseRef] else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + if let transactionId, lease.transactionId != transactionId { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease transaction mismatch" + ) + } + if lease.artifactRefs.insert(artifactRef).inserted { + leases[validatedLeaseRef] = lease + } + } + + private func validateLeaseRef(_ leaseRef: String) throws -> String { + guard Self.isValidLeaseRef(leaseRef) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware leaseRef" + ) + } + return leaseRef + } + + private static func isValidLeaseRef(_ value: String) -> Bool { + value.range( + of: "^fwlease:[a-f0-9-]{36}$", + options: .regularExpression + ) != nil + } + + static func isSafeIdentifier(_ value: String) -> Bool { + firmwareArtifactIdentifierIsSafe(value) + } + + private func markDownloadActive(_ sha256: String, delta: Int) { + activeDownloadLock.lock() + let next = (activeDownloadCounts[sha256] ?? 0) + delta + if next <= 0 { + activeDownloadCounts.removeValue(forKey: sha256) + } else { + activeDownloadCounts[sha256] = next + } + activeDownloadLock.unlock() + } + + private func validateArchiveRequirements( + _ expectedEntries: [FirmwareArchiveExpectedEntry] + ) throws -> [FirmwareArchiveRequirement] { + guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expected entry count is invalid" + ) + } + var names = Set() + var canonicalNames = Set() + var totalSize: Int64 = 0 + for entry in expectedEntries { + guard + !entry.artifactId.isEmpty, + entry.artifactId.count <= 160, + entry.artifactId.allSatisfy({ + $0.isLetter || $0.isNumber || "._-".contains($0) + }), + isPortableArchiveEntryName( + entry.entryName, + canonicalNames: &canonicalNames + ), + names.insert(entry.entryName).inserted, + entry.expectedSize.isFinite, + entry.expectedSize > 0, + entry.expectedSize <= Double(128 * 1024 * 1024), + entry.expectedSize.rounded() == entry.expectedSize, + entry.expectedSha256.range( + of: "^[a-fA-F0-9]{64}$", + options: .regularExpression + ) != nil + else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expected entry is invalid" + ) + } + totalSize += Int64(entry.expectedSize) + guard totalSize <= 512 * 1024 * 1024 else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expected entries exceed limits" + ) + } + } + return expectedEntries.map { + FirmwareArchiveRequirement( + entryName: $0.entryName, + expectedSize: Int64($0.expectedSize), + expectedSha256: $0.expectedSha256.lowercased() + ) + } + } + + private func resolveArchiveRequirements( + _ expectedEntries: [FirmwareArchiveExpectedEntry]?, + archiveEntries: [FirmwareArchiveEntryInfo] + ) throws -> [FirmwareArchiveRequirement] { + if let expectedEntries { + return try validateArchiveRequirements(expectedEntries) + } + if let issue = firmwareArchiveDiscoveredEntriesIssue( + archiveEntries.map(\.name) + ) { + switch issue { + case .empty: + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive must contain at least one entry" + ) + case .duplicateName: + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive contains duplicate entry names" + ) + } + } + return archiveEntries.map { + FirmwareArchiveRequirement( + entryName: $0.name, + expectedSize: $0.uncompressedSize, + expectedSha256: nil + ) + } + } + + private func validateArchiveEntries( + _ entries: [FirmwareArchiveEntryInfo], + requirements: [FirmwareArchiveRequirement] + ) throws { + guard entries.count == requirements.count else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive has missing or extra entries" + ) + } + let requirementsByName = Dictionary( + uniqueKeysWithValues: requirements.map { ($0.entryName, $0) } + ) + var names = Set() + var canonicalNames = Set() + var totalSize: Int64 = 0 + for entry in entries { + guard let requirement = requirementsByName[entry.name] else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive contains an unexpected entry" + ) + } + let (nextTotalSize, overflowed) = totalSize.addingReportingOverflow( + entry.uncompressedSize + ) + guard !overflowed else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expanded size is invalid" + ) + } + totalSize = nextTotalSize + guard + names.insert(entry.name).inserted, + isPortableArchiveEntryName( + entry.name, + canonicalNames: &canonicalNames + ), + entry.uncompressedSize == requirement.expectedSize, + entry.uncompressedSize > 0, + entry.uncompressedSize <= 128 * 1024 * 1024, + totalSize <= 512 * 1024 * 1024, + entry.compressedSize >= 0, + entry.compressedSize <= 512 * 1024 * 1024, + entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000, + entry.diskNumber == 0, + entry.flags & 1 == 0, + entry.compressionMethod == 0 || entry.compressionMethod == 8, + entry.linkName?.isEmpty != false, + isRegularArchiveEntry(entry) + else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive entry metadata is invalid" + ) + } + } + } + + private func isPortableArchiveEntryName( + _ name: String, + canonicalNames: inout Set + ) -> Bool { + let lowered = name.precomposedStringWithCanonicalMapping.lowercased() + let components = name.split( + separator: "/", + omittingEmptySubsequences: false + ) + let nestedArchiveExtensions = [ + ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", + ] + guard + !name.isEmpty, + name.count <= 512, + name == name.precomposedStringWithCanonicalMapping, + !name.hasPrefix("/"), + !name.hasPrefix("\\"), + !name.contains("\\"), + !name.contains(":"), + name.unicodeScalars.allSatisfy({ + !CharacterSet.controlCharacters.contains($0) + }), + components.allSatisfy({ + !$0.isEmpty && + $0 != "." && + $0 != ".." && + !$0.hasSuffix(".") && + !$0.hasSuffix(" ") + }), + !nestedArchiveExtensions.contains(where: { + lowered.hasSuffix($0) + }), + canonicalNames.insert(lowered).inserted + else { + return false + } + return true + } + + private func isRegularArchiveEntry( + _ entry: FirmwareArchiveEntryInfo + ) -> Bool { + let hostSystem = UInt8(entry.versionMadeBy >> 8) + if hostSystem == 3 || hostSystem == 19 { + let fileType = (entry.externalAttributes >> 16) & 0xF000 + return fileType == 0 || fileType == 0x8000 + } + return entry.externalAttributes & 0x10 == 0 + } + + private func streamDownload( + _ params: FirmwareArtifactDownloadParams, + partialURL: URL, + resumeOffset: Int64, + validated: ValidatedDownload + ) async throws { + do { + try await FirmwareArtifactWallClockDeadline.run( + timeoutSeconds: + params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline + ) { [self] in + try await streamDownloadWithinDeadline( + params, + partialURL: partialURL, + resumeOffset: resumeOffset, + validated: validated + ) + } + } catch FirmwareArtifactDeadlineError.exceeded { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline" + ) + } + } + + private func streamDownloadWithinDeadline( + _ params: FirmwareArtifactDownloadParams, + partialURL: URL, + resumeOffset: Int64, + validated: ValidatedDownload + ) async throws { + guard let url = URL(string: params.url), let hostname = url.host else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL") + } + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = + params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + if resumeOffset > 0 { + request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range") + } + + let streamDelegate = FirmwareArtifactStreamDelegate( + partialURL: partialURL, + hostname: hostname, + resumeOffset: resumeOffset, + expectedSize: validated.expectedSize, + maxBytes: validated.maxBytes, + isCancelled: { [weak self] in + Task.isCancelled || + self?.isTransactionCancelled(params.transactionId) == true + } + ) + let session = URLSession( + configuration: .ephemeral, + delegate: streamDelegate, + delegateQueue: nil + ) + defer { + session.finishTasksAndInvalidate() + } + + do { + try await streamDelegate.run(session: session, request: request) + } catch let error as FirmwareArtifactStoreError { + throw error + } catch { + if isCancellationError( + error, + transactionId: params.transactionId + ) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } + if isFirmwareArtifactTLSError(error) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_TLS_FAILED: firmware TLS validation failed" + ) + } + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_NETWORK_FAILED: firmware request failed" + ) + } + try rejectIfCancelled(transactionId: params.transactionId) + } + + private func rejectIfCancelled(transactionId: String) throws { + if isTransactionCancelled(transactionId) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } + } + + func isTransactionCancelled(_ transactionId: String) -> Bool { + cancellationLock.withFirmwareArtifactLock { + cancelledTransactions.contains(transactionId) + } + } + + private func isCancellationError( + _ error: Error, + transactionId: String + ) -> Bool { + if error is CancellationError || + isTransactionCancelled(transactionId) { + return true + } + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && + nsError.code == NSURLErrorCancelled + } + + private func validateStoredArtifact( + fileURL: URL, + expectedSize: Int64, + expectedSha256: String + ) throws -> StoredFirmwareArtifact { + let size = try fileSize(fileURL) + guard size == expectedSize else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + ) + } + let sha256 = try hashFile(fileURL) + guard sha256 == expectedSha256 else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + ) + } + return StoredFirmwareArtifact( + artifactRef: "fw:\(sha256)", + size: size, + sha256: sha256, + fileURL: fileURL + ) + } + + private func validateDownloadedArtifact( + fileURL: URL, + expectedSize: Int64?, + expectedSha256: String?, + maxBytes: Int64 + ) throws -> StoredFirmwareArtifact { + let size = try fileSize(fileURL) + guard + size > 0, + size <= maxBytes, + expectedSize.map({ size == $0 }) ?? true + else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + ) + } + let sha256 = try hashFile(fileURL) + guard expectedSha256.map({ sha256 == $0 }) ?? true else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + ) + } + return StoredFirmwareArtifact( + artifactRef: "fw:\(sha256)", + size: size, + sha256: sha256, + fileURL: fileURL + ) + } + + private func resolveArtifactURL(_ artifactRef: String) throws -> URL { + guard artifactRef.hasPrefix("fw:") else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef") + } + let sha256 = String(artifactRef.dropFirst(3)) + guard sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef") + } + let url = artifactURL(sha256: sha256) + guard fileManager.fileExists(atPath: url.path) else { + throw FirmwareArtifactStoreError.invalidInput("Firmware artifact not found") + } + return url + } + + private func artifactURL(sha256: String) -> URL { + rootURL.appendingPathComponent("\(sha256).bin", isDirectory: false) + } + + private func fileSize(_ url: URL) throws -> Int64 { + let attributes = try fileManager.attributesOfItem(atPath: url.path) + return (attributes[.size] as? NSNumber)?.int64Value ?? 0 + } + + private func hashFile(_ url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let data = try handle.read(upToCount: 256 * 1024), !data.isEmpty { + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private func promote(source: URL, destination: URL) throws { + let stagingURL = destination + .deletingLastPathComponent() + .appendingPathComponent(".promote-\(UUID().uuidString)", isDirectory: false) + try fileManager.moveItem(at: source, to: stagingURL) + defer { + if fileManager.fileExists(atPath: stagingURL.path) { + try? fileManager.removeItem(at: stagingURL) + } + } + if fileManager.fileExists(atPath: destination.path) { + _ = try fileManager.replaceItemAt( + destination, + withItemAt: stagingURL, + backupItemName: nil, + options: [] + ) + } else { + try fileManager.moveItem(at: stagingURL, to: destination) + } + } +} + +private extension NSLock { + func withFirmwareArtifactLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 4c0ea0db..c7bea5df 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -1,6 +1,323 @@ import Foundation import CommonCrypto +enum FirmwareArtifactStoreError: Error, CustomStringConvertible { + case invalidInput(String) + case downloadFailed(String) + case integrityMismatch(String) + case readerInvalid(String) + case archiveInvalid(String) + + var description: String { + switch self { + case let .invalidInput(message), let .downloadFailed(message), + let .integrityMismatch(message), let .readerInvalid(message), + let .archiveInvalid(message): + return message + } + } +} + +enum FirmwareArtifactDeadlineError: Error { + case exceeded +} + +enum FirmwareArtifactWallClockDeadline { + static func run( + timeoutSeconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + let timeoutNanoseconds = UInt64(max(0.001, timeoutSeconds) * 1_000_000_000.0) + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + throw FirmwareArtifactDeadlineError.exceeded + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + throw FirmwareArtifactDeadlineError.exceeded + } + return result + } + } +} + +func firmwareArtifactDownloadKey( + transactionId: String, + taskId: String, + expectedSize: Int64?, + expectedSha256: String?, + downloadToken: String +) -> String { + "\(transactionId)|\(taskId)|\(expectedSize.map(String.init) ?? "unknown")|\(expectedSha256 ?? "unknown")|\(downloadToken)" +} + +enum FirmwareArchiveDiscoveredEntriesIssue: Equatable { + case empty + case duplicateName +} + +func firmwareArchiveDiscoveredEntriesIssue( + _ entryNames: [String] +) -> FirmwareArchiveDiscoveredEntriesIssue? { + guard !entryNames.isEmpty else { + return .empty + } + var names = Set() + guard entryNames.allSatisfy({ names.insert($0).inserted }) else { + return .duplicateName + } + return nil +} + +func firmwareArtifactDownloadToken( + expectedSha256: String?, + url: String +) -> String { + if let expectedSha256 { + return expectedSha256.lowercased() + } + let urlData = Data(url.utf8) + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + urlData.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(urlData.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() +} + +func firmwareArtifactPartialFileName( + transactionId: String, + taskId: String, + downloadToken: String +) -> String { + let transactionData = Data(transactionId.utf8) + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + transactionData.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(transactionData.count), &hash) + } + let transactionToken = hash + .prefix(8) + .map { String(format: "%02x", $0) } + .joined() + return "\(downloadToken).\(taskId).\(transactionToken).partial" +} + +func firmwareArtifactContentRangeIsValid( + _ value: String, + expectedStart: Int64, + expectedTotal: Int64?, + maxBytes: Int64 +) -> Bool { + guard value.lowercased().hasPrefix("bytes ") else { + return false + } + guard + let bounds = RangeDownloadLogic.parseContentRangeBounds(value), + let total = RangeDownloadLogic.parseContentRangeTotal(value) + else { + return false + } + let totalMatches = expectedTotal.map { total == $0 } ?? (total > 0 && total <= maxBytes) + return bounds.start == expectedStart && + bounds.end >= bounds.start && + bounds.end < total && + totalMatches +} + +func firmwareArtifactResponseFits( + expectedContentLength: Int64, + baseOffset: Int64, + maxBytes: Int64 +) -> Bool { + guard baseOffset >= 0, baseOffset <= maxBytes else { + return false + } + return expectedContentLength < 0 || + expectedContentLength <= maxBytes - baseOffset +} + +let firmwareArtifactFinalGrace: TimeInterval = 24 * 60 * 60 +let firmwareArtifactPartialGrace: TimeInterval = 7 * 24 * 60 * 60 +let firmwareArtifactScratchGrace: TimeInterval = firmwareArtifactPartialGrace +let firmwareArtifactMaxBytes: Int64 = 512 * 1024 * 1024 +let firmwareArtifactMaxReadBytes: Int64 = 256 * 1024 + +func firmwareArtifactExactInt64( + _ value: Double, + minimum: Int64, + maximum: Int64 +) -> Int64? { + guard + minimum <= maximum, + value.isFinite, + value.rounded() == value, + let converted = Int64(exactly: value), + converted >= minimum, + converted <= maximum + else { + return nil + } + return converted +} + +func firmwareArtifactIdentifierIsSafe(_ value: String) -> Bool { + value.range( + of: "^[A-Za-z0-9._:-]{1,160}$", + options: .regularExpression + ) != nil +} + +private func firmwareArtifactScratchNameIsValid( + _ name: String, + prefix: String +) -> Bool { + guard name.hasPrefix(prefix) else { + return false + } + let rawUUID = String(name.dropFirst(prefix.count)) + guard + rawUUID.count == 36, + let uuid = UUID(uuidString: rawUUID) + else { + return false + } + return uuid.uuidString.caseInsensitiveCompare(rawUUID) == .orderedSame +} + +private func firmwareArtifactEntrySize( + _ entryURL: URL, + values: URLResourceValues, + fileManager: FileManager +) -> Int64 { + if values.isRegularFile == true { + return Int64(values.fileSize ?? 0) + } + guard + values.isDirectory == true, + let enumerator = fileManager.enumerator( + at: entryURL, + includingPropertiesForKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + ], + options: [], + errorHandler: nil + ) + else { + return 0 + } + var size: Int64 = 0 + for case let childURL as URL in enumerator { + guard + let childValues = try? childURL.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey] + ), + childValues.isRegularFile == true, + childValues.isSymbolicLink != true + else { + continue + } + size += Int64(childValues.fileSize ?? 0) + } + return size +} + +func sweepFirmwareArtifactOrphansAtRoot( + _ rootURL: URL, + retainedSha256: Set, + activeSha256: Set, + openPaths: Set, + now: Date = Date(), + fileManager: FileManager = .default +) throws -> (deletedFiles: Int, deletedBytes: Int64) { + var deletedFiles = 0 + var deletedBytes: Int64 = 0 + let resourceKeys: Set = [ + .isRegularFileKey, + .isDirectoryKey, + .isSymbolicLinkKey, + .contentModificationDateKey, + .fileSizeKey, + ] + let entries = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: Array(resourceKeys), + options: [] + ) + for entryURL in entries { + let name = entryURL.lastPathComponent + let values = try entryURL.resourceValues(forKeys: resourceKeys) + let isArchiveScratch = firmwareArtifactScratchNameIsValid( + name, + prefix: "archive-" + ) + let isPromoteScratch = firmwareArtifactScratchNameIsValid( + name, + prefix: ".promote-" + ) + let isScratchCandidate = values.isSymbolicLink != true && + ((isArchiveScratch && values.isDirectory == true) || + (isPromoteScratch && values.isRegularFile == true)) + if isScratchCandidate { + guard + let modifiedAt = values.contentModificationDate, + now.timeIntervalSince(modifiedAt) >= firmwareArtifactScratchGrace + else { + continue + } + let size = firmwareArtifactEntrySize( + entryURL, + values: values, + fileManager: fileManager + ) + try fileManager.removeItem(at: entryURL) + deletedFiles += 1 + deletedBytes += size + continue + } + + guard + values.isRegularFile == true, + values.isSymbolicLink != true, + name.count >= 64 + else { + continue + } + let sha256 = String(name.prefix(64)) + guard + sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, + !retainedSha256.contains(sha256), + !activeSha256.contains(sha256), + !openPaths.contains(entryURL.path) + else { + continue + } + let grace: TimeInterval + if name.hasSuffix(".bin") { + grace = firmwareArtifactFinalGrace + } else if name.hasSuffix(".partial") { + grace = firmwareArtifactPartialGrace + } else { + continue + } + guard + let modifiedAt = values.contentModificationDate, + now.timeIntervalSince(modifiedAt) >= grace + else { + continue + } + let size = Int64(values.fileSize ?? 0) + try fileManager.removeItem(at: entryURL) + deletedFiles += 1 + deletedBytes += size + } + return (deletedFiles, deletedBytes) +} + // MARK: - Dependency-free RangeDownloader logic (OCDS §4 / §5) // // This file holds the DETERMINISTIC, dependency-light pieces of the range diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 98e0c650..8048681e 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -2,6 +2,16 @@ import Foundation import NitroModules import ReactNativeNativeLogger +private func settledFirmwareArtifactPromise( + _ operation: () throws -> T +) -> Promise { + do { + return Promise.resolved(withResult: try operation()) + } catch { + return Promise.rejected(withError: error) + } +} + // MARK: - Nitro HybridObject entry point // // Thin Nitro shim over `RangeDownloader.shared`. The heavy lifting — concurrent @@ -95,6 +105,188 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { } return NSTemporaryDirectory() } + + func getFirmwareArtifactCapabilities() throws -> FirmwareArtifactCapabilities { + FirmwareArtifactCapabilities( + firmwareArtifactProtocolVersion: 4, + supportedRouteTypes: ["domain"], + supportsArchiveMaterialization: true, + maxReadBytes: Double(FirmwareArtifactStore.maxReadBytes) + ) + } + + func downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams + ) throws -> Promise { + do { + if let artifact = try FirmwareArtifactStore.shared.cachedArtifact(params) { + return Promise.resolved( + withResult: FirmwareArtifactReceipt( + artifactRef: artifact.artifactRef, + size: Double(artifact.size), + sha256: artifact.sha256, + expectedSha256Verified: params.expectedSha256 != nil + ) + ) + } + } catch { + return Promise.rejected(withError: error) + } + return Promise.async { + let artifact = try await FirmwareArtifactStore.shared.download(params) + return FirmwareArtifactReceipt( + artifactRef: artifact.artifactRef, + size: Double(artifact.size), + sha256: artifact.sha256, + expectedSha256Verified: params.expectedSha256 != nil + ) + } + } + + func cancelFirmwareArtifactDownloads( + params: FirmwareArtifactCancelParams + ) throws -> Promise { + guard FirmwareArtifactStore.isSafeIdentifier(params.transactionId) else { + return Promise.rejected( + withError: FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transactionId" + ) + ) + } + return Promise.async { + try await FirmwareArtifactStore.shared.cancelDownloads( + transactionId: params.transactionId + ) + } + } + + func discardFirmwareArtifact( + params: FirmwareArtifactRefParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + try FirmwareArtifactStore.shared.discard(artifactRef: params.artifactRef) + } + } + + func openFirmwareArtifact( + params: FirmwareArtifactRefParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + let reader = try FirmwareArtifactStore.shared.open( + artifactRef: params.artifactRef + ) + return FirmwareArtifactReaderInfo( + readerId: reader.readerId, + size: Double(reader.size) + ) + } + } + + func readFirmwareArtifact( + params: FirmwareArtifactReaderReadParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + guard + let offset = firmwareArtifactExactInt64( + params.offset, + minimum: 0, + maximum: FirmwareArtifactStore.maxArtifactBytes + ), + let length = firmwareArtifactExactInt64( + params.length, + minimum: 1, + maximum: firmwareArtifactMaxReadBytes + ) + else { + throw FirmwareArtifactStoreError.readerInvalid( + "Invalid firmware artifact read" + ) + } + let data = try FirmwareArtifactStore.shared.read( + readerId: params.readerId, + offset: offset, + length: Int(length) + ) + return try ArrayBuffer.copy(data: data) + } + } + + func closeFirmwareArtifact( + params: FirmwareArtifactReaderCloseParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + try FirmwareArtifactStore.shared.close(readerId: params.readerId) + } + } + + func materializeFirmwareArchive( + params: FirmwareArchiveMaterializeParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + let entries = try FirmwareArtifactStore.shared.materializeArchive( + leaseRef: params.leaseRef, + artifactRef: params.archiveArtifactRef, + expectedEntries: params.expectedEntries + ) + return FirmwareArchiveMaterializeResult( + artifacts: entries.map { entry in + FirmwareArchiveMaterializedArtifact( + entryName: entry.entryName, + receipt: FirmwareArtifactReceipt( + artifactRef: entry.artifact.artifactRef, + size: Double(entry.artifact.size), + sha256: entry.artifact.sha256, + expectedSha256Verified: params.expectedEntries != nil + ) + ) + } + ) + } + } + + func createFirmwareArtifactLease( + params: FirmwareArtifactLeaseCreateParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + FirmwareArtifactLease( + leaseRef: try FirmwareArtifactStore.shared.createLease( + transactionId: params.transactionId + ) + ) + } + } + + func retainFirmwareArtifact( + params: FirmwareArtifactLeaseRetainParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + try FirmwareArtifactStore.shared.retain( + leaseRef: params.leaseRef, + artifactRef: params.artifactRef + ) + } + } + + func releaseFirmwareArtifactLease( + params: FirmwareArtifactLeaseReleaseParams + ) throws -> Promise { + settledFirmwareArtifactPromise { + try FirmwareArtifactStore.shared.releaseLease( + leaseRef: params.leaseRef, + disposition: params.disposition + ) + } + } + + func sweepFirmwareArtifactOrphans() throws -> Promise { + settledFirmwareArtifactPromise { + let result = try FirmwareArtifactStore.shared.sweepOrphans() + return FirmwareArtifactSweepResult( + deletedFiles: Double(result.deletedFiles), + deletedBytes: Double(result.deletedBytes) + ) + } + } } // MARK: - RangeDownloader (migrated core) @@ -326,16 +518,32 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { @objc private func handleBackgroundEventsNotification(_ note: Notification) { guard let identifier = note.userInfo?["identifier"] as? String, Self.channel(forIdentifier: identifier) != nil else { return } - // Re-create the session with this delegate so queued completion events are - // delivered here on a background relaunch. - _ = session(forIdentifier: identifier) if let handler = note.userInfo?["completionHandler"] as? () -> Void { - lock.lock() - backgroundCompletionHandlers[identifier] = handler - lock.unlock() + attachBackgroundEvents( + identifier: identifier, + completionHandler: handler + ) } } + private func attachBackgroundEvents( + identifier: String, + completionHandler: @escaping () -> Void + ) { + let replacedHandler: (() -> Void)? = lock.withLockValue { + backgroundCompletionHandlers.updateValue( + completionHandler, + forKey: identifier + ) + } + // UIKit should provide one live handler per session. Complete an older + // handler instead of leaking it if the callback is unexpectedly repeated. + replacedHandler?() + // Store the handler before creating the session. Delegate delivery can + // begin immediately when a background relaunch reattaches this identifier. + _ = session(forIdentifier: identifier) + } + // MARK: - Session cache private func session(forChannel channel: DownloadChannel, segmentCount: Int) -> URLSession { diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 813abd4b..72215ed2 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.80", + "version": "3.0.81-alpha.11", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index ddc96d68..ec8d9a4c 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -4,7 +4,7 @@ import type { HybridObject } from 'react-native-nitro-modules'; // The channel decides the iOS background URLSession identifier suffix and the // event-routing key, so different consumers never cross-talk. Closed set per // design decision 10.1 (constant enum, not a bare string). -export type DownloadChannel = 'bundle' | 'apk' | 'chart'; +export type DownloadChannel = 'bundle' | 'apk' | 'chart' | 'firmware'; export interface RangeDownloadParams { channel: DownloadChannel; @@ -68,6 +68,104 @@ export interface RangeDownloadEvent { message: string; } +export interface FirmwareArtifactDownloadParams { + taskId: string; + transactionId: string; + leaseRef: string; + artifactId: string; + url: string; + routeType: string; + resolvedIp?: string; + expectedSize?: number; + expectedSha256?: string; + maxBytes: number; + overallDeadlineSeconds?: number; // defaults to 180; must be > 0 and <= 24 hours +} + +export interface FirmwareArtifactReceipt { + artifactRef: string; + size: number; + sha256: string; + // True only when sha256 was compared with expectedSha256 supplied by the caller. + // A locally computed content-address digest without that expectation is not trusted integrity. + expectedSha256Verified: boolean; +} + +export interface FirmwareArtifactCapabilities { + firmwareArtifactProtocolVersion: number; + supportedRouteTypes: string[]; + supportsArchiveMaterialization: boolean; + maxReadBytes: number; +} + +export interface FirmwareArtifactRefParams { + artifactRef: string; +} + +export interface FirmwareArtifactCancelParams { + transactionId: string; +} + +export interface FirmwareArtifactReaderInfo { + readerId: string; + size: number; +} + +export interface FirmwareArtifactReaderReadParams { + readerId: string; + offset: number; + length: number; +} + +export interface FirmwareArtifactReaderCloseParams { + readerId: string; +} + +export interface FirmwareArchiveMaterializeParams { + leaseRef: string; + archiveArtifactRef: string; + expectedEntries?: FirmwareArchiveExpectedEntry[]; +} + +export interface FirmwareArchiveExpectedEntry { + artifactId: string; + entryName: string; + expectedSize: number; + expectedSha256: string; +} + +export interface FirmwareArchiveMaterializedArtifact { + entryName: string; + receipt: FirmwareArtifactReceipt; +} + +export interface FirmwareArchiveMaterializeResult { + artifacts: FirmwareArchiveMaterializedArtifact[]; +} + +export interface FirmwareArtifactLeaseCreateParams { + transactionId: string; +} + +export interface FirmwareArtifactLease { + leaseRef: string; +} + +export interface FirmwareArtifactLeaseRetainParams { + leaseRef: string; + artifactRef: string; +} + +export interface FirmwareArtifactLeaseReleaseParams { + leaseRef: string; + disposition: string; +} + +export interface FirmwareArtifactSweepResult { + deletedFiles: number; + deletedBytes: number; +} + export interface ReactNativeRangeDownloader extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { // Main entry: concurrent ranged download (iOS background session / Android thread pool). @@ -102,4 +200,35 @@ export interface ReactNativeRangeDownloader // not own directory layout for real consumers, but exposes this so demos/simple // callers have a valid absolute destination without hardcoding sandbox paths. getDownloadsDir(): string; + + getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities; + downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams + ): Promise; + cancelFirmwareArtifactDownloads( + params: FirmwareArtifactCancelParams + ): Promise; + discardFirmwareArtifact(params: FirmwareArtifactRefParams): Promise; + openFirmwareArtifact( + params: FirmwareArtifactRefParams + ): Promise; + readFirmwareArtifact( + params: FirmwareArtifactReaderReadParams + ): Promise; + closeFirmwareArtifact( + params: FirmwareArtifactReaderCloseParams + ): Promise; + materializeFirmwareArchive( + params: FirmwareArchiveMaterializeParams + ): Promise; + createFirmwareArtifactLease( + params: FirmwareArtifactLeaseCreateParams + ): Promise; + retainFirmwareArtifact( + params: FirmwareArtifactLeaseRetainParams + ): Promise; + releaseFirmwareArtifactLease( + params: FirmwareArtifactLeaseReleaseParams + ): Promise; + sweepFirmwareArtifactOrphans(): Promise; } diff --git a/native-modules/react-native-range-downloader/src/index.tsx b/native-modules/react-native-range-downloader/src/index.tsx index eb729725..ea3e6032 100644 --- a/native-modules/react-native-range-downloader/src/index.tsx +++ b/native-modules/react-native-range-downloader/src/index.tsx @@ -6,7 +6,8 @@ const ReactNativeRangeDownloaderHybridObject = 'ReactNativeRangeDownloader' ); -export const ReactNativeRangeDownloader = ReactNativeRangeDownloaderHybridObject; +export const ReactNativeRangeDownloader = + ReactNativeRangeDownloaderHybridObject; // Closed set of download channels (design decision 10.1). The native side builds the // background session identifier as `so.onekey.rangedownloader.bg.`. New channels @@ -15,6 +16,7 @@ export const RangeDownloadChannel = { Bundle: 'bundle', Apk: 'apk', Chart: 'chart', + Firmware: 'firmware', } as const; export type * from './ReactNativeRangeDownloader.nitro'; diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index f101c318..651c89e8 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -26,6 +26,376 @@ import Foundation // ─────────────────────────────────────────────────────────────────────────── final class RangeDownloadLogicTests: XCTestCase { + func testFirmwareArtifactDownloadKeyIsolatesTransactions() { + let first = firmwareArtifactDownloadKey( + transactionId: "fwtx:first", + taskId: "firmware", + expectedSize: 42, + expectedSha256: String(repeating: "a", count: 64), + downloadToken: String(repeating: "a", count: 64) + ) + let second = firmwareArtifactDownloadKey( + transactionId: "fwtx:second", + taskId: "firmware", + expectedSize: 42, + expectedSha256: String(repeating: "a", count: 64), + downloadToken: String(repeating: "a", count: 64) + ) + + XCTAssertNotEqual(first, second) + } + + func testFirmwareArtifactDownloadKeyIsolatesUnverifiedURLs() { + let first = firmwareArtifactDownloadKey( + transactionId: "fwtx:same", + taskId: "firmware", + expectedSize: nil, + expectedSha256: nil, + downloadToken: firmwareArtifactDownloadToken( + expectedSha256: nil, + url: "https://common.onekey-asset.com/first.bin" + ) + ) + let second = firmwareArtifactDownloadKey( + transactionId: "fwtx:same", + taskId: "firmware", + expectedSize: nil, + expectedSha256: nil, + downloadToken: firmwareArtifactDownloadToken( + expectedSha256: nil, + url: "https://common.onekey-asset.com/second.bin" + ) + ) + + XCTAssertNotEqual(first, second) + } + + func testFirmwareArchiveDiscoveredEntriesRejectEmptyAndDuplicateNames() { + XCTAssertEqual(firmwareArchiveDiscoveredEntriesIssue([]), .empty) + XCTAssertEqual( + firmwareArchiveDiscoveredEntriesIssue(["fw.bin", "fw.bin"]), + .duplicateName + ) + XCTAssertNil( + firmwareArchiveDiscoveredEntriesIssue(["fw.bin", "resource.bin"]) + ) + } + + func testFirmwareArtifactPartialFileNameIsolatesTransactions() { + let sha256 = String(repeating: "a", count: 64) + let first = firmwareArtifactPartialFileName( + transactionId: "fwtx:first", + taskId: "firmware", + downloadToken: sha256 + ) + let second = firmwareArtifactPartialFileName( + transactionId: "fwtx:second", + taskId: "firmware", + downloadToken: sha256 + ) + + XCTAssertNotEqual(first, second) + XCTAssertTrue(first.hasPrefix("\(sha256).firmware.")) + XCTAssertTrue(first.hasSuffix(".partial")) + } + + func testFirmwareArtifactContentRangeValidation() { + XCTAssertTrue( + firmwareArtifactContentRangeIsValid( + "bytes 128-255/256", + expectedStart: 128, + expectedTotal: 256, + maxBytes: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "bytes 0-255/256", + expectedStart: 128, + expectedTotal: 256, + maxBytes: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "bytes 128-256/256", + expectedStart: 128, + expectedTotal: 256, + maxBytes: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "items 128-255/256", + expectedStart: 128, + expectedTotal: 256, + maxBytes: 256 + ) + ) + } + + func testFirmwareArtifactContentRangeAcceptsUnknownExpectedTotalWithinBound() { + XCTAssertTrue( + firmwareArtifactContentRangeIsValid( + "bytes 128-255/256", + expectedStart: 128, + expectedTotal: nil, + maxBytes: 512 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "bytes 128-1023/1024", + expectedStart: 128, + expectedTotal: nil, + maxBytes: 512 + ) + ) + } + + func testFirmwareArtifactDownloadTokenFallsBackToStableUrlHash() { + let url = "https://common.onekey-asset.com/firmware.bin" + let first = firmwareArtifactDownloadToken(expectedSha256: nil, url: url) + let second = firmwareArtifactDownloadToken(expectedSha256: nil, url: url) + + XCTAssertEqual(first, second) + XCTAssertEqual(first.count, 64) + XCTAssertEqual( + firmwareArtifactDownloadToken( + expectedSha256: String(repeating: "A", count: 64), + url: url + ), + String(repeating: "a", count: 64) + ) + } + + func testFirmwareArtifactResponseSizeIsBoundedBeforeStreaming() { + XCTAssertTrue( + firmwareArtifactResponseFits( + expectedContentLength: 128, + baseOffset: 128, + maxBytes: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactResponseFits( + expectedContentLength: 129, + baseOffset: 128, + maxBytes: 256 + ) + ) + XCTAssertTrue( + firmwareArtifactResponseFits( + expectedContentLength: -1, + baseOffset: 128, + maxBytes: 256 + ) + ) + } + + func testFirmwareArtifactStoreErrorsHaveStableDescriptions() { + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.invalidInput("ARTIFACT_INVALID_INPUT")), + "ARTIFACT_INVALID_INPUT" + ) + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.downloadFailed("ARTIFACT_CANCELLED")), + "ARTIFACT_CANCELLED" + ) + XCTAssertEqual( + String( + describing: FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED" + ) + ), + "ARTIFACT_INTEGRITY_FAILED" + ) + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.readerInvalid("ARTIFACT_READER_INVALID")), + "ARTIFACT_READER_INVALID" + ) + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.archiveInvalid("ARTIFACT_ARCHIVE_INVALID")), + "ARTIFACT_ARCHIVE_INVALID" + ) + } + + func testFirmwareArtifactIdentifierValidationForSynchronousCancelRejection() { + XCTAssertTrue(firmwareArtifactIdentifierIsSafe("fwtx:valid-1")) + XCTAssertFalse(firmwareArtifactIdentifierIsSafe("")) + XCTAssertFalse(firmwareArtifactIdentifierIsSafe("invalid/path")) + XCTAssertFalse( + firmwareArtifactIdentifierIsSafe(String(repeating: "a", count: 161)) + ) + } + + func testFirmwareArtifactExactInt64RejectsRoundedOverflowAndOutOfBoundsValues() { + XCTAssertNil( + firmwareArtifactExactInt64( + pow(2.0, 63.0), + minimum: 1, + maximum: firmwareArtifactMaxBytes + ) + ) + XCTAssertNil( + firmwareArtifactExactInt64( + Double(firmwareArtifactMaxBytes) + 1, + minimum: 1, + maximum: firmwareArtifactMaxBytes + ) + ) + XCTAssertNil( + firmwareArtifactExactInt64( + 1.5, + minimum: 1, + maximum: firmwareArtifactMaxBytes + ) + ) + XCTAssertEqual( + firmwareArtifactExactInt64( + Double(firmwareArtifactMaxBytes), + minimum: 1, + maximum: firmwareArtifactMaxBytes + ), + firmwareArtifactMaxBytes + ) + XCTAssertEqual( + firmwareArtifactExactInt64( + Double(firmwareArtifactMaxReadBytes), + minimum: 1, + maximum: firmwareArtifactMaxReadBytes + ), + firmwareArtifactMaxReadBytes + ) + } + + func testFirmwareArtifactOrphanSweepRemovesOnlyStaleRootScratchEntries() throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent( + "firmware-artifact-sweep-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: rootURL) } + + let now = Date(timeIntervalSince1970: 2_000_000_000) + let staleDate = now.addingTimeInterval(-firmwareArtifactScratchGrace - 1) + let freshDate = now.addingTimeInterval(-firmwareArtifactScratchGrace + 1) + let staleArchive = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000001", + isDirectory: true + ) + let stalePromote = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000002" + ) + let freshArchive = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000003", + isDirectory: true + ) + let freshPromote = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000004" + ) + let malformedArchive = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000005.extra", + isDirectory: true + ) + let malformedPromote = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000006.tmp" + ) + let archiveNamedFile = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000007" + ) + let promoteNamedDirectory = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000008", + isDirectory: true + ) + let nestedContainer = rootURL.appendingPathComponent("nested", isDirectory: true) + let nestedArchive = nestedContainer.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000009", + isDirectory: true + ) + + for directory in [ + staleArchive, + freshArchive, + malformedArchive, + promoteNamedDirectory, + nestedArchive, + ] { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + try Data("archive".utf8).write( + to: staleArchive.appendingPathComponent("0.entry") + ) + for file in [stalePromote, freshPromote, malformedPromote, archiveNamedFile] { + try Data("promote".utf8).write(to: file) + } + for entry in [ + staleArchive, + stalePromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ] { + try fileManager.setAttributes( + [.modificationDate: staleDate], + ofItemAtPath: entry.path + ) + } + for entry in [freshArchive, freshPromote] { + try fileManager.setAttributes( + [.modificationDate: freshDate], + ofItemAtPath: entry.path + ) + } + + let result = try sweepFirmwareArtifactOrphansAtRoot( + rootURL, + retainedSha256: [], + activeSha256: [], + openPaths: [], + now: now, + fileManager: fileManager + ) + + XCTAssertEqual(result.deletedFiles, 2) + XCTAssertEqual(result.deletedBytes, 14) + XCTAssertFalse(fileManager.fileExists(atPath: staleArchive.path)) + XCTAssertFalse(fileManager.fileExists(atPath: stalePromote.path)) + for retainedEntry in [ + freshArchive, + freshPromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ] { + XCTAssertTrue( + fileManager.fileExists(atPath: retainedEntry.path), + "Unexpectedly removed \(retainedEntry.lastPathComponent)" + ) + } + } + + func testFirmwareArtifactWallClockDeadlineRejectsSlowOperation() async { + do { + _ = try await FirmwareArtifactWallClockDeadline.run( + timeoutSeconds: 0.01 + ) { + try await Task.sleep(nanoseconds: 1_000_000_000) + return true + } + XCTFail("Expected the wall-clock deadline to reject") + } catch FirmwareArtifactDeadlineError.exceeded { + // Expected. + } catch { + XCTFail("Unexpected error: \(error)") + } + } + // MARK: §4 — HTTP status classification // // OCDS §4. Mirrors Android's IsPermanentHttpStatusTest matrix. Asserts BOTH the diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt index 265d61bb..0a0e887b 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -13,11 +13,9 @@ import okhttp3.Call import okhttp3.Callback import okhttp3.ConnectionPool import okhttp3.Dispatcher -import okhttp3.Dns import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient -import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -25,7 +23,6 @@ import okhttp3.ResponseBody import okio.Buffer import java.io.IOException import java.io.InterruptedIOException -import java.net.InetAddress import java.net.Proxy import java.net.ProxySelector import java.net.URI @@ -37,7 +34,6 @@ import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLException import javax.net.ssl.SSLPeerUnverifiedException @@ -405,24 +401,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) : synchronized(clientCache) { clientCache[key]?.let { return it } - val client = OkHttpClient.Builder() - .dispatcher(sharedDispatcher) - .connectionPool(sharedConnectionPool) - .proxy(Proxy.NO_PROXY) - .protocols(listOf(Protocol.HTTP_1_1)) - .connectTimeout(0, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) - .writeTimeout(0, TimeUnit.MILLISECONDS) - .callTimeout(0, TimeUnit.MILLISECONDS) - .followRedirects(false) - .followSslRedirects(false) - // TLS is validated normally: cert chain via the default trust manager and - // hostname verification against the REAL hostname (not the pinned IP). - .hostnameVerifier { _, session -> - HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session) - } - .dns(createPinnedDns(config.ip, config.hostname)) - .build() + val client = SniPinnedTransport.createClient( + ip = config.ip, + hostname = config.hostname, + dispatcher = sharedDispatcher, + connectionPool = sharedConnectionPool, + ) clientCache[key] = client SniConnectLogger.info( @@ -442,29 +426,6 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } } - private fun createPinnedDns(ip: String, hostname: String): Dns = - object : Dns { - private val expectedHost = hostname.lowercase(Locale.US) - // Resolve the literal IP once up front (validated; never triggers DNS). - private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip) - - override fun lookup(requestedHost: String): List { - return if (requestedHost.lowercase(Locale.US) == expectedHost) { - listOf(pinnedAddress) - } else { - SniConnectLogger.warn( - SniConnectLogger.event( - "sni_pinned_dns_unexpected_host", - "expectedHost" to expectedHost, - "requestedHostHash" to SniConnectLogger.shortHash(requestedHost.lowercase(Locale.US)), - "result" to "fail_closed", - ), - ) - throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost") - } - } - } - /** * Build the request. Always `https://` on the implicit port 443 — * `path` has been validated as relative, so scheme/host/port cannot be overridden. diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt new file mode 100644 index 00000000..0491d5fd --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt @@ -0,0 +1,58 @@ +package com.sniconnect + +import java.net.InetAddress +import java.net.Proxy +import java.net.UnknownHostException +import java.util.Locale +import java.util.concurrent.TimeUnit +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Protocol +import javax.net.ssl.HttpsURLConnection + +object SniPinnedTransport { + @JvmStatic + fun createClient( + ip: String, + hostname: String, + dispatcher: Dispatcher = Dispatcher(), + connectionPool: ConnectionPool = ConnectionPool(), + ): OkHttpClient { + SniConnectValidation.validatePublicIp(ip) + SniConnectValidation.validateHostname(hostname) + val normalizedHostname = hostname.lowercase(Locale.US) + return OkHttpClient.Builder() + .dispatcher(dispatcher) + .connectionPool(connectionPool) + .proxy(Proxy.NO_PROXY) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .followRedirects(false) + .followSslRedirects(false) + .hostnameVerifier { _, session -> + HttpsURLConnection.getDefaultHostnameVerifier().verify(normalizedHostname, session) + } + .dns(createPinnedDns(ip, normalizedHostname)) + .build() + } + + private fun createPinnedDns(ip: String, hostname: String): Dns = + object : Dns { + private val pinnedAddress: InetAddress = + SniConnectValidation.literalToInetAddress(ip) + + override fun lookup(requestedHost: String): List { + if (requestedHost.lowercase(Locale.US) == hostname) { + return listOf(pinnedAddress) + } + throw UnknownHostException( + "Unexpected host for pinned SNI request: $requestedHost" + ) + } + } +} diff --git a/native-modules/react-native-sni-connect/eslint.config.mjs b/native-modules/react-native-sni-connect/eslint.config.mjs new file mode 100644 index 00000000..3b9276a8 --- /dev/null +++ b/native-modules/react-native-sni-connect/eslint.config.mjs @@ -0,0 +1,27 @@ +import { fixupConfigRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: [ + '**/node_modules', + '**/android/build', + '**/ios/build', + '**/lib', + '**/*.config.js', + '**/*.config.mjs', + ], + }, + ...fixupConfigRules(compat.extends('@react-native', 'prettier')), +]; diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index f660eed0..07b52a37 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -4,13 +4,13 @@ import UIKit import EMASCurl @objc(SniConnectPinnedDNSResolverBase) -private class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver { +class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver { @objc class func resolveDomain(_ domain: String) -> String? { PinnedDNSResolverFactory.resolve(domain: domain, resolverClass: self) } } -private enum PinnedDNSResolverFactory { +enum PinnedDNSResolverFactory { private static let queue = DispatchQueue(label: "com.onekey.sni.connect.pinned-dns-resolvers") private static var nextClassID = 0 private static let registry = SniConnectPinnedResolverRegistry() @@ -54,7 +54,7 @@ private enum PinnedDNSResolverFactory { } } -private final class SniConnectPinnedResolverLease { +final class SniConnectPinnedResolverLease { private let hostname: String private let ip: String private let queue = DispatchQueue(label: "com.onekey.sni.connect.resolver-lease") @@ -90,15 +90,22 @@ private final class SniConnectPinnedResolverLease { } } -private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate { +final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDataDelegate { private let hostname: String private let ip: String private let resolverLease: SniConnectPinnedResolverLease - - init(hostname: String, ip: String, resolverLease: SniConnectPinnedResolverLease) { + private weak var forwardingDataDelegate: URLSessionDataDelegate? + + init( + hostname: String, + ip: String, + resolverLease: SniConnectPinnedResolverLease, + forwardingDataDelegate: URLSessionDataDelegate? = nil + ) { self.hostname = hostname self.ip = ip self.resolverLease = resolverLease + self.forwardingDataDelegate = forwardingDataDelegate } func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { @@ -110,6 +117,66 @@ private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionD ("success", error == nil), ])) } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + if let forwardingDataDelegate { + forwardingDataDelegate.urlSession?( + session, + task: task, + willPerformHTTPRedirection: response, + newRequest: request, + completionHandler: completionHandler + ) + } else { + completionHandler(nil) + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + forwardingDataDelegate?.urlSession?( + session, + dataTask: dataTask, + didReceive: response, + completionHandler: completionHandler + ) ?? completionHandler( + SniConnectSessionDelegatePolicy.responseDispositionWithoutForwardingDelegate + ) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + forwardingDataDelegate?.urlSession?( + session, + dataTask: dataTask, + didReceive: data + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + forwardingDataDelegate?.urlSession?( + session, + task: task, + didCompleteWithError: error + ) + } } /// Core HTTPS client that enforces IP direct connection with SNI. @@ -334,33 +401,10 @@ final class SniConnectClient { } private static func makeURLSession(for key: SessionKey) throws -> ManagedSession { - let configuration = URLSessionConfiguration.default - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.urlCache = nil - configuration.httpCookieStorage = nil - configuration.httpShouldSetCookies = false - configuration.connectionProxyDictionary = [:] - configuration.shouldUseExtendedBackgroundIdleMode = false - - let curlConfig = EMASCurlConfiguration.default() - curlConfig.httpVersion = .HTTP1 - curlConfig.connectTimeoutInterval = 2.5 - curlConfig.enableBuiltInGzip = false - curlConfig.enableBuiltInRedirection = false - curlConfig.cacheEnabled = false - - // Enable full certificate validation for security. - // The certificate is validated against the SNI hostname, not the IP, because - // the custom DNS resolver only overrides address resolution — libcurl keeps the - // original hostname for SNI and certificate CN/SAN matching. - curlConfig.certificateValidationEnabled = true - curlConfig.domainNameVerificationEnabled = true - curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass( + let resources = try SniConnectPinnedTransport.makeResources( hostname: key.hostname, ip: key.ip ) - - EMASCurlProtocol.install(into: configuration, with: curlConfig) SniConnectLog.info(SniConnectLog.event("sni_transport_config", [ ("hostname", key.hostname), ("ipHash", SniConnectLog.shortHash(key.ip)), @@ -371,15 +415,13 @@ final class SniConnectClient { ("followRedirects", false), ("cacheEnabled", false), ])) - let resolverLease = SniConnectPinnedResolverLease(hostname: key.hostname, ip: key.ip) - let delegate = SniConnectSessionInvalidationDelegate( - hostname: key.hostname, - ip: key.ip, - resolverLease: resolverLease - ) return ManagedSession( - session: URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), - resolverLease: resolverLease + session: URLSession( + configuration: resources.configuration, + delegate: resources.delegate, + delegateQueue: nil + ), + resolverLease: resources.resolverLease ) } diff --git a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift index db21e3af..06656df4 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift @@ -1,5 +1,9 @@ import Foundation +enum SniConnectSessionDelegatePolicy { + static let responseDispositionWithoutForwardingDelegate: URLSession.ResponseDisposition = .allow +} + enum SniConnectCoreDiagnostics { static var warnSink: ((String) -> Void)? diff --git a/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift b/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift new file mode 100644 index 00000000..65f0f55b --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift @@ -0,0 +1,92 @@ +import Foundation +import EMASCurl + +struct SniConnectPinnedTransportResources { + let configuration: URLSessionConfiguration + let resolverLease: SniConnectPinnedResolverLease + let delegate: SniConnectSessionInvalidationDelegate +} + +public final class SniConnectPinnedSession { + public let session: URLSession + + init(resources: SniConnectPinnedTransportResources) { + session = URLSession( + configuration: resources.configuration, + delegate: resources.delegate, + delegateQueue: nil + ) + } + + public func close() { + session.finishTasksAndInvalidate() + } + + deinit { + close() + } +} + +public enum SniConnectPinnedTransport { + static func makeResources( + hostname: String, + ip: String, + dataDelegate: URLSessionDataDelegate? = nil + ) throws -> SniConnectPinnedTransportResources { + try SniConnectValidation.validateHostname(hostname) + try SniConnectValidation.validatePublicIP(ip) + + let normalizedHostname = hostname.lowercased() + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.connectionProxyDictionary = [:] + configuration.shouldUseExtendedBackgroundIdleMode = false + + let curlConfig = EMASCurlConfiguration.default() + curlConfig.httpVersion = .HTTP1 + curlConfig.connectTimeoutInterval = 2.5 + curlConfig.enableBuiltInGzip = false + curlConfig.enableBuiltInRedirection = false + curlConfig.cacheEnabled = false + curlConfig.certificateValidationEnabled = true + curlConfig.domainNameVerificationEnabled = true + curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass( + hostname: normalizedHostname, + ip: ip + ) + EMASCurlProtocol.install(into: configuration, with: curlConfig) + + let resolverLease = SniConnectPinnedResolverLease( + hostname: normalizedHostname, + ip: ip + ) + let delegate = SniConnectSessionInvalidationDelegate( + hostname: normalizedHostname, + ip: ip, + resolverLease: resolverLease, + forwardingDataDelegate: dataDelegate + ) + return SniConnectPinnedTransportResources( + configuration: configuration, + resolverLease: resolverLease, + delegate: delegate + ) + } + + public static func makeSession( + hostname: String, + ip: String, + dataDelegate: URLSessionDataDelegate? = nil + ) throws -> SniConnectPinnedSession { + SniConnectPinnedSession( + resources: try makeResources( + hostname: hostname, + ip: ip, + dataDelegate: dataDelegate + ) + ) + } +} diff --git a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift index 6e3403cf..90a74d67 100644 --- a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +++ b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift @@ -3,6 +3,15 @@ import XCTest final class SniConnectValidationTests: XCTestCase { + func testSessionWithoutForwardingDataDelegateAllowsResponse() { + switch SniConnectSessionDelegatePolicy.responseDispositionWithoutForwardingDelegate { + case .allow: + break + default: + XCTFail("A session without a forwarding data delegate must allow its response") + } + } + func testAcceptsValidRequestBoundaryValues() throws { XCTAssertNoThrow(try SniConnectValidation.validateRequestId("req-1")) XCTAssertNoThrow(try SniConnectValidation.validateTimeout(120_000)) diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 72f9af30..5b1c37ff 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.80", + "version": "3.0.81-alpha.9", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -161,5 +161,6 @@ "languages": "kotlin-objc", "type": "turbo-module", "version": "0.54.8" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index bc5c76cb..e51ff67a 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 4a6a02d7..78976254 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index f391d7a5..edb84535 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -159,5 +159,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 959bd8ed..1a018fa0 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -94,5 +94,6 @@ } ] ] - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 025a040f..7ac143d9 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -167,5 +167,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index dd78bf9c..e262d078 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 205b0a27..23eb5124 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -98,5 +98,6 @@ "RNCViewPager": "RNCPagerViewComponentView" } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index a42f3488..948c94e0 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index e47def84..fe30c539 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -103,5 +103,6 @@ "create-react-native-library": { "type": "nitro-view", "languages": "kotlin-swift" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index aacfab4b..d26ee3b0 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 8cdfa451..a5a1af6f 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -167,5 +167,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 0ad46162..219f15f6 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.80", + "version": "3.0.81-alpha.8", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -111,5 +111,6 @@ ] } } - } + }, + "stableVersion": "3.0.80" } diff --git a/scripts/validate-npm-dist-tag.mjs b/scripts/validate-npm-dist-tag.mjs new file mode 100644 index 00000000..5206e785 --- /dev/null +++ b/scripts/validate-npm-dist-tag.mjs @@ -0,0 +1,87 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const allowedDistTags = new Set(["latest", "next"]); + +export function validateNpmDistTag(distTag, releaseWorkspaces) { + if (!allowedDistTags.has(distTag)) { + throw new Error(`Unsupported npm dist-tag: ${distTag}`); + } + + if (distTag !== "latest") { + return; + } + + const prereleaseWorkspaces = releaseWorkspaces.filter(({ version }) => + version.includes("-") + ); + if (prereleaseWorkspaces.length === 0) { + return; + } + + const versions = prereleaseWorkspaces + .map(({ name, version }) => `${name}@${version}`) + .sort() + .join(", "); + throw new Error( + `Refusing to publish prerelease workspaces with the latest dist-tag: ${versions}` + ); +} + +async function loadReleaseWorkspaces(repoRoot) { + const rootPackage = JSON.parse( + await readFile(join(repoRoot, "package.json"), "utf8") + ); + const releaseWorkspaces = []; + + for (const workspacePattern of rootPackage.workspaces ?? []) { + if (!workspacePattern.endsWith("/*")) { + throw new Error(`Unsupported workspace pattern: ${workspacePattern}`); + } + const workspaceRoot = join(repoRoot, workspacePattern.slice(0, -2)); + const entries = await readdir(workspaceRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const packagePath = join(workspaceRoot, entry.name, "package.json"); + let workspacePackage; + try { + workspacePackage = JSON.parse(await readFile(packagePath, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + continue; + } + throw error; + } + if ( + workspacePackage.private !== true && + typeof workspacePackage.scripts?.release === "string" + ) { + releaseWorkspaces.push({ + name: workspacePackage.name, + version: workspacePackage.version, + }); + } + } + } + + return releaseWorkspaces; +} + +async function main() { + const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const releaseWorkspaces = await loadReleaseWorkspaces(repoRoot); + validateNpmDistTag(process.argv[2], releaseWorkspaces); + console.log( + `Validated npm dist-tag ${process.argv[2]} for ${releaseWorkspaces.length} release workspaces` + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/validate-npm-dist-tag.test.mjs b/scripts/validate-npm-dist-tag.test.mjs new file mode 100644 index 00000000..65581db4 --- /dev/null +++ b/scripts/validate-npm-dist-tag.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { validateNpmDistTag } from "./validate-npm-dist-tag.mjs"; + +const prereleaseWorkspaces = [ + { name: "@onekeyfe/module-a", version: "3.0.81-alpha.1" }, + { name: "@onekeyfe/module-b", version: "3.0.81-alpha.2" }, +]; + +test("allows prerelease workspaces on the next dist-tag", () => { + assert.doesNotThrow(() => validateNpmDistTag("next", prereleaseWorkspaces)); +}); + +test("rejects prerelease workspaces on the latest dist-tag", () => { + assert.throws( + () => validateNpmDistTag("latest", prereleaseWorkspaces), + /Refusing to publish prerelease workspaces with the latest dist-tag/ + ); +}); + +test("allows stable workspaces on the latest dist-tag", () => { + assert.doesNotThrow(() => + validateNpmDistTag("latest", [ + { name: "@onekeyfe/module-a", version: "3.0.81" }, + ]) + ); +}); + +test("rejects unsupported dist-tags", () => { + assert.throws( + () => validateNpmDistTag("beta", prereleaseWorkspaces), + /Unsupported npm dist-tag/ + ); +});