diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67fc428..84353db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install evidence replay tools + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends ffmpeg + ffmpeg -version | head -n 1 + - name: Verify package run: pnpm verify diff --git a/.github/workflows/demo-evidence.yml b/.github/workflows/demo-evidence.yml index 57b4ad2..9898684 100644 --- a/.github/workflows/demo-evidence.yml +++ b/.github/workflows/demo-evidence.yml @@ -11,6 +11,8 @@ on: - "src/**" - "package.json" - "scripts/capture-android-demo.sh" + - "scripts/create-economic-resilience-environment.mjs" + - "scripts/create-economic-resilience-evidence.mjs" - "scripts/benchmark-core.mjs" - "scripts/benchmark-comparison-core.mjs" - "scripts/create-benchmark-comparison-evidence.mjs" @@ -20,7 +22,11 @@ on: - "scripts/demo-visual-agreement-core.mjs" - "scripts/measure-demo-visual-agreement.mjs" - "scripts/demo-evidence-core.mjs" + - "scripts/economic-resilience-evidence-core.mjs" + - "scripts/verify-economic-resilience-evidence.mjs" - "scripts/guided-demo-core.mjs" + - "scripts/inspect-ios-simulator-metadata.mjs" + - "scripts/ios-simulator-metadata-core.mjs" - "scripts/normalize-demo-recording.mjs" - "scripts/verify-benchmark-comparison-evidence.mjs" workflow_dispatch: @@ -48,6 +54,14 @@ jobs: with: ref: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source checkout + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + [[ "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + - name: Setup Java uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: @@ -127,6 +141,14 @@ jobs: with: ref: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source checkout + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + [[ "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + - name: Setup pnpm uses: ./.github/actions/setup-pnpm @@ -158,8 +180,23 @@ jobs: shell: bash run: | set -euo pipefail - udid=$(xcrun simctl list devices booted --json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=Object.values(JSON.parse(s).devices).flat()[0];if(!d)process.exit(1);process.stdout.write(d.udid)})") app=example/ios/build/Build/Products/Debug-iphonesimulator/ImageCompressionKitExample.app + app_executable="$app/ImageCompressionKitExample" + test -f "$app_executable" + test ! -L "$app_executable" + app_architectures=$(xcrun lipo -archs "$app_executable" | tr -d '\r') + xcrun simctl list devices booted --json > /tmp/rnick-sim-devices.json + xcrun simctl list runtimes --json > /tmp/rnick-sim-runtimes.json + node scripts/inspect-ios-simulator-metadata.mjs \ + --devices /tmp/rnick-sim-devices.json \ + --runtimes /tmp/rnick-sim-runtimes.json \ + --app-architectures "$app_architectures" \ + --runner-arch "$RUNNER_ARCH" > /tmp/rnick-sim-metadata.json + udid=$(node -e "process.stdout.write(require('/tmp/rnick-sim-metadata.json').udid)") + runtime=$(node -e "process.stdout.write(require('/tmp/rnick-sim-metadata.json').runtime)") + device=$(node -e "process.stdout.write(require('/tmp/rnick-sim-metadata.json').device)") + os_build=$(node -e "process.stdout.write(require('/tmp/rnick-sim-metadata.json').osBuild)") + abi=$(node -e "process.stdout.write(require('/tmp/rnick-sim-metadata.json').abi)") xcrun simctl install "$udid" "$app" pnpm --filter image-compression-kit-example exec react-native start --port 8081 > /tmp/rnick-metro.log 2>&1 & metro_pid=$! @@ -189,7 +226,10 @@ jobs: sleep 2 SIMCTL_CHILD_RNICK_DEMO_CAPTURE=1 xcrun simctl launch --terminate-running-process "$udid" com.imagecompressionkit.example --rnick-demo-capture capture_native_log() { - xcrun simctl spawn "$udid" log show --style compact --last 3m --predicate 'eventMessage CONTAINS "RNICK_DEMO_" OR eventMessage CONTAINS "RNICK_GUIDED_DEMO_" OR eventMessage CONTAINS "RNICK_BENCHMARK_"' > /tmp/rnick-demo-raw/native.log + # The simulator is fresh for this job. A 15-minute window exceeds + # every bounded capture poll while avoiding fragile device-local + # date parsing in `log show --start`. + xcrun simctl spawn "$udid" log show --style compact --last 15m --predicate 'eventMessage CONTAINS "RNICK_DEMO_" OR eventMessage CONTAINS "RNICK_GUIDED_DEMO_" OR eventMessage CONTAINS "RNICK_BENCHMARK_" OR eventMessage CONTAINS "RNICK_ECONOMIC_RESILIENCE_"' > /tmp/rnick-demo-raw/native.log } for attempt in $(seq 1 60); do capture_native_log @@ -219,14 +259,26 @@ jobs: test "$attempt" != "60" sleep 1 done - for attempt in $(seq 1 120); do + for attempt in $(seq 1 300); do capture_native_log if grep -q 'RNICK_DEMO_PASS' /tmp/rnick-demo-raw/native.log && \ grep -q 'RNICK_BENCHMARK_PASS' /tmp/rnick-demo-raw/native.log && \ - grep -q 'RNICK_BENCHMARK_COMPARISON_PASS' /tmp/rnick-demo-raw/native.log; then + grep -q 'RNICK_BENCHMARK_COMPARISON_PASS' /tmp/rnick-demo-raw/native.log && \ + grep -q 'RNICK_ECONOMIC_RESILIENCE_PASS' /tmp/rnick-demo-raw/native.log; then break fi - test "$attempt" != "120" + if grep -q 'RNICK_DEMO_FAIL' /tmp/rnick-demo-raw/native.log; then + echo 'Native demo reported failure before all evidence markers passed.' >&2 + tail -n 200 /tmp/rnick-demo-raw/native.log >&2 || true + tail -n 200 /tmp/rnick-metro.log >&2 || true + exit 1 + fi + if [ "$attempt" = "300" ]; then + echo 'Timed out waiting for all native evidence markers.' >&2 + tail -n 200 /tmp/rnick-demo-raw/native.log >&2 || true + tail -n 200 /tmp/rnick-metro.log >&2 || true + fi + test "$attempt" != "300" sleep 1 done sleep 2 @@ -242,8 +294,6 @@ jobs: NODE cp "$(sed -n '1p' /tmp/rnick-demo-raw/uris.txt)" /tmp/rnick-demo-raw/source.jpg cp "$(sed -n '2p' /tmp/rnick-demo-raw/uris.txt)" /tmp/rnick-demo-raw/output.jpg - runtime=$(xcrun simctl list devices booted --json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const p=JSON.parse(s);const e=Object.entries(p.devices).find(([,v])=>v.length);process.stdout.write(e[0].replace('com.apple.CoreSimulator.SimRuntime.','').replaceAll('-','.'))})") - device=$(xcrun simctl list devices booted --json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=Object.values(JSON.parse(s).devices).flat()[0];process.stdout.write(d.name)})") node scripts/normalize-demo-recording.mjs \ --input /tmp/rnick-demo-raw/recording-raw.mp4 \ --output /tmp/rnick-demo-raw/recording.mp4 \ @@ -293,6 +343,69 @@ jobs: --destination demo-evidence/ios \ --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" node scripts/verify-benchmark-comparison-evidence.mjs demo-evidence/ios + mkdir -p /tmp/rnick-economic-raw + node --input-type=module - /tmp/rnick-demo-raw/native.log > /tmp/rnick-economic-raw/uris.txt <<'NODE' + import { readFileSync } from 'node:fs'; + import { parseNativeEconomicResiliencePayload } from './scripts/economic-resilience-evidence-core.mjs'; + const payload = parseNativeEconomicResiliencePayload(readFileSync(process.argv[2], 'utf8')); + console.log(new URL(payload.fixture.sourceUri).pathname); + console.log(new URL(payload.representative.stagedOutputUri).pathname); + NODE + cp "$(sed -n '1p' /tmp/rnick-economic-raw/uris.txt)" /tmp/rnick-economic-raw/source.jpg + cp "$(sed -n '2p' /tmp/rnick-economic-raw/uris.txt)" /tmp/rnick-economic-raw/output.jpg + node scripts/measure-demo-visual-agreement.mjs \ + --source /tmp/rnick-economic-raw/source.jpg \ + --output /tmp/rnick-economic-raw/output.jpg \ + --resize-mode contain \ + --max-width 1600 \ + --max-height 1200 \ + --comparison-profile jpeg-full-range-to-limited-yuv444p-v1 \ + --report /tmp/rnick-economic-raw/visual-agreement.json + react_native_version=$(node -e "process.stdout.write(require('./example/package.json').dependencies['react-native'])") + node_version=$(node --version) + ffmpeg_version=$(ffmpeg -version | head -n 1) + ffprobe_version=$(ffprobe -version | head -n 1) + xcode_version=$(xcodebuild -version | tr '\n' ' ' | sed 's/[[:space:]]*$//') + simulator_sdk=$(xcrun --sdk iphonesimulator --show-sdk-version) + node scripts/create-economic-resilience-environment.mjs \ + --platform ios \ + --runtime "$runtime" \ + --os-build "$os_build" \ + --device "$device" \ + --device-kind simulator \ + --abi "$abi" \ + --react-native-version "$react_native_version" \ + --native-log /tmp/rnick-demo-raw/native.log \ + --build-type debug \ + --runner-label macos-latest \ + --runner-os "$RUNNER_OS" \ + --runner-arch "$RUNNER_ARCH" \ + --runner-name "$RUNNER_NAME" \ + --image-os "$ImageOS" \ + --image-version "$ImageVersion" \ + --node "$node_version" \ + --ffmpeg "$ffmpeg_version" \ + --ffprobe "$ffprobe_version" \ + --primary-toolchain "$xcode_version" \ + --platform-sdk "iOS Simulator $simulator_sdk" \ + --output /tmp/rnick-economic-raw/environment.json + node scripts/create-economic-resilience-evidence.mjs \ + --platform ios \ + --package-version "${{ steps.package.outputs.version }}" \ + --source-sha "${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --log /tmp/rnick-demo-raw/native.log \ + --source /tmp/rnick-economic-raw/source.jpg \ + --output /tmp/rnick-economic-raw/output.jpg \ + --fixture-manifest example/fixtures/kit-only-12mp-v1.json \ + --visual-agreement /tmp/rnick-economic-raw/visual-agreement.json \ + --environment /tmp/rnick-economic-raw/environment.json \ + --destination demo-evidence/ios + node scripts/verify-economic-resilience-evidence.mjs \ + --artifact-dir demo-evidence/ios/economic-resilience \ + --report-file /tmp/rnick-economic-raw/verification.json - name: Upload iOS evidence uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eea65d..a60ed38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and patch releases remain backward compatible within the current minor line. arbitrary files, traversal, symlinks, and directories. - An iOS SDK privacy manifest declaring no tracking or collected data and the C617.1 reason used to inspect package-owned cache output metadata. +- A reproducible kit-only 12 MP JPEG evidence harness with exact environment, + byte, geometry, explicit color-range visual agreement, latency, portable + offline replay, and output-cleanup verification. ### Changed @@ -22,6 +25,10 @@ and patch releases remain backward compatible within the current minor line. ### Fixed +- iOS `metadata: 'strip'` now removes encoder-generated JPEG APP1, APP13, and + COM segments after ImageIO encoding. The marker parser rejects malformed, + truncated, or trailing JPEG output through the existing `ERR_ENCODE_FAILED` + path while leaving `safe` and `preserve` encoding unchanged. - iOS no longer vertically inverts pixels after ImageIO has normalized an orientation-bearing input. The default pipeline now verifies EXIF orientations 1–8 through decode, transform, and encode, and native demo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c62e2e..3e046f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ Requirements: - pnpm 11.8.0 through Corepack - Ruby/CocoaPods and Xcode for iOS validation - Java 21 and Android SDK 36 for Android executable validation +- ffmpeg and ffprobe for decode, geometry, and SSIM evidence replay ```bash corepack enable diff --git a/Dockerfile b/Dockerfile index 6f2cf8f..a61034f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN set -eux; \ apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + ffmpeg \ git \ make \ openssh-client \ @@ -31,6 +32,8 @@ RUN set -eux; \ unzip \ xz-utils \ g++; \ + ffmpeg -version | head -n 1; \ + ffprobe -version | head -n 1; \ rm -rf /var/lib/apt/lists/* RUN set -eux; \ diff --git a/README.md b/README.md index 5f4fae6..8cf7e30 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ owned-file cleanup. | Large photos | Decode downsampling, pixel limits, two-operation scheduling | 48 MP → 1.92 MP planned decode; this is not measured peak memory | | Cancellation | `ERR_CANCELLED` without publishing partial output | JS and native suites assert zero residual output at representative boundaries | | Output lifecycle | Narrow `removeCompressionOutput(uri)` ownership check | 0.4.1 candidate tests owned deletion and foreign/path/directory rejection | -| Metadata | Explicit `preserve`, `safe`, and `strip` | Android safe retained 0/7 named sensitive fields; iOS safe/strip copy no source metadata | +| Metadata | Explicit `preserve`, `safe`, and `strip` | Android safe retained 0/7 named sensitive fields; iOS safe copies no source metadata and JPEG strip also removes APP1/APP13/COM segments | | Integration | Packed tarball installed by fresh consumers | 8/8 release-target platform builds passed for v0.4.0 | @@ -352,8 +352,9 @@ Important limitations: - HEIC, HEIF, and AVIF output reject with `ERR_NOT_IMPLEMENTED`. - GIF output and animation preservation for GIF/WebP/AVIF are not implemented. - `metadata: 'preserve'` is supported only for JPEG source to JPEG output. -- Android `safe` copies a privacy-filtered JPEG EXIF allowlist. iOS `safe` and - `strip` re-encode without copying source metadata. +- Android `safe` copies a privacy-filtered JPEG EXIF allowlist. iOS `safe` + re-encodes without copying source metadata; for JPEG output, iOS `strip` + additionally removes encoder-generated APP1, APP13, and COM segments. - The iOS SDK ships a namespaced privacy manifest declaring no tracking or collected data and C617.1 for package-cache file metadata validation. - JPEG orientation is rendered into pixels before resize/encode; preserved @@ -372,6 +373,10 @@ Important limitations: ## Development verification +The full repository gate requires `ffmpeg` and `ffprobe` so retained and +newly generated native image evidence can be decoded and visually replayed. +The pinned Docker lane includes both tools. + ```bash pnpm test:coverage pnpm verify @@ -384,6 +389,7 @@ pnpm example:ios:output-test pnpm example:ios:pipeline-test pnpm example:ios:large-image-test pnpm example:ios:metadata-test +pnpm example:ios:jpeg-sanitizer-test pnpm example:ios:transformer-test pnpm docs:check pnpm site:check @@ -416,6 +422,13 @@ movie header. The same runs emit versioned baseline and exact-plan comparison evidence with raw samples, fixture and plan digests, balanced execution positions, and median/p95 summaries. +They also create a kit-only 12 MP JPEG source-tree evidence bundle that binds source and +output bytes, environment, capabilities, latency samples, visual agreement, +and package-output cleanup. Its visual replay pins the JPEG color-range +conversion and allows only a 0.001 SSIM implementation tolerance after both +measurements independently pass the quality and orientation gates. The bundle +is an environment-specific observation, not a speed ranking, cost-savings +claim, or real-device benchmark. Comparison dependencies remain inside the private example application and outside the published package. See the [benchmark methodology](docs/benchmarks/README.md) for its timing boundary, diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 40de4e5..50f441b 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -34,12 +34,61 @@ Do not compare Android measurements with iOS measurements. Native codecs, device classes, simulator behavior, filesystem caches, thermals, and runner load differ. A result describes only its captured environment. +## 12 MP kit-only economic resilience + +The same hosted workflow runs a separate large-photo case for this package +only. Its repository-generated, non-personal JPEG is exactly 4,000 × 3,000, +1,721,333 bytes, and SHA-256 +`bdcf4e083f1860d8829898211e4b1c428a80dfd53dceca697c6f7e4a4901bfcc`. +Android and iOS bundle those same fixture bytes. The request is fixed to +`contain` within 1,600 × 1,200, JPEG quality 90, `maxBytes: 500_000`, and +`metadata: "strip"`. + +Each platform runs two warmups and ten sequential measured calls. The timer +surrounds only `compressImage(options)`; option construction, inspection, +staging, and cleanup are outside it. Measured iteration 10 is copied to an +example-owned staging file before all 12 package-owned outputs are removed. +Acceptance requires a decodable 1,600 × 1,200 JPEG at or below the byte target, +exact native/file byte and SHA-256 agreement, no APP1/APP13/comment metadata, +upright SSIM of at least 0.90, an upright-over-vertical-flip margin of at least +0.02, the unchanged source, and zero package-output residuals. The visual +profile requires full-range JPEG inputs and explicitly converts both sides to +limited-range `yuv444p` with Lanczos scaling before comparison, avoiding +version-dependent implicit YUVJ range negotiation. + +The artifact records the exact checked-out source commit, package source-tree +version, workflow run and attempt, runtime, OS build, simulator/emulator, +architecture, JS engine, React Native version, runner image, toolchains, +capabilities, raw warmup/measured samples, and signed source-minus-output byte +difference. `sourceToOutputByteDifference` is an observation, not avoided +transfer or storage. The source remains, no matched transfer baseline exists, +and no cost-savings claim is made. + +This case is not a competitor comparison, speed ranking, production workload, +physical-device benchmark, peak-RSS measurement, or universal resilience +rate. Verify a downloaded platform bundle with the locally installed ffmpeg +and ffprobe tools: + +```bash +pnpm verify:economic-resilience-evidence -- \ + --artifact-dir path/to/native-demo-platform-artifact/economic-resilience +``` + +The replay reports both captured and local ffmpeg versions and gates on the +recalculated decode, geometry, hashes, SSIM, and flip-control report. Both the +captured and replayed reports must independently pass the 0.90 quality and 0.02 +orientation gates; all profile, geometry, check, and digest fields must match. +Only the two six-decimal SSIM values may differ, by at most 0.001. That narrow +tolerance absorbs decoder/scaler implementation drift for this exact bound +fixture; it is not extra quality slack or a claim about other images. + ## Capture and verify The [Native Demo Evidence workflow](https://github.com/GGULBAE/react-native-image-compression-kit/actions/workflows/demo-evidence.yml) runs the benchmark after the visible demo result on both platforms. Each platform artifact contains `benchmark.json`, the exact source fixture, the demo -manifest, input/output images, screenshot, and native log. +manifest, input/output images, screenshot, native log, and an independently +scoped `economic-resilience/` directory. After downloading one platform artifact, verify it without network access: diff --git a/docs/launch/README.md b/docs/launch/README.md index 587b33b..07d9887 100644 --- a/docs/launch/README.md +++ b/docs/launch/README.md @@ -68,6 +68,15 @@ then replace `website/public/demo` in a focused pull request. Run publishing. Never synthesize a missing platform recording or relabel a local capture as hosted evidence. +Each platform artifact may also contain the independently scoped +`economic-resilience/` bundle. Verify that directory with +`pnpm verify:economic-resilience-evidence -- --artifact-dir ` before any +reviewed import. If it is later exposed publicly, copy both platforms into a +new versioned evidence directory; do not place the large-photo files inside +the current `/demo` snapshot or overwrite older evidence. A public import is a +separate focused change and must preserve the bundle's source-tree, +environment, cleanup, and no-cost-claim boundaries. + The hosted screen recorders can omit repeated frames during static stages. The workflow therefore rescales the real captured frames to the independently logged walkthrough duration, caps or pads that moving timeline to the logged diff --git a/docs/verification-architecture.md b/docs/verification-architecture.md index f296730..dba8038 100644 --- a/docs/verification-architecture.md +++ b/docs/verification-architecture.md @@ -16,6 +16,7 @@ from the npm package. | Public documentation site structure, claims, local links, and npm exclusion | `scripts/verify-site.mjs` | `pnpm site:check` and `pnpm site:build` | | Native demo result metrics, source/output/screenshot/recording bytes, auto-oriented SSIM and vertical-flip control, ordered walkthrough timing, parsed video-track duration, timestamp normalization, digests, platform provenance, and exact source identity | `test/demoEvidence.test.mjs`, `test/demoVisualAgreement.test.mjs`, `test/guidedDemoCore.test.mjs`, `test/demoCaptureScriptContract.test.ts`, `scripts/demo-evidence-core.mjs`, `scripts/demo-visual-agreement-core.mjs`, and `scripts/guided-demo-core.mjs` | Native Demo Evidence workflow and `pnpm verify:demo-evidence` | | Native baseline and exact-plan implementation comparison metrics, balanced sample positions, comparator identity, fixture/plan bytes, digests, and platform provenance | `test/benchmark.test.mjs`, `test/benchmarkComparison.test.mjs`, `scripts/benchmark-core.mjs`, and `scripts/benchmark-comparison-core.mjs` | Native Demo Evidence workflow, `pnpm verify:benchmark-evidence`, and `pnpm verify:benchmark-comparison-evidence` | +| Kit-only 12 MP source/output bytes, exact environment and capabilities, call-only timing, decode/geometry, a `strip` request with no APP1/APP13/JPEG comments, explicit full-to-limited-range SSIM/flip control with a 0.001 replay tolerance, signed byte difference, and zero package-output residuals | `test/economicResilienceBenchmark.test.mjs`, `test/economicResilienceEvidence.test.mjs`, `test/economicResilienceNativeSourceContract.test.mjs`, and `scripts/economic-resilience-evidence-core.mjs` | Native Demo Evidence workflow and `pnpm verify:economic-resilience-evidence` | | Packed-consumer compatibility lane definitions | `test/compatibilityMatrix.test.mjs` and `scripts/compatibility-matrix-core.mjs` | `pnpm fixtures:compatibility:check` and the Compatibility workflow | | Built public-site performance, accessibility, and SEO | `scripts/verify-site-quality.mjs` | `pnpm site:build && pnpm site:quality` | | Repository metadata, security features, Actions policy, rulesets, environments, and Pages | `test/repositorySettings.test.mjs`, `docs/repository-settings.json`, and `scripts/repository-settings-core.mjs` | `pnpm fixtures:repository-settings:check` and `pnpm audit:repository-settings` | @@ -23,12 +24,13 @@ from the npm package. | Protected master identity and required source checks | `test/releaseSource.test.mjs` and `scripts/release-source-core.mjs` | `pnpm verify:release-source` and the Trusted Release workflow | | Android registration, typed request/source/decode/transform boundaries, build wiring, fixtures, and native-test presence | `test/androidSourceContract.test.ts` | `pnpm test` and `pnpm android:doctor` | | Android compression behavior | Kotlin unit and instrumentation tests under `android/src/test` and `android/src/androidTest` | `pnpm example:android-unit-test` and `pnpm example:android-instrumentation` | -| iOS bridge, immutable request/source/inspection/decoder/transform/JPEG-metadata/output-encoder/output-persistence/pipeline boundaries, pod, workflow, and native-test/smoke-runner wiring | `test/iosSourceContract.test.ts` | `pnpm test` and `pnpm android:doctor` | +| iOS bridge, immutable request/source/inspection/decoder/transform/JPEG-metadata/JPEG-segment-sanitizer/output-encoder/output-persistence/pipeline boundaries, pod, workflow, and native-test/smoke-runner wiring | `test/iosSourceContract.test.ts` and the structural checks in `scripts/android-verification.mjs` | `pnpm test` and `pnpm android:doctor` | | iOS request validation behavior | Foundation-only table-driven native tests under `test/ios-native` | `pnpm example:ios:request-parser-test` and `pnpm example:ios:smoke` | | iOS source acquisition and format inspection behavior | Foundation/ImageIO table-driven native tests under `test/ios-native` | `pnpm example:ios:input-test` and `pnpm example:ios:smoke` | | iOS decode route, result/error, and executor ownership | Foundation/ImageIO table-driven native tests plus UIKit host smoke | `pnpm example:ios:decoder-test` and `pnpm example:ios:smoke` | | iOS resize geometry, render request/result/error, asymmetric pixel order, EXIF orientations 1–8, background policy, and executor ownership | Foundation/CoreGraphics table-driven native tests, default-pipeline ImageIO round trips, plus UIKit host smoke | `pnpm example:ios:transformer-test`, `pnpm example:ios:large-image-test`, and `pnpm example:ios:smoke` | | iOS JPEG preserve policy, ImageIO source properties, and destination metadata normalization | Foundation/ImageIO table-driven native tests plus UIKit host smoke | `pnpm example:ios:metadata-test` and `pnpm example:ios:smoke` | +| iOS strip-only JPEG APP1/APP13/COM removal, strict SOI/segment/SOS/DNL/entropy/EOI parsing, and unchanged safe/preserve behavior | Foundation table-driven marker tests plus default-pipeline ImageIO decode, geometry, metadata, and persisted-byte integration | `pnpm example:ios:jpeg-sanitizer-test`, `pnpm example:ios:large-image-test`, and `pnpm example:ios:smoke` | | iOS JPEG/PNG/WebP routing, target-size search, WebP availability, codec defaults, and executor ownership | Foundation-only table-driven native tests plus UIKit/ImageIO host smoke | `pnpm example:ios:encoder-test` and `pnpm example:ios:smoke` | | iOS cache path and extension selection, atomic file writes, stable output errors, and result projection | Foundation-only table-driven native tests plus UIKit host smoke | `pnpm example:ios:output-test` and `pnpm example:ios:smoke` | | iOS request-to-output stage order, failure forwarding, runtime capability providers, and smoke observation | Foundation-only table-driven pipeline tests plus UIKit host smoke | `pnpm example:ios:pipeline-test` and `pnpm example:ios:smoke` | @@ -82,9 +84,9 @@ pnpm pack --dry-run ``` Platform workflows additionally run Android unit and instrumentation tests and -the iOS request/input/decoder/transformer/JPEG-metadata/output-encoder, -output-persistence, and pipeline native -tests plus host-app smoke test in their supported environments. +the iOS request/input/decoder/transformer/JPEG-metadata/JPEG-segment-sanitizer, +output-encoder, output-persistence, and pipeline native tests plus host-app +smoke test in their supported environments. ## Change routing diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b5dc4df..c5b7ae2 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -85,6 +85,9 @@ android { versionCode 1 versionName "1.0" } + sourceSets { + main.assets.srcDirs += ["../../fixtures"] + } signingConfigs { debug { storeFile file('debug.keystore') diff --git a/example/android/app/src/main/java/com/imagecompressionkit/example/ExampleImageSourceModule.kt b/example/android/app/src/main/java/com/imagecompressionkit/example/ExampleImageSourceModule.kt index 052d194..f1080a9 100644 --- a/example/android/app/src/main/java/com/imagecompressionkit/example/ExampleImageSourceModule.kt +++ b/example/android/app/src/main/java/com/imagecompressionkit/example/ExampleImageSourceModule.kt @@ -1,12 +1,19 @@ package com.imagecompressionkit.example +import android.graphics.BitmapFactory import android.net.Uri +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.system.StructStat import android.util.Log +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import java.io.File +import java.security.MessageDigest class ExampleImageSourceModule( private val reactContext: ReactApplicationContext @@ -34,22 +41,94 @@ class ExampleImageSourceModule( @ReactMethod fun copySampleJpegToCache(promise: Promise) { - try { - val outputDir = File(reactContext.cacheDir, "image-compression-kit-example") + copyAssetToCache("sample.jpg", "sample.jpg", promise) + } + + @ReactMethod + fun copyEconomicResilienceJpegToCache(promise: Promise) { + copyAssetToCache( + "kit-only-12mp-v1.jpg", + "kit-only-12mp-v1.jpg", + promise + ) + } - if (!outputDir.exists() && !outputDir.mkdirs()) { - promise.reject( - "ERR_SAMPLE_FILE_ACCESS", - "Could not create sample image cache directory." - ) + @ReactMethod + fun inspectEvidenceImage(uri: String, promise: Promise) { + try { + val file = resolveCacheFile(uri) + if (!file.exists()) { + promise.resolve(Arguments.createMap().apply { + putBoolean("exists", false) + putDouble("byteSize", 0.0) + }) return } + if (!isRegularNonSymlink(file)) { + throw IllegalArgumentException("Evidence image URI must reference a regular file.") + } - val outputFile = File(outputDir, "sample.jpg") + val decodeBounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, decodeBounds) + if ( + decodeBounds.outMimeType != "image/jpeg" || + decodeBounds.outWidth <= 0 || + decodeBounds.outHeight <= 0 + ) { + throw IllegalArgumentException("Evidence image must be a decodable JPEG.") + } + + promise.resolve(Arguments.createMap().apply { + putBoolean("exists", true) + putDouble("byteSize", file.length().toDouble()) + putString("sha256", sha256(file)) + putString("mediaType", decodeBounds.outMimeType) + putInt("width", decodeBounds.outWidth) + putInt("height", decodeBounds.outHeight) + }) + } catch (error: Exception) { + promise.reject( + "ERR_EVIDENCE_FILE_ACCESS", + error.message ?: "Could not inspect the evidence image.", + error + ) + } + } - reactContext.assets.open("sample.jpg").use { input -> - outputFile.outputStream().use { output -> - input.copyTo(output) + @ReactMethod + fun copyEconomicResilienceOutputForEvidence(uri: String, promise: Promise) { + try { + val source = resolveCacheFile(uri) + if (!isRegularNonSymlink(source)) { + throw IllegalArgumentException("Representative output must be a regular file.") + } + val outputDir = evidenceCacheDirectory() + val outputFile = File(outputDir, "kit-only-12mp-v1-output.jpg") + removeExistingRegularDestination(outputFile) + writeAtomicRegularFile(outputDir, outputFile) { temporary -> + source.inputStream().use { input -> + temporary.outputStream().use { output -> input.copyTo(output) } + } + } + promise.resolve(Uri.fromFile(outputFile).toString()) + } catch (error: Exception) { + promise.reject( + "ERR_EVIDENCE_FILE_ACCESS", + error.message ?: "Could not preserve the representative evidence output.", + error + ) + } + } + + private fun copyAssetToCache(assetName: String, outputName: String, promise: Promise) { + try { + val outputDir = evidenceCacheDirectory() + val outputFile = File(outputDir, outputName) + removeExistingRegularDestination(outputFile) + + writeAtomicRegularFile(outputDir, outputFile) { temporary -> + reactContext.assets.open(assetName).use { input -> + temporary.outputStream().use { output -> input.copyTo(output) } } } @@ -62,4 +141,107 @@ class ExampleImageSourceModule( ) } } + + private fun evidenceCacheDirectory(): File { + val cacheRoot = reactContext.cacheDir.canonicalFile + val outputDir = File(cacheRoot, "image-compression-kit-example").absoluteFile + if (lstatOrNull(outputDir) == null && !outputDir.mkdir()) { + throw IllegalStateException("Could not create sample image cache directory.") + } + val canonicalOutputDir = outputDir.canonicalFile + val status = lstatOrNull(outputDir) + if ( + canonicalOutputDir.path != outputDir.path || + canonicalOutputDir.parentFile != cacheRoot || + status == null || + !OsConstants.S_ISDIR(status.st_mode) + ) { + throw IllegalStateException("Sample image cache directory must not be linked.") + } + return canonicalOutputDir + } + + private fun resolveCacheFile(uriValue: String): File { + val uri = Uri.parse(uriValue) + if ( + uri.scheme != "file" || + uri.isOpaque || + !uri.authority.isNullOrEmpty() || + uri.query != null || + uri.fragment != null || + uri.path.isNullOrBlank() + ) { + throw IllegalArgumentException("Evidence image URI must use file://.") + } + val cacheRoot = reactContext.cacheDir.canonicalFile + val requestedFile = File(requireNotNull(uri.path)).absoluteFile + val file = requestedFile.canonicalFile + val cachePrefix = cacheRoot.path + File.separator + val requestedStatus = lstatOrNull(requestedFile) + if ( + (requestedStatus != null && !OsConstants.S_ISREG(requestedStatus.st_mode)) || + !file.path.startsWith(cachePrefix) + ) { + throw IllegalArgumentException("Evidence image URI must stay inside the app cache.") + } + return file + } + + private fun removeExistingRegularDestination(file: File) { + val status = lstatOrNull(file) ?: return + if (!OsConstants.S_ISREG(status.st_mode) || !file.delete()) { + throw IllegalStateException("Evidence destination must be a removable regular file.") + } + } + + private fun writeAtomicRegularFile( + directory: File, + destination: File, + write: (File) -> Unit + ) { + val temporary = File.createTempFile(".rnick-evidence-", ".tmp", directory) + try { + if (!isRegularNonSymlink(temporary)) { + throw IllegalStateException("Evidence temporary file must be regular.") + } + write(temporary) + if (!isRegularNonSymlink(temporary)) { + throw IllegalStateException("Evidence temporary file changed during write.") + } + Os.rename(temporary.absolutePath, destination.absolutePath) + if (!isRegularNonSymlink(destination)) { + throw IllegalStateException("Evidence destination must remain a regular file.") + } + } finally { + if (lstatOrNull(temporary)?.let { OsConstants.S_ISREG(it.st_mode) } == true) { + temporary.delete() + } + } + } + + private fun isRegularNonSymlink(file: File): Boolean { + val status = lstatOrNull(file) ?: return false + return OsConstants.S_ISREG(status.st_mode) + } + + private fun lstatOrNull(file: File): StructStat? { + return try { + Os.lstat(file.absolutePath) + } catch (error: ErrnoException) { + if (error.errno == OsConstants.ENOENT) null else throw error + } + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (read > 0) digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { byte -> "%02x".format(byte) } + } } diff --git a/example/fixtures/kit-only-12mp-v1.jpg b/example/fixtures/kit-only-12mp-v1.jpg new file mode 100644 index 0000000..6c30abf Binary files /dev/null and b/example/fixtures/kit-only-12mp-v1.jpg differ diff --git a/example/fixtures/kit-only-12mp-v1.json b/example/fixtures/kit-only-12mp-v1.json new file mode 100644 index 0000000..cfa3a53 --- /dev/null +++ b/example/fixtures/kit-only-12mp-v1.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "kit-only-12mp-v1", + "file": "kit-only-12mp-v1.jpg", + "provenance": { + "kind": "project-generated-synthetic", + "containsPersonalData": false, + "license": "MIT", + "generator": "FFmpeg 8.1.2 and libjpeg-turbo jpegtran 3.1.4.1", + "recipe": "testsrc2 4000x3000 with three asymmetric color fields and a 127x113 grid; MJPEG q=2, yuvj444p, bitexact muxing; jpegtran -copy none -optimize removes COM, EXIF, XMP, and IPTC metadata" + }, + "mediaType": "image/jpeg", + "width": 4000, + "height": 3000, + "pixelCount": 12000000, + "orientation": 1, + "orientationEncoding": "implicit-default-no-exif-orientation", + "byteSize": 1721333, + "maximumFixtureByteSize": 8000000, + "sha256": "bdcf4e083f1860d8829898211e4b1c428a80dfd53dceca697c6f7e4a4901bfcc" +} diff --git a/example/ios/ImageCompressionKitExample.xcodeproj/project.pbxproj b/example/ios/ImageCompressionKitExample.xcodeproj/project.pbxproj index 655b19d..299c6f8 100644 --- a/example/ios/ImageCompressionKitExample.xcodeproj/project.pbxproj +++ b/example/ios/ImageCompressionKitExample.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0C80B921A6F3F58F76C31292 /* libPods-ImageCompressionKitExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-ImageCompressionKitExample.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 4F4A81002D22000000000001 /* ExampleImageSource.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F4A81012D22000000000001 /* ExampleImageSource.m */; }; + 4F4A81022D22000000000001 /* kit-only-12mp-v1.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 4F4A81032D22000000000001 /* kit-only-12mp-v1.jpg */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ @@ -21,6 +22,7 @@ 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = ImageCompressionKitExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; 3B4392A12AC88292D35C810B /* Pods-ImageCompressionKitExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImageCompressionKitExample.debug.xcconfig"; path = "Target Support Files/Pods-ImageCompressionKitExample/Pods-ImageCompressionKitExample.debug.xcconfig"; sourceTree = ""; }; 4F4A81012D22000000000001 /* ExampleImageSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ExampleImageSource.m; path = ImageCompressionKitExample/ExampleImageSource.m; sourceTree = ""; }; + 4F4A81032D22000000000001 /* kit-only-12mp-v1.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; name = kit-only-12mp-v1.jpg; path = ../fixtures/kit-only-12mp-v1.jpg; sourceTree = SOURCE_ROOT; }; 5709B34CF0A7D63546082F79 /* Pods-ImageCompressionKitExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImageCompressionKitExample.release.xcconfig"; path = "Target Support Files/Pods-ImageCompressionKitExample/Pods-ImageCompressionKitExample.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-ImageCompressionKitExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ImageCompressionKitExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ImageCompressionKitExample/AppDelegate.swift; sourceTree = ""; }; @@ -46,6 +48,7 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, 4F4A81012D22000000000001 /* ExampleImageSource.m */, + 4F4A81032D22000000000001 /* kit-only-12mp-v1.jpg */, 13B07FB61A68108700A75B9A /* Info.plist */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, @@ -162,6 +165,7 @@ files = ( 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 4F4A81022D22000000000001 /* kit-only-12mp-v1.jpg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example/ios/ImageCompressionKitExample/ExampleImageSource.m b/example/ios/ImageCompressionKitExample/ExampleImageSource.m index dddacf8..a493f39 100644 --- a/example/ios/ImageCompressionKitExample/ExampleImageSource.m +++ b/example/ios/ImageCompressionKitExample/ExampleImageSource.m @@ -1,6 +1,9 @@ #import +#import #import #import +#import +#import @interface ExampleImageSource : NSObject @end @@ -14,6 +17,8 @@ @interface ExampleImageSource : NSObject static NSDictionary *ExampleImageSourceReadJpegMetadataSummary(NSData *data); static NSString *ExampleImageSourceReadJpegSoftwareMetadata(NSData *data); static NSNumber *ExampleImageSourceNumberValue(NSDictionary *properties, NSString *key); +static NSString *ExampleImageSourceScopedCachePath(NSString *uri); +static BOOL ExampleImageSourcePathIsRegularNonSymlink(NSString *path); @implementation ExampleImageSource @@ -72,6 +77,130 @@ @implementation ExampleImageSource reject:reject]; } +RCT_EXPORT_METHOD(copyEconomicResilienceJpegToCache:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [[NSBundle mainBundle] pathForResource:@"kit-only-12mp-v1" ofType:@"jpg"]; + NSError *error = nil; + NSData *data = path.length > 0 + ? [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:&error] + : nil; + if (data.length == 0) { + reject( + @"ERR_SAMPLE_FILE_ACCESS", + @"The bundled 12 MP evidence fixture is missing or empty.", + error + ); + return; + } + [self writeSampleWithFileName:@"kit-only-12mp-v1.jpg" + data:data + resolve:resolve + reject:reject]; +} + +RCT_EXPORT_METHOD(inspectEvidenceImage:(NSString *)uri + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = ExampleImageSourceScopedCachePath(uri); + if (path.length == 0) { + reject( + @"ERR_EVIDENCE_FILE_ACCESS", + @"Evidence image URI must reference the app cache.", + nil + ); + return; + } + + BOOL isDirectory = NO; + if (![[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory]) { + resolve(@{ @"exists" : @NO, @"byteSize" : @0 }); + return; + } + if (isDirectory || !ExampleImageSourcePathIsRegularNonSymlink(path)) { + reject( + @"ERR_EVIDENCE_FILE_ACCESS", + @"Evidence image must be a decodable JPEG regular file.", + nil + ); + return; + } + NSError *error = nil; + NSData *data = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:&error]; + CGImageSourceRef source = data.length > 0 + ? CGImageSourceCreateWithData((__bridge CFDataRef)data, nil) + : nil; + NSString *mediaType = source != nil ? (__bridge NSString *)CGImageSourceGetType(source) : nil; + NSDictionary *properties = source != nil && CGImageSourceGetCount(source) == 1 + ? CFBridgingRelease(CGImageSourceCopyPropertiesAtIndex(source, 0, nil)) + : nil; + if (source != nil) CFRelease(source); + NSNumber *width = properties[(__bridge NSString *)kCGImagePropertyPixelWidth]; + NSNumber *height = properties[(__bridge NSString *)kCGImagePropertyPixelHeight]; + if ( + data.length == 0 || ![mediaType isEqualToString:@"public.jpeg"] || + ![width isKindOfClass:[NSNumber class]] || width.integerValue <= 0 || + ![height isKindOfClass:[NSNumber class]] || height.integerValue <= 0 + ) { + reject( + @"ERR_EVIDENCE_FILE_ACCESS", + @"Evidence image must be a decodable JPEG regular file.", + error + ); + return; + } + + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *sha256 = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index += 1) { + [sha256 appendFormat:@"%02x", digest[index]]; + } + resolve(@{ + @"exists" : @YES, + @"byteSize" : @(data.length), + @"sha256" : sha256, + @"mediaType" : @"image/jpeg", + @"width" : width, + @"height" : height + }); +} + +RCT_EXPORT_METHOD(copyEconomicResilienceOutputForEvidence:(NSString *)uri + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = ExampleImageSourceScopedCachePath(uri); + BOOL isDirectory = NO; + if ( + path.length == 0 || + ![[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory] || + isDirectory || !ExampleImageSourcePathIsRegularNonSymlink(path) + ) { + reject( + @"ERR_EVIDENCE_FILE_ACCESS", + @"Representative output must be a regular file in the app cache.", + nil + ); + return; + } + NSError *error = nil; + NSData *data = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:&error]; + if (data.length == 0) { + reject( + @"ERR_EVIDENCE_FILE_ACCESS", + @"The representative package output is missing or empty.", + error + ); + return; + } + [self writeSampleWithFileName:@"kit-only-12mp-v1-output.jpg" + data:data + resolve:resolve + reject:reject]; +} + RCT_EXPORT_METHOD(copySamplePngToCache:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -187,9 +316,51 @@ - (void)writeSampleWithFormat:(NSString *)format } NSString *fileName = [NSString stringWithFormat:@"rnick-sample.%@", format]; + [self writeSampleWithFileName:fileName data:data resolve:resolve reject:reject]; +} + +- (void)writeSampleWithFileName:(NSString *)fileName + data:(NSData *)data + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + if (data == nil || data.length == 0) { + reject( + @"ERR_SAMPLE_GENERATION_FAILED", + @"The iOS example could not generate the sample image.", + nil + ); + return; + } NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; NSError *error = nil; + BOOL isDirectory = NO; + if ([[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory]) { + if (isDirectory || !ExampleImageSourcePathIsRegularNonSymlink(path) || + ![[NSFileManager defaultManager] removeItemAtPath:path error:&error]) { + reject( + @"ERR_SAMPLE_WRITE_FAILED", + @"The iOS example refused an unsafe evidence destination.", + error + ); + return; + } + } else { + struct stat destinationStatus; + if ( + lstat(path.fileSystemRepresentation, &destinationStatus) == 0 || + errno != ENOENT + ) { + reject( + @"ERR_SAMPLE_WRITE_FAILED", + @"The iOS example refused a linked evidence destination.", + nil + ); + return; + } + } + if (![data writeToFile:path options:NSDataWritingAtomic error:&error]) { reject( @"ERR_SAMPLE_WRITE_FAILED", @@ -335,6 +506,37 @@ - (void)writeSampleWithFormat:(NSString *)format return [value isKindOfClass:[NSNumber class]] ? value : nil; } +static NSString *ExampleImageSourceScopedCachePath(NSString *uri) +{ + NSURL *URL = [NSURL URLWithString:uri]; + if (!URL.isFileURL || URL.host.length > 0 || URL.query.length > 0 || URL.fragment.length > 0) { + return nil; + } + NSString *standardizedPath = URL.path.stringByStandardizingPath; + NSString *standardizedParent = standardizedPath.stringByDeletingLastPathComponent; + NSString *canonicalParent = standardizedParent.stringByResolvingSymlinksInPath; + NSString *path = [canonicalParent stringByAppendingPathComponent:standardizedPath.lastPathComponent]; + NSString *cachePath = [NSSearchPathForDirectoriesInDomains( + NSCachesDirectory, + NSUserDomainMask, + YES + ) firstObject].stringByStandardizingPath.stringByResolvingSymlinksInPath; + NSString *temporaryPath = NSTemporaryDirectory().stringByStandardizingPath.stringByResolvingSymlinksInPath; + if ( + ([path hasPrefix:[cachePath stringByAppendingString:@"/"]] || + [path hasPrefix:[temporaryPath stringByAppendingString:@"/"]]) + ) { + return path; + } + return nil; +} + +static BOOL ExampleImageSourcePathIsRegularNonSymlink(NSString *path) +{ + struct stat status; + return lstat(path.fileSystemRepresentation, &status) == 0 && S_ISREG(status.st_mode); +} + static NSData *ExampleImageSourcePngData(void) { UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat defaultFormat]; diff --git a/example/src/App.tsx b/example/src/App.tsx index d7b0950..7df0008 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -43,6 +43,10 @@ import { } from './guidedDemo'; import { runNativeBenchmark } from './nativeBenchmark'; import { runNativeComparisonBenchmark } from './nativeComparisonBenchmark'; +import { + createEconomicResilienceDependencies, + runEconomicResilienceBenchmark, +} from './economicResilienceBenchmark'; const EXAMPLE_OUTPUT_FORMATS: OutputFormat[] = ['jpeg', 'png', 'webp']; const RESIZE_MODES: ResizeMode[] = ['contain', 'cover', 'stretch']; @@ -201,6 +205,18 @@ export default function App(): React.JSX.Element { for (const message of comparison.logs) { await emitIOSSmokeLog(message); } + const economicResilience = await runEconomicResilienceBenchmark( + SAMPLE_MODULE, + Platform.OS === 'ios' ? 'ios' : 'android', + createEconomicResilienceDependencies({ + compress: compressImage, + removeOutput: removeCompressionOutput, + capabilities: getImageCompressionCapabilities, + }) + ); + for (const message of economicResilience.logs) { + await emitIOSSmokeLog(message); + } setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: false }), 500); } catch (captureError) { const errorState = toErrorState(captureError); diff --git a/example/src/economicResilienceBenchmark.ts b/example/src/economicResilienceBenchmark.ts new file mode 100644 index 0000000..6635a1a --- /dev/null +++ b/example/src/economicResilienceBenchmark.ts @@ -0,0 +1,454 @@ +import type { + CompressionOptions, + CompressionResult, + ImageCompressionCapabilities, +} from 'react-native-image-compression-kit'; +import type { + EvidenceImageInspection, + ExampleImageSourceModule, +} from './exampleNative'; +import { createChunkedNativeLogMessages } from './nativeBenchmarkLog'; + +export const ECONOMIC_RESILIENCE_ID = 'kit-only-12mp-jpeg-v1'; +export const ECONOMIC_RESILIENCE_WARMUP_ITERATIONS = 2; +export const ECONOMIC_RESILIENCE_MEASURED_ITERATIONS = 10; +export const ECONOMIC_RESILIENCE_REPRESENTATIVE_ITERATION = 10; +export const ECONOMIC_RESILIENCE_CHUNK_MARKER = + 'RNICK_ECONOMIC_RESILIENCE_CHUNK'; +export const ECONOMIC_RESILIENCE_PASS_MARKER = + 'RNICK_ECONOMIC_RESILIENCE_PASS'; +export const ECONOMIC_RESILIENCE_FIXTURE = { + id: 'kit-only-12mp-v1', + file: 'kit-only-12mp-v1.jpg', + mediaType: 'image/jpeg', + width: 4000, + height: 3000, + pixelCount: 12_000_000, + orientation: 1, + orientationEncoding: 'implicit-default-no-exif-orientation', + byteSize: 1_721_333, + maximumFixtureByteSize: 8_000_000, + sha256: 'bdcf4e083f1860d8829898211e4b1c428a80dfd53dceca697c6f7e4a4901bfcc', +} as const; +export const ECONOMIC_RESILIENCE_OPERATION = { + resize: { maxWidth: 1600, maxHeight: 1200, mode: 'contain' }, + output: { format: 'jpeg', quality: 90, maxBytes: 500_000 }, + metadata: 'strip', +} as const satisfies Omit; + +type NativePlatform = 'android' | 'ios'; +type NativeArchitecture = 'legacy' | 'new'; + +type ResilienceSample = { + phase: 'warmup' | 'measured'; + iteration: number; + elapsedMs: number; + result: Omit; + sourceToOutputByteDifference: number; + outputInspection: Required; + cleanup: { + packageOutputRemoved: true; + existsAfterRemoval: false; + residualByteSize: 0; + }; +}; + +export type EconomicResiliencePayload = { + schemaVersion: 1; + scenarioId: typeof ECONOMIC_RESILIENCE_ID; + implementation: { name: 'react-native-image-compression-kit' }; + platform: NativePlatform; + architecture: NativeArchitecture; + jsEngine: 'hermes' | 'jsc'; + fixture: typeof ECONOMIC_RESILIENCE_FIXTURE & { + sourceUri: string; + inspection: Required; + remainsAfterRun: true; + }; + operation: typeof ECONOMIC_RESILIENCE_OPERATION; + capabilities: ImageCompressionCapabilities; + timing: { + clock: 'performance.now' | 'Date.now'; + boundary: 'compressImage-call-only'; + warmupIterations: typeof ECONOMIC_RESILIENCE_WARMUP_ITERATIONS; + measuredIterations: typeof ECONOMIC_RESILIENCE_MEASURED_ITERATIONS; + }; + representative: { + measuredIteration: typeof ECONOMIC_RESILIENCE_REPRESENTATIVE_ITERATION; + stagedOutputUri: string; + inspection: Required; + }; + samples: ResilienceSample[]; + cleanup: { + attemptedPackageOutputs: number; + removedPackageOutputs: number; + residualPackageOutputs: number; + residualPackageOutputBytes: number; + }; +}; + +export type EconomicResilienceDependencies = { + compress: (options: CompressionOptions) => Promise; + removeOutput: (uri: string) => Promise; + capabilities: () => Promise; + now: () => number; + clock: 'performance.now' | 'Date.now'; + createCaptureId: () => string; +}; + +export function selectEconomicResilienceClock( + runtime: { + performance?: { now?: unknown }; + Date?: { now?: unknown }; + } = globalThis as unknown as { + performance?: { now?: unknown }; + Date?: { now?: unknown }; + } +): Pick { + if (typeof runtime.performance?.now === 'function') { + const performanceNow = runtime.performance.now as () => number; + return { + now: () => performanceNow.call(runtime.performance), + clock: 'performance.now', + }; + } + if (typeof runtime.Date?.now !== 'function') { + throw new Error('No supported economic resilience clock is available.'); + } + const dateNow = runtime.Date.now as () => number; + return { now: () => dateNow.call(runtime.Date), clock: 'Date.now' }; +} + +export function createEconomicResilienceDependencies( + native: Pick< + EconomicResilienceDependencies, + 'compress' | 'removeOutput' | 'capabilities' + > +): EconomicResilienceDependencies { + return { + ...native, + ...selectEconomicResilienceClock(), + createCaptureId: defaultCaptureId, + }; +} + +export async function runEconomicResilienceBenchmark( + sampleModule: ExampleImageSourceModule, + platform: NativePlatform, + dependencies: EconomicResilienceDependencies +): Promise<{ payload: EconomicResiliencePayload; logs: string[] }> { + const [sourceUri, architecture, capabilities] = await Promise.all([ + sampleModule.copyEconomicResilienceJpegToCache(), + sampleModule.getReactNativeArchitecture(), + dependencies.capabilities(), + ]); + const sourceInspection = requireExistingJpeg( + await sampleModule.inspectEvidenceImage(sourceUri), + 'source' + ); + assertSourceFixture(sourceInspection); + assertCapabilities(capabilities, platform); + const compressionOptions: CompressionOptions = { + source: { uri: sourceUri }, + ...ECONOMIC_RESILIENCE_OPERATION, + }; + + const samples: ResilienceSample[] = []; + let stagedRepresentative: EconomicResiliencePayload['representative'] | null = null; + let removedPackageOutputs = 0; + let residualPackageOutputs = 0; + let residualPackageOutputBytes = 0; + + const totalIterations = + ECONOMIC_RESILIENCE_WARMUP_ITERATIONS + + ECONOMIC_RESILIENCE_MEASURED_ITERATIONS; + for (let absoluteIndex = 1; absoluteIndex <= totalIterations; absoluteIndex += 1) { + const phase = + absoluteIndex <= ECONOMIC_RESILIENCE_WARMUP_ITERATIONS + ? 'warmup' + : 'measured'; + const iteration = + phase === 'warmup' + ? absoluteIndex + : absoluteIndex - ECONOMIC_RESILIENCE_WARMUP_ITERATIONS; + const startedAt = dependencies.now(); + const result = await dependencies.compress(compressionOptions); + let elapsedMs = 0; + let outputInspection: Required | null = null; + let primaryError: unknown = null; + try { + const finishedAt = dependencies.now(); + elapsedMs = finishedAt - startedAt; + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) { + throw new Error('Economic resilience clock must advance for every sample.'); + } + outputInspection = requireExistingJpeg( + await sampleModule.inspectEvidenceImage(result.uri), + `${phase} output ${iteration}` + ); + assertCompressionResult(result, outputInspection); + + if ( + phase === 'measured' && + iteration === ECONOMIC_RESILIENCE_REPRESENTATIVE_ITERATION + ) { + const stagedOutputUri = + await sampleModule.copyEconomicResilienceOutputForEvidence(result.uri); + const stagedInspection = requireExistingJpeg( + await sampleModule.inspectEvidenceImage(stagedOutputUri), + 'representative staged output' + ); + if (!sameInspection(outputInspection, stagedInspection)) { + throw new Error('Representative staged output does not match iteration 10.'); + } + stagedRepresentative = { + measuredIteration: ECONOMIC_RESILIENCE_REPRESENTATIVE_ITERATION, + stagedOutputUri, + inspection: stagedInspection, + }; + } + } catch (error) { + primaryError = error; + } + + const cleanupErrors: unknown[] = []; + try { + await dependencies.removeOutput(result.uri); + } catch (error) { + cleanupErrors.push(error); + } + try { + const removedInspection = await sampleModule.inspectEvidenceImage(result.uri); + if (removedInspection.exists || removedInspection.byteSize !== 0) { + residualPackageOutputs += 1; + residualPackageOutputBytes += removedInspection.byteSize; + cleanupErrors.push( + new Error(`${phase} output ${iteration} remained after removal.`) + ); + } + } catch (error) { + cleanupErrors.push(error); + } + if (cleanupErrors.length === 0) { + removedPackageOutputs += 1; + } + if (primaryError !== null || cleanupErrors.length > 0) { + throw combinedIterationError({ + phase, + iteration, + primaryError, + cleanupErrors, + }); + } + if (!outputInspection) { + throw new Error(`${phase} output ${iteration} inspection is missing.`); + } + samples.push({ + phase, + iteration, + elapsedMs, + result: withoutUri(result), + sourceToOutputByteDifference: + result.originalByteSize - result.byteSize, + outputInspection, + cleanup: { + packageOutputRemoved: true, + existsAfterRemoval: false, + residualByteSize: 0, + }, + }); + } + + if ( + removedPackageOutputs !== totalIterations || + residualPackageOutputs !== 0 || + residualPackageOutputBytes !== 0 + ) { + throw new Error('Every package-owned output must be removed before PASS.'); + } + const sourceAfterRun = requireExistingJpeg( + await sampleModule.inspectEvidenceImage(sourceUri), + 'source after run' + ); + if (!sameInspection(sourceInspection, sourceAfterRun)) { + throw new Error('Source fixture must remain unchanged after the run.'); + } + if (!stagedRepresentative) { + throw new Error('Measured iteration 10 must be staged for host verification.'); + } + + const payload: EconomicResiliencePayload = { + schemaVersion: 1, + scenarioId: ECONOMIC_RESILIENCE_ID, + implementation: { name: 'react-native-image-compression-kit' }, + platform, + architecture, + jsEngine: detectJsEngine(), + fixture: { + ...ECONOMIC_RESILIENCE_FIXTURE, + sourceUri, + inspection: sourceAfterRun, + remainsAfterRun: true, + }, + operation: ECONOMIC_RESILIENCE_OPERATION, + capabilities, + timing: { + clock: dependencies.clock, + boundary: 'compressImage-call-only', + warmupIterations: ECONOMIC_RESILIENCE_WARMUP_ITERATIONS, + measuredIterations: ECONOMIC_RESILIENCE_MEASURED_ITERATIONS, + }, + representative: stagedRepresentative, + samples, + cleanup: { + attemptedPackageOutputs: totalIterations, + removedPackageOutputs, + residualPackageOutputs, + residualPackageOutputBytes, + }, + }; + return { + payload, + logs: createChunkedNativeLogMessages( + payload, + dependencies.createCaptureId(), + { + chunk: ECONOMIC_RESILIENCE_CHUNK_MARKER, + pass: ECONOMIC_RESILIENCE_PASS_MARKER, + } + ), + }; +} + +function requireExistingJpeg( + inspection: EvidenceImageInspection, + label: string +): Required { + if ( + inspection.exists !== true || + !Number.isInteger(inspection.byteSize) || + inspection.byteSize <= 0 || + !/^[0-9a-f]{64}$/.test(inspection.sha256 ?? '') || + inspection.mediaType !== 'image/jpeg' || + !Number.isInteger(inspection.width) || + (inspection.width ?? 0) <= 0 || + !Number.isInteger(inspection.height) || + (inspection.height ?? 0) <= 0 + ) { + throw new Error(`${label} must be an existing, hashed, decodable JPEG.`); + } + return inspection as Required; +} + +function assertSourceFixture(inspection: Required): void { + if ( + inspection.byteSize !== ECONOMIC_RESILIENCE_FIXTURE.byteSize || + inspection.sha256 !== ECONOMIC_RESILIENCE_FIXTURE.sha256 || + inspection.width !== ECONOMIC_RESILIENCE_FIXTURE.width || + inspection.height !== ECONOMIC_RESILIENCE_FIXTURE.height || + inspection.byteSize > ECONOMIC_RESILIENCE_FIXTURE.maximumFixtureByteSize + ) { + throw new Error('Copied source does not match the immutable 12 MP fixture.'); + } +} + +function assertCapabilities( + capabilities: ImageCompressionCapabilities, + platform: NativePlatform +): void { + const jpeg = capabilities.formats.find(({ format }) => format === 'jpeg'); + if ( + capabilities.platform !== platform || + !jpeg?.input || + !jpeg.output || + !capabilities.supportsTargetSizeCompression || + !capabilities.supportsDecodeDownsampling || + !capabilities.metadataPolicies.includes('strip') || + !Number.isInteger(capabilities.maxConcurrentOperations) || + capabilities.maxConcurrentOperations <= 0 || + !Number.isInteger(capabilities.resourceLimits?.maxSourceDimension) || + capabilities.resourceLimits.maxSourceDimension < 4000 || + !Number.isInteger(capabilities.resourceLimits?.maxSourcePixels) || + capabilities.resourceLimits.maxSourcePixels < 12_000_000 || + !Number.isInteger(capabilities.resourceLimits?.maxWorkingPixels) || + capabilities.resourceLimits.maxWorkingPixels < 1_920_000 + ) { + throw new Error('Runtime capabilities do not support the declared 12 MP case.'); + } +} + +function assertCompressionResult( + result: CompressionResult, + inspection: Required +): void { + if ( + result.format !== 'jpeg' || + result.width !== 1600 || + result.height !== 1200 || + result.byteSize <= 0 || + result.byteSize > 500_000 || + result.originalByteSize !== ECONOMIC_RESILIENCE_FIXTURE.byteSize || + !Number.isFinite(result.compressionRatio) || + result.compressionRatio <= 0 || + Math.abs(result.compressionRatio - result.byteSize / result.originalByteSize) > + 1e-6 || + inspection.byteSize !== result.byteSize || + inspection.width !== result.width || + inspection.height !== result.height + ) { + throw new Error('Compression output does not satisfy the 12 MP acceptance contract.'); + } +} + +function sameInspection( + left: Required, + right: Required +): boolean { + return ( + left.byteSize === right.byteSize && + left.sha256 === right.sha256 && + left.mediaType === right.mediaType && + left.width === right.width && + left.height === right.height + ); +} + +function withoutUri(result: CompressionResult): Omit { + const { uri: _uri, ...metrics } = result; + return metrics; +} + +function detectJsEngine(): 'hermes' | 'jsc' { + const runtime = globalThis as unknown as { HermesInternal?: unknown }; + return runtime.HermesInternal === undefined ? 'jsc' : 'hermes'; +} + +function defaultCaptureId(): string { + return `economic-resilience-${Date.now().toString(36)}`; +} + +function combinedIterationError({ + phase, + iteration, + primaryError, + cleanupErrors, +}: { + phase: 'warmup' | 'measured'; + iteration: number; + primaryError: unknown; + cleanupErrors: unknown[]; +}): Error { + const details = [ + ...(primaryError === null + ? [] + : [`primary: ${toErrorMessage(primaryError)}`]), + ...cleanupErrors.map((error) => `cleanup: ${toErrorMessage(error)}`), + ]; + return new Error( + `Economic resilience ${phase} ${iteration} failed; ${details.join(' | ')}` + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/example/src/exampleNative.ts b/example/src/exampleNative.ts index 6cc26d7..35ea2af 100644 --- a/example/src/exampleNative.ts +++ b/example/src/exampleNative.ts @@ -10,8 +10,20 @@ export type IOSJpegMetadataSummary = { exifPixelYDimension: number | null; }; +export type EvidenceImageInspection = { + exists: boolean; + byteSize: number; + sha256?: string; + mediaType?: 'image/jpeg'; + width?: number; + height?: number; +}; + export type ExampleImageSourceModule = { copySampleJpegToCache: () => Promise; + copyEconomicResilienceJpegToCache: () => Promise; + copyEconomicResilienceOutputForEvidence: (uri: string) => Promise; + inspectEvidenceImage: (uri: string) => Promise; getReactNativeArchitecture: () => Promise<'legacy' | 'new'>; copySamplePngToCache?: () => Promise; copySampleHeicToCache?: () => Promise; diff --git a/ios/RCTImageCompressionJpegMetadata.h b/ios/RCTImageCompressionJpegMetadata.h index 3991f4e..6e74b70 100644 --- a/ios/RCTImageCompressionJpegMetadata.h +++ b/ios/RCTImageCompressionJpegMetadata.h @@ -30,11 +30,16 @@ NS_ASSUME_NONNULL_BEGIN @interface RCTImageCompressionJpegMetadataResult : NSObject +@property (nonatomic, copy, readonly) NSString *metadataPolicy; @property (nonatomic, readonly) BOOL preservingSourceMetadata; +@property (nonatomic, readonly) BOOL stripRequested; @property (nonatomic, copy, readonly, nullable) NSDictionary *sourceProperties; +- (instancetype)initWithMetadataPolicy:(NSString *)metadataPolicy + preservingSourceMetadata:(BOOL)preservingSourceMetadata + sourceProperties:(nullable NSDictionary *)sourceProperties NS_DESIGNATED_INITIALIZER; - (instancetype)initWithPreservingSourceMetadata:(BOOL)preservingSourceMetadata - sourceProperties:(nullable NSDictionary *)sourceProperties NS_DESIGNATED_INITIALIZER; + sourceProperties:(nullable NSDictionary *)sourceProperties; - (instancetype)init NS_UNAVAILABLE; - (NSDictionary *)destinationPropertiesForQuality:(NSInteger)quality diff --git a/ios/RCTImageCompressionJpegMetadata.mm b/ios/RCTImageCompressionJpegMetadata.mm index 56e0694..93c488a 100644 --- a/ios/RCTImageCompressionJpegMetadata.mm +++ b/ios/RCTImageCompressionJpegMetadata.mm @@ -42,17 +42,32 @@ - (instancetype)initWithCode:(NSString *)code message:(NSString *)message @implementation RCTImageCompressionJpegMetadataResult -- (instancetype)initWithPreservingSourceMetadata:(BOOL)preservingSourceMetadata - sourceProperties:(NSDictionary *)sourceProperties +- (instancetype)initWithMetadataPolicy:(NSString *)metadataPolicy + preservingSourceMetadata:(BOOL)preservingSourceMetadata + sourceProperties:(NSDictionary *)sourceProperties { self = [super init]; if (self != nil) { + _metadataPolicy = [metadataPolicy copy]; _preservingSourceMetadata = preservingSourceMetadata; + _stripRequested = [metadataPolicy isEqualToString:RCTImageCompressionKitStripMetadataPolicy]; _sourceProperties = [sourceProperties copy]; } return self; } +- (instancetype)initWithPreservingSourceMetadata:(BOOL)preservingSourceMetadata + sourceProperties:(NSDictionary *)sourceProperties +{ + return [self + initWithMetadataPolicy:preservingSourceMetadata + ? RCTImageCompressionKitPreserveMetadataPolicy + : RCTImageCompressionKitDefaultMetadataPolicy + preservingSourceMetadata:preservingSourceMetadata + sourceProperties:sourceProperties + ]; +} + - (NSDictionary *)destinationPropertiesForQuality:(NSInteger)quality pixelWidth:(NSUInteger)pixelWidth pixelHeight:(NSUInteger)pixelHeight @@ -129,7 +144,8 @@ - (nullable RCTImageCompressionJpegMetadataResult *)prepareRequest:(RCTImageComp ? self.sourcePropertyReader(request.sourceData) : nil; return [[RCTImageCompressionJpegMetadataResult alloc] - initWithPreservingSourceMetadata:preserveRequested + initWithMetadataPolicy:request.metadataPolicy + preservingSourceMetadata:preserveRequested sourceProperties:sourceProperties ]; } diff --git a/ios/RCTImageCompressionJpegSegmentSanitizer.h b/ios/RCTImageCompressionJpegSegmentSanitizer.h new file mode 100644 index 0000000..0d07471 --- /dev/null +++ b/ios/RCTImageCompressionJpegSegmentSanitizer.h @@ -0,0 +1,12 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTImageCompressionJpegSegmentSanitizer : NSObject + ++ (nullable NSData *)sanitizeJpegData:(NSData *)jpegData + stripRequested:(BOOL)stripRequested; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RCTImageCompressionJpegSegmentSanitizer.mm b/ios/RCTImageCompressionJpegSegmentSanitizer.mm new file mode 100644 index 0000000..1a5bd17 --- /dev/null +++ b/ios/RCTImageCompressionJpegSegmentSanitizer.mm @@ -0,0 +1,129 @@ +#import "RCTImageCompressionJpegSegmentSanitizer.h" + +static BOOL RCTImageCompressionJpegMarkerHasNoLength(uint8_t marker) +{ + return marker == 0x01 || (marker >= 0xd0 && marker <= 0xd9); +} + +static BOOL RCTImageCompressionJpegMarkerCarriesMetadata(uint8_t marker) +{ + return marker == 0xe1 || marker == 0xed || marker == 0xfe; +} + +static BOOL RCTImageCompressionJpegMarkerHasLength(uint8_t marker) +{ + return marker >= 0xc0 && marker <= 0xfe; +} + +@implementation RCTImageCompressionJpegSegmentSanitizer + ++ (nullable NSData *)sanitizeJpegData:(NSData *)jpegData + stripRequested:(BOOL)stripRequested +{ + if (!stripRequested) return [jpegData copy]; + + const uint8_t *bytes = (const uint8_t *)jpegData.bytes; + const NSUInteger length = jpegData.length; + if (length < 4 || bytes[0] != 0xff || bytes[1] != 0xd8) return nil; + + NSMutableData *sanitized = [NSMutableData dataWithCapacity:length]; + [sanitized appendBytes:bytes length:2]; + NSUInteger cursor = 2; + BOOL insideEntropyData = NO; + BOOL resumeEntropyAfterSegment = NO; + BOOL sawScan = NO; + + while (cursor < length) { + if (insideEntropyData) { + NSUInteger entropyStart = cursor; + BOOL foundMarker = NO; + while (cursor < length) { + if (bytes[cursor] != 0xff) { + cursor += 1; + continue; + } + + NSUInteger markerStart = cursor; + cursor += 1; + while (cursor < length && bytes[cursor] == 0xff) cursor += 1; + if (cursor >= length) return nil; + + uint8_t marker = bytes[cursor]; + if (marker == 0x00 || marker == 0x01 || + (marker >= 0xd0 && marker <= 0xd7)) { + cursor += 1; + continue; + } + + [sanitized appendBytes:bytes + entropyStart length:markerStart - entropyStart]; + cursor = markerStart; + insideEntropyData = NO; + resumeEntropyAfterSegment = marker == 0xdc; + foundMarker = YES; + break; + } + if (!foundMarker) return nil; + continue; + } + + if (bytes[cursor] != 0xff) return nil; + NSUInteger markerStart = cursor; + cursor += 1; + while (cursor < length && bytes[cursor] == 0xff) cursor += 1; + if (cursor >= length) return nil; + + uint8_t marker = bytes[cursor]; + cursor += 1; + if (marker == 0x00 || marker == 0xd8 || + (marker >= 0xd0 && marker <= 0xd7)) { + return nil; + } + + if (marker == 0xd9) { + if (!sawScan || cursor != length) return nil; + [sanitized appendBytes:bytes + markerStart length:cursor - markerStart]; + return [sanitized copy]; + } + + if (RCTImageCompressionJpegMarkerHasNoLength(marker)) { + [sanitized appendBytes:bytes + markerStart length:cursor - markerStart]; + continue; + } + + if (!RCTImageCompressionJpegMarkerHasLength(marker)) return nil; + + if (length - cursor < 2) return nil; + NSUInteger segmentLength = ((NSUInteger)bytes[cursor] << 8) | bytes[cursor + 1]; + if (segmentLength < 2 || segmentLength > length - cursor) return nil; + if (marker == 0xda) { + if (segmentLength < 8) return nil; + NSUInteger componentCount = bytes[cursor + 2]; + if (componentCount == 0 || componentCount > 4 || + segmentLength != 6 + (2 * componentCount)) { + return nil; + } + } + if (marker == 0xdc && + (!resumeEntropyAfterSegment || segmentLength != 4)) { + return nil; + } + NSUInteger segmentEnd = cursor + segmentLength; + + if (!RCTImageCompressionJpegMarkerCarriesMetadata(marker)) { + [sanitized appendBytes:bytes + markerStart length:segmentEnd - markerStart]; + } + cursor = segmentEnd; + + if (marker == 0xda) { + sawScan = YES; + insideEntropyData = YES; + } else if (resumeEntropyAfterSegment) { + insideEntropyData = YES; + } + resumeEntropyAfterSegment = NO; + } + + return nil; +} + +@end diff --git a/ios/RCTImageCompressionUIKitImageEncoder.mm b/ios/RCTImageCompressionUIKitImageEncoder.mm index 3b2b8e5..ea8cecb 100644 --- a/ios/RCTImageCompressionUIKitImageEncoder.mm +++ b/ios/RCTImageCompressionUIKitImageEncoder.mm @@ -1,6 +1,7 @@ #import "RCTImageCompressionImageEncoder.h" #import "RCTImageCompressionCGImage.h" +#import "RCTImageCompressionJpegSegmentSanitizer.h" #import @@ -59,7 +60,16 @@ static CGImageRef RCTImageCompressionEncoderCGImage(UIImage *image) pixelWidth:CGImageGetWidth(cgImage) pixelHeight:CGImageGetHeight(cgImage) ]; - return RCTImageCompressionEncodeImage(image, @"public.jpeg", properties); + NSData *encodedData = RCTImageCompressionEncodeImage( + image, + @"public.jpeg", + properties + ); + if (encodedData == nil) return nil; + return [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:encodedData + stripRequested:metadata.stripRequested + ]; } static NSData *RCTImageCompressionEncodeWebP(UIImage *image, NSInteger quality) diff --git a/package.json b/package.json index 061c798..d28edc0 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "example:ios:pipeline-test": "node scripts/ios-validation.mjs pipeline-test", "example:ios:large-image-test": "node scripts/ios-validation.mjs large-image-test", "example:ios:metadata-test": "node scripts/ios-validation.mjs metadata-test", + "example:ios:jpeg-sanitizer-test": "node scripts/ios-validation.mjs jpeg-sanitizer-test", "example:ios:transformer-test": "node scripts/ios-validation.mjs transformer-test", "example:ios:input-test": "node scripts/ios-validation.mjs input-test", "example:ios:request-parser-test": "node scripts/ios-validation.mjs request-parser-test", @@ -78,6 +79,8 @@ "verify:benchmark-evidence": "node scripts/verify-benchmark-evidence.mjs", "benchmark:comparison:evidence": "node scripts/create-benchmark-comparison-evidence.mjs", "verify:benchmark-comparison-evidence": "node scripts/verify-benchmark-comparison-evidence.mjs", + "economic-resilience:evidence": "node scripts/create-economic-resilience-evidence.mjs", + "verify:economic-resilience-evidence": "node scripts/verify-economic-resilience-evidence.mjs", "merge:demo-evidence": "node scripts/merge-demo-evidence.mjs", "release:dry-run": "node scripts/release-dry-run.mjs", "smoke:consumer": "pnpm build && node scripts/consumer-smoke-test.mjs", diff --git a/react-native-image-compression-kit.podspec b/react-native-image-compression-kit.podspec index cbbd4c1..66355a3 100644 --- a/react-native-image-compression-kit.podspec +++ b/react-native-image-compression-kit.podspec @@ -20,6 +20,7 @@ Pod::Spec.new do |s| "ios/RCTImageCompressionInput.h", "ios/RCTImageCompressionIOSCapabilities.h", "ios/RCTImageCompressionJpegMetadata.h", + "ios/RCTImageCompressionJpegSegmentSanitizer.h", "ios/RCTImageCompressionOutput.h", "ios/RCTImageCompressionPipeline.h", "ios/RCTImageCompressionRequest.h", diff --git a/scripts/android-verification.mjs b/scripts/android-verification.mjs index 8b0a0d8..083e392 100644 --- a/scripts/android-verification.mjs +++ b/scripts/android-verification.mjs @@ -48,6 +48,8 @@ const REQUIRED_FILES = [ 'ios/RCTImageCompressionIOSCapabilities.mm', 'ios/RCTImageCompressionJpegMetadata.h', 'ios/RCTImageCompressionJpegMetadata.mm', + 'ios/RCTImageCompressionJpegSegmentSanitizer.h', + 'ios/RCTImageCompressionJpegSegmentSanitizer.mm', 'ios/RCTImageCompressionOutput.h', 'ios/RCTImageCompressionOutput.mm', 'ios/RCTImageCompressionPipeline.h', @@ -145,6 +147,8 @@ const REQUIRED_FILES = [ 'test/ios-native/RCTImageCompressionOutputTests.mm', 'test/ios-native/RCTImageCompressionPipelineTests.mm', 'test/ios-native/RCTImageCompressionJpegMetadataTests.mm', + 'test/ios-native/RCTImageCompressionJpegSegmentSanitizerTests.mm', + 'test/ios-native/RCTImageCompressionLargeImageTests.mm', 'test/ios-native/RCTImageCompressionImageTransformerTests.mm', 'test/verificationArchitecture.test.ts', 'test/releaseEvidence.test.mjs', @@ -290,6 +294,7 @@ function runDoctor() { checkIOSImageDecoderAuthorities(), checkIOSImageTransformerAuthorities(), checkIOSJpegMetadataAuthorities(), + checkIOSJpegSegmentSanitizerAuthorities(), checkIOSImageEncoderAuthorities(), checkIOSOutputAuthorities(), checkIOSPipelineAuthorities(), @@ -1471,6 +1476,170 @@ function checkIOSJpegMetadataAuthorities() { }; } +function checkIOSJpegSegmentSanitizerAuthorities() { + const sanitizerHeader = readText( + 'ios/RCTImageCompressionJpegSegmentSanitizer.h' + ); + const sanitizerCore = readText( + 'ios/RCTImageCompressionJpegSegmentSanitizer.mm' + ); + const metadataHeader = readText('ios/RCTImageCompressionJpegMetadata.h'); + const metadataCore = readText('ios/RCTImageCompressionJpegMetadata.mm'); + const uiKitEncoder = readText( + 'ios/RCTImageCompressionUIKitImageEncoder.mm' + ); + const nativeTests = readText( + 'test/ios-native/RCTImageCompressionJpegSegmentSanitizerTests.mm' + ); + const largeImageTests = readText( + 'test/ios-native/RCTImageCompressionLargeImageTests.mm' + ); + const validationRunner = readText('scripts/ios-validation.mjs'); + const podspec = readText('react-native-image-compression-kit.podspec'); + const packageJson = readJson('package.json'); + const nativeTestNames = [ + ...nativeTests.matchAll(/static void (Test\w+)\(void\)/gu), + ].map((match) => match[1]); + const requiredNativeTests = [ + 'TestRemovesMetadataBeforeAndBetweenScans', + 'TestPreservesStuffedRestartAndNonSensitiveSegments', + 'TestBypassesSafeAndPreserveOutputs', + 'TestRejectsMalformedHeadersAndSegmentLengths', + 'TestRejectsMalformedScanTermination', + ]; + const largeImageRunnerStart = validationRunner.indexOf( + 'function runLargeImageTests()' + ); + const largeImageRunnerEnd = validationRunner.indexOf( + 'function runImageTransformerTests()', + largeImageRunnerStart + ); + const largeImageRunner = + largeImageRunnerStart >= 0 && largeImageRunnerEnd > largeImageRunnerStart + ? validationRunner.slice(largeImageRunnerStart, largeImageRunnerEnd) + : ''; + const structureChecks = [ + { + ok: + sanitizerHeader.includes( + '@interface RCTImageCompressionJpegSegmentSanitizer : NSObject' + ) && + sanitizerHeader.includes('sanitizeJpegData:(NSData *)jpegData') && + sanitizerHeader.includes('stripRequested:(BOOL)stripRequested'), + name: 'Foundation JPEG sanitizer boundary', + }, + { + ok: + !/#import <(?:UIKit|ImageIO|React)/u.test(sanitizerCore) && + sanitizerCore.includes( + 'if (!stripRequested) return [jpegData copy]' + ), + name: 'safe and preserve bypass outside platform codec APIs', + }, + { + ok: + sanitizerCore.includes( + 'bytes[0] != 0xff || bytes[1] != 0xd8' + ) && + sanitizerCore.includes( + 'marker == 0xe1 || marker == 0xed || marker == 0xfe' + ) && + sanitizerCore.includes( + 'segmentLength < 2 || segmentLength > length - cursor' + ) && + sanitizerCore.includes('if (!sawScan || cursor != length) return nil'), + name: 'strict SOI segment EOI and trailing-byte validation', + }, + { + ok: + sanitizerCore.includes('marker == 0x00 || marker == 0x01') && + sanitizerCore.includes('marker >= 0xd0 && marker <= 0xd7') && + sanitizerCore.includes('resumeEntropyAfterSegment = marker == 0xdc'), + name: 'entropy stuffing restart TEM and multi-scan preservation', + }, + { + ok: + sanitizerCore.includes( + 'segmentLength != 6 + (2 * componentCount)' + ) && + sanitizerCore.includes( + 'componentCount == 0 || componentCount > 4' + ) && + sanitizerCore.includes( + '!resumeEntropyAfterSegment || segmentLength != 4' + ), + name: 'SOS component table and DNL semantic validation', + }, + { + ok: + metadataHeader.includes( + '@property (nonatomic, readonly) BOOL stripRequested;' + ) && + metadataCore.includes( + 'initWithMetadataPolicy:request.metadataPolicy' + ) && + uiKitEncoder.includes( + '#import "RCTImageCompressionJpegSegmentSanitizer.h"' + ) && + uiKitEncoder.includes('stripRequested:metadata.stripRequested'), + name: 'metadata policy propagation into default ImageIO encoder', + }, + { + ok: + nativeTestNames.length === 5 && + requiredNativeTests.every((name) => nativeTestNames.includes(name)), + name: 'table-driven native JPEG sanitizer test authority', + }, + { + ok: + largeImageTests.includes( + 'TestStripSanitizesJpegWithoutChangingGeometry' + ) && + largeImageTests.includes( + '@"strip result metrics and persisted bytes use the sanitized JPEG"' + ) && + largeImageTests.includes( + '@"preserve output retains source TIFF artist metadata"' + ), + name: 'ImageIO decode geometry persistence and preserve integration', + }, + { + ok: + packageJson.scripts?.['example:ios:jpeg-sanitizer-test'] === + 'node scripts/ios-validation.mjs jpeg-sanitizer-test' && + validationRunner.includes("if (mode === 'jpeg-sanitizer-test')") && + validationRunner.includes('function runJpegSegmentSanitizerTests()') && + /runLargeImageTests\(\);\s*runJpegSegmentSanitizerTests\(\);/u.test( + validationRunner + ), + name: 'sanitizer native tests integrated into iOS smoke', + }, + { + ok: + largeImageRunner.includes('JPEG_SEGMENT_SANITIZER_CORE_SOURCE') && + validationRunner.includes( + "'RCTImageCompressionJpegSegmentSanitizer.mm'" + ) && + podspec.includes( + '"ios/RCTImageCompressionJpegSegmentSanitizer.h"' + ), + name: 'native linker CocoaPods and pod-refresh source inventory', + }, + ]; + const violations = structureChecks + .filter((check) => !check.ok) + .map((check) => check.name); + + return { + ok: violations.length === 0, + label: 'iOS JPEG segment sanitizer boundary and native tests are present', + detail: + violations.length === 0 + ? 'strip-only APP1/APP13/COM removal, strict marker/SOS/DNL parsing, ImageIO integration, and five native groups are aligned' + : `contract violations: ${violations.join(' | ')}`, + }; +} + function checkIOSImageEncoderAuthorities() { const encoderHeader = readText('ios/RCTImageCompressionImageEncoder.h'); const encoderCore = readText('ios/RCTImageCompressionImageEncoder.mm'); diff --git a/scripts/capture-android-demo.sh b/scripts/capture-android-demo.sh index 635f60d..0669a1b 100644 --- a/scripts/capture-android-demo.sh +++ b/scripts/capture-android-demo.sh @@ -5,11 +5,37 @@ set -euo pipefail : "${RNICK_DEMO_PACKAGE_VERSION:?RNICK_DEMO_PACKAGE_VERSION is required}" : "${RNICK_DEMO_SOURCE_SHA:?RNICK_DEMO_SOURCE_SHA is required}" : "${RNICK_DEMO_RUN_URL:?RNICK_DEMO_RUN_URL is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}" +: "${RUNNER_OS:?RUNNER_OS is required}" +: "${RUNNER_ARCH:?RUNNER_ARCH is required}" +: "${RUNNER_NAME:?RUNNER_NAME is required}" +: "${ImageOS:?ImageOS is required}" +: "${ImageVersion:?ImageVersion is required}" metro_pid="" screenrecord_pid="" +logcat_pid="" + +stop_logcat_stream() { + if [ -n "$logcat_pid" ]; then + kill -TERM "$logcat_pid" 2>/dev/null || true + for _attempt in $(seq 1 50); do + if ! kill -0 "$logcat_pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + if kill -0 "$logcat_pid" 2>/dev/null; then + kill -KILL "$logcat_pid" 2>/dev/null || true + fi + wait "$logcat_pid" 2>/dev/null || true + logcat_pid="" + fi +} cleanup() { + stop_logcat_stream if [ -n "$screenrecord_pid" ]; then kill "$screenrecord_pid" 2>/dev/null || true wait "$screenrecord_pid" 2>/dev/null || true @@ -77,13 +103,16 @@ for attempt in $(seq 1 60); do done (cd example/android && ./gradlew app:installDebug --no-daemon) +mkdir -p /tmp/rnick-demo-raw adb logcat -c +adb logcat -v threadtime -s RNICK_DEMO:I '*:S' > /tmp/rnick-demo-raw/native.log & +logcat_pid=$! +sleep 1 +kill -0 "$logcat_pid" adb shell am force-stop com.imagecompressionkit.example adb shell am start -n com.imagecompressionkit.example/.MainActivity --ez rnick-demo-capture true -mkdir -p /tmp/rnick-demo-raw for attempt in $(seq 1 60); do - adb logcat -d -s RNICK_DEMO:I '*:S' > /tmp/rnick-demo-raw/native.log if grep -q 'RNICK_GUIDED_DEMO_READY' /tmp/rnick-demo-raw/native.log; then break fi @@ -101,7 +130,6 @@ adb shell screenrecord \ screenrecord_pid=$! for attempt in $(seq 1 60); do - adb logcat -d -s RNICK_DEMO:I '*:S' > /tmp/rnick-demo-raw/native.log if grep -q 'RNICK_GUIDED_DEMO_PASS' /tmp/rnick-demo-raw/native.log; then break fi @@ -116,17 +144,30 @@ fi screenrecord_pid="" adb pull /sdcard/rnick-guided-demo.mp4 /tmp/rnick-demo-raw/recording-raw.mp4 >/dev/null -for attempt in $(seq 1 120); do - adb logcat -d -s RNICK_DEMO:I '*:S' > /tmp/rnick-demo-raw/native.log +for attempt in $(seq 1 300); do if grep -q 'RNICK_DEMO_PASS' /tmp/rnick-demo-raw/native.log && \ grep -q 'RNICK_BENCHMARK_PASS' /tmp/rnick-demo-raw/native.log && \ - grep -q 'RNICK_BENCHMARK_COMPARISON_PASS' /tmp/rnick-demo-raw/native.log; then + grep -q 'RNICK_BENCHMARK_COMPARISON_PASS' /tmp/rnick-demo-raw/native.log && \ + grep -q 'RNICK_ECONOMIC_RESILIENCE_PASS' /tmp/rnick-demo-raw/native.log; then break fi - test "$attempt" != "120" + if grep -q 'RNICK_DEMO_FAIL' /tmp/rnick-demo-raw/native.log; then + echo 'Native demo reported failure before all evidence markers passed.' >&2 + tail -n 200 /tmp/rnick-demo-raw/native.log >&2 || true + tail -n 200 /tmp/rnick-metro.log >&2 || true + exit 1 + fi + if [ "$attempt" = "300" ]; then + echo 'Timed out waiting for all native evidence markers.' >&2 + tail -n 200 /tmp/rnick-demo-raw/native.log >&2 || true + tail -n 200 /tmp/rnick-metro.log >&2 || true + fi + test "$attempt" != "300" sleep 1 done +stop_logcat_stream + sleep 2 dismiss_system_anr_dialog adb shell am start -n com.imagecompressionkit.example/.MainActivity >/dev/null @@ -210,3 +251,70 @@ node scripts/create-benchmark-comparison-evidence.mjs \ --run-url "$RNICK_DEMO_RUN_URL" node scripts/verify-benchmark-comparison-evidence.mjs demo-evidence/android + +mkdir -p /tmp/rnick-economic-raw +node --input-type=module - /tmp/rnick-demo-raw/native.log > /tmp/rnick-economic-raw/uris.txt <<'NODE' +import { readFileSync } from 'node:fs'; +import { parseNativeEconomicResiliencePayload } from './scripts/economic-resilience-evidence-core.mjs'; +const payload = parseNativeEconomicResiliencePayload(readFileSync(process.argv[2], 'utf8')); +console.log(new URL(payload.fixture.sourceUri).pathname); +console.log(new URL(payload.representative.stagedOutputUri).pathname); +NODE +economic_source_path=$(sed -n '1p' /tmp/rnick-economic-raw/uris.txt) +economic_output_path=$(sed -n '2p' /tmp/rnick-economic-raw/uris.txt) +adb exec-out run-as com.imagecompressionkit.example cat "$economic_source_path" > /tmp/rnick-economic-raw/source.jpg +adb exec-out run-as com.imagecompressionkit.example cat "$economic_output_path" > /tmp/rnick-economic-raw/output.jpg +node scripts/measure-demo-visual-agreement.mjs \ + --source /tmp/rnick-economic-raw/source.jpg \ + --output /tmp/rnick-economic-raw/output.jpg \ + --resize-mode contain \ + --max-width 1600 \ + --max-height 1200 \ + --comparison-profile jpeg-full-range-to-limited-yuv444p-v1 \ + --report /tmp/rnick-economic-raw/visual-agreement.json +react_native_version=$(node -e "process.stdout.write(require('./example/package.json').dependencies['react-native'])") +os_build=$(adb shell getprop ro.build.id | tr -d '\r') +abi=$(adb shell getprop ro.product.cpu.abi | tr -d '\r') +node_version=$(node --version) +ffmpeg_version=$(ffmpeg -version | head -n 1) +ffprobe_version=$(ffprobe -version | head -n 1) +java_version=$(java -version 2>&1 | head -n 1) +node scripts/create-economic-resilience-environment.mjs \ + --platform android \ + --runtime "$runtime" \ + --os-build "$os_build" \ + --device "$device" \ + --device-kind emulator \ + --abi "$abi" \ + --react-native-version "$react_native_version" \ + --native-log /tmp/rnick-demo-raw/native.log \ + --build-type debug \ + --runner-label ubuntu-latest \ + --runner-os "$RUNNER_OS" \ + --runner-arch "$RUNNER_ARCH" \ + --runner-name "$RUNNER_NAME" \ + --image-os "$ImageOS" \ + --image-version "$ImageVersion" \ + --node "$node_version" \ + --ffmpeg "$ffmpeg_version" \ + --ffprobe "$ffprobe_version" \ + --primary-toolchain "$java_version" \ + --platform-sdk "Android compile SDK 36; emulator API 35; build-tools 36.0.0; NDK 27.1.12297006" \ + --output /tmp/rnick-economic-raw/environment.json +node scripts/create-economic-resilience-evidence.mjs \ + --platform android \ + --package-version "$RNICK_DEMO_PACKAGE_VERSION" \ + --source-sha "$RNICK_DEMO_SOURCE_SHA" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --run-url "$RNICK_DEMO_RUN_URL" \ + --log /tmp/rnick-demo-raw/native.log \ + --source /tmp/rnick-economic-raw/source.jpg \ + --output /tmp/rnick-economic-raw/output.jpg \ + --fixture-manifest example/fixtures/kit-only-12mp-v1.json \ + --visual-agreement /tmp/rnick-economic-raw/visual-agreement.json \ + --environment /tmp/rnick-economic-raw/environment.json \ + --destination demo-evidence/android +node scripts/verify-economic-resilience-evidence.mjs \ + --artifact-dir demo-evidence/android/economic-resilience \ + --report-file /tmp/rnick-economic-raw/verification.json diff --git a/scripts/create-economic-resilience-environment.mjs b/scripts/create-economic-resilience-environment.mjs new file mode 100644 index 0000000..3e6543c --- /dev/null +++ b/scripts/create-economic-resilience-environment.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import { randomUUID } from 'node:crypto'; +import { + linkSync, + lstatSync, + readFileSync, + realpathSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { parseNativeEconomicResiliencePayload } from './economic-resilience-evidence-core.mjs'; + +const options = parseArgs(process.argv.slice(2)); +for (const field of [ + 'platform', + 'runtime', + 'osBuild', + 'device', + 'deviceKind', + 'abi', + 'reactNativeVersion', + 'nativeLog', + 'buildType', + 'runnerLabel', + 'runnerOs', + 'runnerArch', + 'runnerName', + 'imageOs', + 'imageVersion', + 'node', + 'ffmpeg', + 'ffprobe', + 'primaryToolchain', + 'platformSdk', + 'output', +]) { + if (!options[field]) throw new Error(`--${toFlag(field)} is required`); +} +const nativeLog = secureInputFile(options.nativeLog, '--native-log'); +const output = secureOutputFile(options.output); +const payload = parseNativeEconomicResiliencePayload(readFileSync(nativeLog, 'utf8')); +if (payload.platform !== options.platform) { + throw new Error('native payload platform does not match --platform'); +} +const environment = { + platform: options.platform, + runtime: options.runtime, + osBuild: options.osBuild, + device: options.device, + deviceKind: options.deviceKind, + abi: options.abi, + reactNativeArchitecture: payload.architecture, + reactNativeVersion: options.reactNativeVersion, + jsEngine: payload.jsEngine, + buildType: options.buildType, + runner: { + label: options.runnerLabel, + os: options.runnerOs, + arch: options.runnerArch, + name: options.runnerName, + imageOS: options.imageOs, + imageVersion: options.imageVersion, + }, + toolchain: { + node: options.node, + ffmpeg: options.ffmpeg, + ffprobe: options.ffprobe, + primary: options.primaryToolchain, + platformSdk: options.platformSdk, + }, +}; +if (containsUnknown(environment)) { + throw new Error('environment values must be explicit and must not be unknown'); +} +writeOutputAtomic(output, `${JSON.stringify(environment, null, 2)}\n`); + +function secureInputFile(value, flag) { + const requested = path.resolve(value); + const status = lstatSync(requested); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`${flag} must be a regular non-symlink file`); + } + return realpathSync(requested); +} + +function secureOutputFile(value) { + const requested = path.resolve(value); + const requestedParent = path.dirname(requested); + const requestedParentStatus = lstatSync(requestedParent); + const parent = realpathSync(requestedParent); + const status = lstatSync(parent); + if ( + !requestedParentStatus.isDirectory() || + requestedParentStatus.isSymbolicLink() || + !status.isDirectory() || + status.isSymbolicLink() + ) { + throw new Error('--output parent must resolve to a regular directory'); + } + const destination = path.join(parent, path.basename(requested)); + try { + lstatSync(destination); + throw new Error('--output must not already exist'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + return destination; +} + +function writeOutputAtomic(destination, contents) { + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp` + ); + try { + writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 }); + linkSync(temporary, destination); + } finally { + try { + unlinkSync(temporary); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } +} + +function containsUnknown(value) { + if (value === null || value === undefined) return true; + if (typeof value === 'string') { + return value.trim() === '' || /^(?:unknown|null|undefined|n\/a)$/i.test(value.trim()); + } + if (typeof value === 'object') return Object.values(value).some(containsUnknown); + return false; +} + +function parseArgs(args) { + const parsed = {}; + const normalizedArgs = args.filter((value) => value !== '--'); + for (let index = 0; index < normalizedArgs.length; index += 2) { + const flag = normalizedArgs[index]; + const value = normalizedArgs[index + 1]; + if (!flag?.startsWith('--') || !value) { + throw new Error(`invalid argument: ${flag ?? ''}`); + } + parsed[flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = + value; + } + return parsed; +} + +function toFlag(value) { + return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} diff --git a/scripts/create-economic-resilience-evidence.mjs b/scripts/create-economic-resilience-evidence.mjs new file mode 100644 index 0000000..efe9898 --- /dev/null +++ b/scripts/create-economic-resilience-evidence.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { + ECONOMIC_RESILIENCE_ASSET_FILES, + buildEconomicResilienceEvidence, + inspectEconomicResilienceEvidence, + parseNativeEconomicResiliencePayload, +} from './economic-resilience-evidence-core.mjs'; + +const options = parseArgs(process.argv.slice(2)); +for (const field of [ + 'platform', + 'packageVersion', + 'sourceSha', + 'runId', + 'runAttempt', + 'runUrl', + 'log', + 'source', + 'output', + 'fixtureManifest', + 'visualAgreement', + 'environment', + 'destination', +]) { + if (!options[field]) throw new Error(`--${toFlag(field)} is required`); +} +if (!['android', 'ios'].includes(options.platform)) { + throw new Error('--platform must be android or ios'); +} + +const inputPaths = Object.fromEntries( + ['log', 'source', 'output', 'fixtureManifest', 'visualAgreement', 'environment'].map( + (field) => [field, secureInputFile(options[field], `--${toFlag(field)}`)] + ) +); +const payload = parseNativeEconomicResiliencePayload( + readFileSync(inputPaths.log, 'utf8') +); +if (payload.platform !== options.platform) { + throw new Error('native economic resilience platform does not match --platform'); +} +const sourceBytes = readFileSync(inputPaths.source); +const outputBytes = readFileSync(inputPaths.output); +const fixtureManifest = readJson(inputPaths.fixtureManifest); +const visualAgreement = readJson(inputPaths.visualAgreement); +const environment = readJson(inputPaths.environment); +const evidence = buildEconomicResilienceEvidence({ + payload, + packageVersion: options.packageVersion, + sourceCommit: options.sourceSha, + runId: positiveInteger(options.runId, '--run-id'), + runAttempt: positiveInteger(options.runAttempt, '--run-attempt'), + capturedAt: options.capturedAt ?? new Date().toISOString(), + runUrl: options.runUrl, + environment, + fixtureManifest, + sourceBytes, + outputBytes, + visualAgreement, +}); + +const preparedDestination = secureDestinationRoot(options.destination); +const destinationRoot = preparedDestination.path; +const destination = path.join(destinationRoot, 'economic-resilience'); +if (pathEntryExists(destination)) { + if (preparedDestination.created) rmdirSync(destinationRoot); + throw new Error(`economic resilience evidence already exists: ${destination}`); +} +const temporary = mkdtempSync(path.join(destinationRoot, '.economic-resilience.tmp-')); +try { + cpSync(inputPaths.source, path.join(temporary, 'source.jpg'), { errorOnExist: true }); + cpSync(inputPaths.output, path.join(temporary, 'output.jpg'), { errorOnExist: true }); + cpSync(inputPaths.fixtureManifest, path.join(temporary, 'fixture-manifest.json'), { + errorOnExist: true, + }); + cpSync(inputPaths.visualAgreement, path.join(temporary, 'visual-agreement.json'), { + errorOnExist: true, + }); + writeFileSync( + path.join(temporary, 'environment.json'), + `${JSON.stringify(evidence.environment, null, 2)}\n`, + { flag: 'wx' } + ); + writeFileSync( + path.join(temporary, 'economic-resilience.json'), + `${JSON.stringify(evidence, null, 2)}\n`, + { flag: 'wx' } + ); + const actualFiles = readdirSync(temporary).sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(ECONOMIC_RESILIENCE_ASSET_FILES)) { + throw new Error('economic resilience builder wrote an unexpected asset set'); + } + const report = inspectEconomicResilienceEvidence(temporary, evidence); + if (report.status !== 'passed') throw new Error(report.error); + if (pathEntryExists(destination)) { + throw new Error(`economic resilience evidence appeared during creation: ${destination}`); + } + renameSync(temporary, destination); + process.stdout.write(`${JSON.stringify(report)}\n`); +} catch (error) { + if (existsSync(temporary)) rmSync(temporary, { recursive: true, force: true }); + if (preparedDestination.created && existsSync(destinationRoot)) { + rmdirSync(destinationRoot); + } + throw error; +} + +function secureInputFile(value, flag) { + const requested = path.resolve(value); + const status = lstatSync(requested); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`${flag} must be a regular non-symlink file`); + } + // macOS exposes /tmp as an ancestor alias for /private/tmp. Canonicalize + // ancestors while still rejecting a symlink at the final file component. + return realpathSync(requested); +} + +function secureDestinationRoot(value) { + const requested = path.resolve(value); + let created = false; + if (!existsSync(requested)) { + const parent = path.dirname(requested); + const parentStatus = lstatSync(parent); + if (!parentStatus.isDirectory() || parentStatus.isSymbolicLink()) { + throw new Error('--destination parent must be a regular non-symlink directory'); + } + mkdirSync(requested); + created = true; + } + const status = lstatSync(requested); + if (!status.isDirectory() || status.isSymbolicLink()) { + if (created) rmdirSync(requested); + throw new Error('--destination must be a regular non-symlink directory'); + } + return { path: realpathSync(requested), created }; +} + +function readJson(file) { + return JSON.parse(readFileSync(file, 'utf8')); +} + +function pathEntryExists(candidate) { + try { + lstatSync(candidate); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +function parseArgs(args) { + const parsed = {}; + const normalizedArgs = args.filter((value) => value !== '--'); + for (let index = 0; index < normalizedArgs.length; index += 2) { + const flag = normalizedArgs[index]; + const value = normalizedArgs[index + 1]; + if (!flag?.startsWith('--') || !value) { + throw new Error(`invalid argument: ${flag ?? ''}`); + } + parsed[flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = + value; + } + return parsed; +} + +function toFlag(value) { + return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} + +function positiveInteger(value, flag) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive safe integer`); + } + return parsed; +} diff --git a/scripts/demo-visual-agreement-core.mjs b/scripts/demo-visual-agreement-core.mjs index 879b0b3..b9cd244 100644 --- a/scripts/demo-visual-agreement-core.mjs +++ b/scripts/demo-visual-agreement-core.mjs @@ -1,13 +1,62 @@ import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; export const DEMO_VISUAL_AGREEMENT_ALGORITHM = 'ffmpeg-auto-oriented-contain-ssim-v2'; +export const PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT = Object.freeze({ + profile: 'jpeg-full-range-to-limited-yuv444p-v1', + inputColorRange: 'pc', + comparisonColorRange: 'tv', + pixelFormat: 'yuv444p', + scaler: 'lanczos', + scoreTolerance: 0.001, +}); +export const PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE = + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.profile; +export const PORTABLE_DEMO_VISUAL_AGREEMENT_ALGORITHM = + 'ffmpeg-auto-oriented-contain-limited-range-ssim-v3'; +export const PORTABLE_DEMO_VISUAL_AGREEMENT_REPLAY_TOLERANCE = + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.scoreTolerance; export const DEMO_VISUAL_AGREEMENT_THRESHOLD = 0.9; export const DEMO_VISUAL_AGREEMENT_MARGIN = 0.02; const DEMO_VISUAL_OUTCOMES = new Set(['passed', 'failed']); const LEGACY_DEMO_VISUAL_AGREEMENT_ALGORITHM = 'ffmpeg-auto-oriented-ssim-v1'; +const PORTABLE_DEMO_VISUAL_AGREEMENT_FIELDS = [ + 'schemaVersion', + 'status', + 'algorithm', + 'comparisonProfile', + 'inputColorRange', + 'comparisonColorRange', + 'comparisonPixelFormat', + 'comparisonScaler', + 'scoreTolerance', + 'sourceColorRange', + 'outputColorRange', + 'resizeMode', + 'maxWidth', + 'maxHeight', + 'sourceWidth', + 'sourceHeight', + 'expectedWidth', + 'expectedHeight', + 'width', + 'height', + 'uprightSimilarity', + 'verticalFlipSimilarity', + 'minimumSimilarity', + 'minimumOrientationMargin', + 'checks', + 'sourceSha256', + 'outputSha256', +].sort(); +const DEMO_VISUAL_AGREEMENT_CHECK_FIELDS = [ + 'geometry', + 'minimumSimilarity', + 'orientationMargin', +].sort(); export function calculateContainDimensions({ sourceWidth, @@ -46,7 +95,18 @@ export function createDemoVisualAgreementReport({ maxHeight, uprightSimilarity, verticalFlipSimilarity, + comparisonProfile, + sourceColorRange, + outputColorRange, }) { + if ( + comparisonProfile !== undefined && + comparisonProfile !== PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE + ) { + throw new Error('comparisonProfile is unsupported'); + } + const portable = + comparisonProfile === PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE; const expected = calculateContainDimensions({ sourceWidth, sourceHeight, @@ -64,9 +124,28 @@ export function createDemoVisualAgreementReport({ verticalFlipSimilarity: roundedVerticalFlipSimilarity, }); return { - schemaVersion: 2, + schemaVersion: portable ? 3 : 2, status: deriveOutcome(checks), - algorithm: DEMO_VISUAL_AGREEMENT_ALGORITHM, + algorithm: portable + ? PORTABLE_DEMO_VISUAL_AGREEMENT_ALGORITHM + : DEMO_VISUAL_AGREEMENT_ALGORITHM, + ...(portable + ? { + comparisonProfile, + inputColorRange: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.inputColorRange, + comparisonColorRange: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.comparisonColorRange, + comparisonPixelFormat: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.pixelFormat, + comparisonScaler: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.scaler, + scoreTolerance: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.scoreTolerance, + sourceColorRange, + outputColorRange, + } + : {}), resizeMode, maxWidth, maxHeight, @@ -94,13 +173,66 @@ export function inspectDemoVisualAgreement( return inspectLegacyDemoVisualAgreement(report, { sourceBytes, outputBytes }); } const errors = []; - if (report?.schemaVersion !== 2) errors.push('schemaVersion must be 2'); + if ( + report?.schemaVersion === 3 && + !exactKeys(report, PORTABLE_DEMO_VISUAL_AGREEMENT_FIELDS) + ) { + errors.push('portable visual agreement fields drifted'); + } + if ( + report?.schemaVersion === 3 && + !exactKeys(report?.checks, DEMO_VISUAL_AGREEMENT_CHECK_FIELDS) + ) { + errors.push('portable visual agreement check fields drifted'); + } + const modernContract = report?.schemaVersion === 2 + ? { + algorithm: DEMO_VISUAL_AGREEMENT_ALGORITHM, + comparisonProfile: undefined, + } + : report?.schemaVersion === 3 + ? { + algorithm: PORTABLE_DEMO_VISUAL_AGREEMENT_ALGORITHM, + comparisonProfile: PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, + inputColorRange: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.inputColorRange, + comparisonColorRange: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.comparisonColorRange, + comparisonPixelFormat: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.pixelFormat, + comparisonScaler: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.scaler, + scoreTolerance: + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.scoreTolerance, + } + : null; + if (modernContract === null) errors.push('schemaVersion must be 2 or 3'); if (!DEMO_VISUAL_OUTCOMES.has(report?.status)) { errors.push('status must be passed or failed'); } - if (report?.algorithm !== DEMO_VISUAL_AGREEMENT_ALGORITHM) { + if (report?.algorithm !== modernContract?.algorithm) { errors.push('algorithm is unsupported'); } + if (report?.comparisonProfile !== modernContract?.comparisonProfile) { + errors.push('comparison profile does not match the visual schema'); + } + for (const field of [ + 'inputColorRange', + 'comparisonColorRange', + 'comparisonPixelFormat', + 'comparisonScaler', + 'scoreTolerance', + ]) { + if (report?.[field] !== modernContract?.[field]) { + errors.push(`${field} does not match the visual schema`); + } + } + if ( + report?.schemaVersion === 3 && + (report?.sourceColorRange !== 'pc' || report?.outputColorRange !== 'pc') + ) { + errors.push('portable visual inputs must be full-range JPEG frames'); + } if (report?.resizeMode !== 'contain') { errors.push('resizeMode must be contain'); } @@ -181,6 +313,99 @@ export function inspectDemoVisualAgreement( }; } +export function comparePortableDemoVisualAgreement( + captured, + replayed +) { + const exactFields = [ + 'schemaVersion', + 'status', + 'algorithm', + 'comparisonProfile', + 'inputColorRange', + 'comparisonColorRange', + 'comparisonPixelFormat', + 'comparisonScaler', + 'scoreTolerance', + 'sourceColorRange', + 'outputColorRange', + 'resizeMode', + 'maxWidth', + 'maxHeight', + 'sourceWidth', + 'sourceHeight', + 'expectedWidth', + 'expectedHeight', + 'width', + 'height', + 'minimumSimilarity', + 'minimumOrientationMargin', + 'checks', + 'sourceSha256', + 'outputSha256', + ]; + const stableCaptured = Object.fromEntries( + exactFields.map((field) => [field, captured?.[field]]) + ); + const stableReplayed = Object.fromEntries( + exactFields.map((field) => [field, replayed?.[field]]) + ); + const uprightDelta = absoluteDelta( + captured?.uprightSimilarity, + replayed?.uprightSimilarity + ); + const verticalFlipDelta = absoluteDelta( + captured?.verticalFlipSimilarity, + replayed?.verticalFlipSimilarity + ); + const outcomesPassed = [captured, replayed].every( + (report) => + report?.status === 'passed' && + report?.checks?.geometry === true && + report?.checks?.minimumSimilarity === true && + report?.checks?.orientationMargin === true + ); + const measurementsMatch = + uprightDelta !== null && + verticalFlipDelta !== null && + uprightDelta <= PORTABLE_DEMO_VISUAL_AGREEMENT_REPLAY_TOLERANCE && + verticalFlipDelta <= PORTABLE_DEMO_VISUAL_AGREEMENT_REPLAY_TOLERANCE; + const stableFieldsMatch = isDeepStrictEqual(stableCaptured, stableReplayed); + const portableSchema = + captured?.schemaVersion === 3 && + replayed?.schemaVersion === 3 && + captured?.comparisonProfile === PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE && + replayed?.comparisonProfile === PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE; + const exactShapes = + exactKeys(captured, PORTABLE_DEMO_VISUAL_AGREEMENT_FIELDS) && + exactKeys(replayed, PORTABLE_DEMO_VISUAL_AGREEMENT_FIELDS) && + exactKeys(captured?.checks, DEMO_VISUAL_AGREEMENT_CHECK_FIELDS) && + exactKeys(replayed?.checks, DEMO_VISUAL_AGREEMENT_CHECK_FIELDS); + const status = + portableSchema && + exactShapes && + outcomesPassed && + stableFieldsMatch && + measurementsMatch + ? 'passed' + : 'failed'; + return { + status, + mode: 'portable-tolerance', + measurementMatch: measurementsMatch, + outcomesPassed, + exactShapes, + stableFieldsMatch, + tolerance: PORTABLE_DEMO_VISUAL_AGREEMENT_REPLAY_TOLERANCE, + uprightSimilarityDelta: uprightDelta, + verticalFlipSimilarityDelta: verticalFlipDelta, + error: + status === 'passed' + ? null + : 'portable visual replay fields or measurements differ beyond the allowed contract', + }; +} + export function parseFfmpegSsim(stderr) { const matches = [...String(stderr).matchAll(/\bAll:([0-9]+(?:\.[0-9]+)?)/gu)]; if (matches.length === 0) { @@ -300,3 +525,19 @@ function sha256(bytes) { function roundSix(value) { return Math.round(value * 1_000_000) / 1_000_000; } + +function absoluteDelta(left, right) { + if (!unitInterval(left) || !unitInterval(right)) return null; + const leftMicrounits = Math.round(left * 1_000_000); + const rightMicrounits = Math.round(right * 1_000_000); + return Math.abs(leftMicrounits - rightMicrounits) / 1_000_000; +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + isDeepStrictEqual(Object.keys(value).sort(), expected) + ); +} diff --git a/scripts/economic-resilience-evidence-core.mjs b/scripts/economic-resilience-evidence-core.mjs new file mode 100644 index 0000000..5c16c12 --- /dev/null +++ b/scripts/economic-resilience-evidence-core.mjs @@ -0,0 +1,1178 @@ +import { createHash } from 'node:crypto'; +import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { parseChunkedNativePayload, summarizeBenchmarkSamples } from './benchmark-core.mjs'; +import { + inspectDemoVisualAgreement, + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, +} from './demo-visual-agreement-core.mjs'; + +export const ECONOMIC_RESILIENCE_SCHEMA_VERSION = 1; +export const ECONOMIC_RESILIENCE_SCENARIO_ID = 'kit-only-12mp-jpeg-v1'; +export const ECONOMIC_RESILIENCE_CHUNK_MARKER = + 'RNICK_ECONOMIC_RESILIENCE_CHUNK'; +export const ECONOMIC_RESILIENCE_PASS_MARKER = + 'RNICK_ECONOMIC_RESILIENCE_PASS'; +export const ECONOMIC_RESILIENCE_ASSET_FILES = Object.freeze([ + 'economic-resilience.json', + 'environment.json', + 'fixture-manifest.json', + 'output.jpg', + 'source.jpg', + 'visual-agreement.json', +]); +export const ECONOMIC_RESILIENCE_FIXTURE = Object.freeze({ + id: 'kit-only-12mp-v1', + file: 'kit-only-12mp-v1.jpg', + mediaType: 'image/jpeg', + width: 4000, + height: 3000, + pixelCount: 12_000_000, + orientation: 1, + orientationEncoding: 'implicit-default-no-exif-orientation', + byteSize: 1_721_333, + maximumFixtureByteSize: 8_000_000, + sha256: 'bdcf4e083f1860d8829898211e4b1c428a80dfd53dceca697c6f7e4a4901bfcc', +}); +export const ECONOMIC_RESILIENCE_FIXTURE_PROVENANCE = Object.freeze({ + kind: 'project-generated-synthetic', + containsPersonalData: false, + license: 'MIT', + generator: 'FFmpeg 8.1.2 and libjpeg-turbo jpegtran 3.1.4.1', + recipe: + 'testsrc2 4000x3000 with three asymmetric color fields and a 127x113 grid; MJPEG q=2, yuvj444p, bitexact muxing; jpegtran -copy none -optimize removes COM, EXIF, XMP, and IPTC metadata', +}); +export const ECONOMIC_RESILIENCE_OPERATION = Object.freeze({ + resize: { maxWidth: 1600, maxHeight: 1200, mode: 'contain' }, + output: { format: 'jpeg', quality: 90, maxBytes: 500_000 }, + metadata: 'strip', +}); + +const EXIF_APP1_IDENTIFIER = Buffer.from('Exif\0\0', 'latin1'); +const STANDARD_XMP_APP1_IDENTIFIER = Buffer.from( + 'http://ns.adobe.com/xap/1.0/\0', + 'latin1' +); +const EXTENDED_XMP_APP1_IDENTIFIER = Buffer.from( + 'http://ns.adobe.com/xmp/extension/\0', + 'latin1' +); + +const ENVIRONMENT_FIELDS = Object.freeze([ + 'platform', + 'runtime', + 'osBuild', + 'device', + 'deviceKind', + 'abi', + 'reactNativeArchitecture', + 'reactNativeVersion', + 'jsEngine', + 'buildType', + 'runner', + 'toolchain', +]); +const RUNNER_FIELDS = Object.freeze([ + 'label', + 'os', + 'arch', + 'name', + 'imageOS', + 'imageVersion', +]); +const TOOLCHAIN_FIELDS = Object.freeze([ + 'node', + 'ffmpeg', + 'ffprobe', + 'primary', + 'platformSdk', +]); +const CAPABILITY_FIELDS = Object.freeze([ + 'platform', + 'formats', + 'metadataPolicies', + 'supportsTargetSizeCompression', + 'supportsCancellation', + 'maxConcurrentOperations', + 'supportsDecodeDownsampling', + 'resourceLimits', +]); +const FORMAT_CAPABILITY_FIELDS = Object.freeze([ + 'format', + 'input', + 'output', + 'supportsAlpha', + 'supportsAnimation', + 'notes', +]); +const RESOURCE_LIMIT_FIELDS = Object.freeze([ + 'maxSourceDimension', + 'maxSourcePixels', + 'maxWorkingPixels', +]); +const IMAGE_FORMATS = Object.freeze([ + 'jpeg', + 'png', + 'webp', + 'heic', + 'heif', + 'avif', + 'gif', +]); +const FIXTURE_MANIFEST_FIELDS = Object.freeze([ + 'schemaVersion', + 'id', + 'file', + 'provenance', + 'mediaType', + 'width', + 'height', + 'pixelCount', + 'orientation', + 'orientationEncoding', + 'byteSize', + 'maximumFixtureByteSize', + 'sha256', +]); +const FIXTURE_PROVENANCE_FIELDS = Object.freeze([ + 'kind', + 'containsPersonalData', + 'license', + 'generator', + 'recipe', +]); +const NATIVE_PAYLOAD_FIELDS = Object.freeze([ + 'schemaVersion', + 'scenarioId', + 'implementation', + 'platform', + 'architecture', + 'jsEngine', + 'fixture', + 'operation', + 'capabilities', + 'timing', + 'representative', + 'samples', + 'cleanup', +]); +const NATIVE_FIXTURE_FIELDS = Object.freeze([ + ...Object.keys(ECONOMIC_RESILIENCE_FIXTURE), + 'sourceUri', + 'inspection', + 'remainsAfterRun', +]); +const IMAGE_INSPECTION_FIELDS = Object.freeze([ + 'exists', + 'byteSize', + 'sha256', + 'mediaType', + 'width', + 'height', +]); +const NATIVE_SAMPLE_FIELDS = Object.freeze([ + 'phase', + 'iteration', + 'elapsedMs', + 'result', + 'sourceToOutputByteDifference', + 'outputInspection', + 'cleanup', +]); +const COMPRESSION_RESULT_FIELDS = Object.freeze([ + 'format', + 'width', + 'height', + 'byteSize', + 'originalByteSize', + 'compressionRatio', +]); +const SAMPLE_CLEANUP_FIELDS = Object.freeze([ + 'packageOutputRemoved', + 'existsAfterRemoval', + 'residualByteSize', +]); +const AGGREGATE_CLEANUP_FIELDS = Object.freeze([ + 'attemptedPackageOutputs', + 'removedPackageOutputs', + 'residualPackageOutputs', + 'residualPackageOutputBytes', +]); +const EVIDENCE_FIELDS = Object.freeze([ + 'schemaVersion', + 'status', + 'scenarioId', + 'implementation', + 'sourceCommit', + 'runId', + 'runAttempt', + 'capturedAt', + 'runUrl', + 'environment', + 'capabilities', + 'fixture', + 'operation', + 'timing', + 'samples', + 'measuredSummary', + 'representative', + 'economics', + 'cleanup', + 'visualAgreement', +]); + +export function parseNativeEconomicResiliencePayload(contents) { + return parseChunkedNativePayload(contents, { + passMarker: ECONOMIC_RESILIENCE_PASS_MARKER, + chunkMarker: ECONOMIC_RESILIENCE_CHUNK_MARKER, + }); +} + +export function inspectJpegStructure(bytes) { + const buffer = Buffer.from(bytes ?? []); + const errors = []; + let width = null; + let height = null; + let precision = null; + let components = null; + let hasExif = false; + let hasXmp = false; + let hasIptc = false; + let commentCount = 0; + let app1Count = 0; + let app13Count = 0; + let hasStartOfScan = false; + let hasEndOfImage = false; + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return { status: 'failed', error: 'JPEG SOI marker is missing' }; + } + let offset = 2; + while (offset < buffer.length) { + while (offset < buffer.length && buffer[offset] === 0xff) offset += 1; + if (offset >= buffer.length) break; + const marker = buffer[offset]; + offset += 1; + if (marker === 0x00) { + errors.push('JPEG stuffed byte appears outside scan data'); + break; + } + if (marker === 0xd9) { + hasEndOfImage = true; + if (offset !== buffer.length) errors.push('JPEG contains bytes after EOI'); + break; + } + if (marker === 0xda) { + hasStartOfScan = true; + if (offset + 2 > buffer.length) { + errors.push('JPEG SOS length is truncated'); + break; + } else { + const scanHeaderLength = buffer.readUInt16BE(offset); + const scanComponentCount = buffer[offset + 2]; + if ( + !positiveInteger(scanComponentCount) || + scanHeaderLength !== 6 + 2 * scanComponentCount || + offset + scanHeaderLength > buffer.length + ) { + errors.push('JPEG SOS header length or component count is invalid'); + break; + } + offset += scanHeaderLength; + } + // Entropy-coded scan bytes use FF00 for a literal FF byte and may carry + // restart markers. Resume normal marker parsing at the next other marker + // so metadata between scans or before EOI cannot evade inspection. + while (offset < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + let markerOffset = offset; + while (markerOffset < buffer.length && buffer[markerOffset] === 0xff) { + markerOffset += 1; + } + if (markerOffset >= buffer.length) { + offset = markerOffset; + break; + } + const scanMarker = buffer[markerOffset]; + if (scanMarker === 0x00 || (scanMarker >= 0xd0 && scanMarker <= 0xd7)) { + offset = markerOffset + 1; + continue; + } + offset = markerOffset; + break; + } + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (offset + 2 > buffer.length) { + errors.push('JPEG segment length is truncated'); + break; + } + const segmentLength = buffer.readUInt16BE(offset); + if (segmentLength < 2 || offset + segmentLength > buffer.length) { + errors.push('JPEG segment extends outside the file'); + break; + } + const payloadStart = offset + 2; + const payloadEnd = offset + segmentLength; + const payload = buffer.subarray(payloadStart, payloadEnd); + if (marker === 0xe1) { + app1Count += 1; + hasExif ||= payload.subarray(0, EXIF_APP1_IDENTIFIER.length) + .equals(EXIF_APP1_IDENTIFIER); + hasXmp ||= payload.subarray(0, STANDARD_XMP_APP1_IDENTIFIER.length) + .equals(STANDARD_XMP_APP1_IDENTIFIER); + hasXmp ||= payload.subarray(0, EXTENDED_XMP_APP1_IDENTIFIER.length) + .equals(EXTENDED_XMP_APP1_IDENTIFIER); + } + if (marker === 0xed) { + app13Count += 1; + hasIptc ||= payload.toString('latin1').includes('Photoshop 3.0'); + } + if (marker === 0xfe) commentCount += 1; + if ([0xc0, 0xc1, 0xc2].includes(marker) && payload.length >= 6) { + precision = payload[0]; + height = payload.readUInt16BE(1); + width = payload.readUInt16BE(3); + components = payload[5]; + } + offset += segmentLength; + } + if (!positiveInteger(width) || !positiveInteger(height)) { + errors.push('JPEG frame geometry is missing'); + } + if (precision !== 8) errors.push('JPEG precision must be 8 bit'); + if (![1, 3].includes(components)) errors.push('JPEG component count is unsupported'); + if (!hasStartOfScan) errors.push('JPEG SOS marker is missing'); + if (!hasEndOfImage) errors.push('JPEG EOI marker is missing'); + return { + status: errors.length === 0 ? 'passed' : 'failed', + mediaType: 'image/jpeg', + width, + height, + precision, + components, + hasExif, + hasXmp, + hasIptc, + commentCount, + app1Count, + app13Count, + error: errors.length > 0 ? errors.join(' | ') : null, + }; +} + +export function inspectFixtureManifest(manifest, sourceBytes) { + const errors = []; + if (!sameFields(manifest, FIXTURE_MANIFEST_FIELDS)) { + errors.push('fixture manifest fields drifted'); + } + if (!sameFields(manifest?.provenance, FIXTURE_PROVENANCE_FIELDS)) { + errors.push('fixture provenance fields drifted'); + } + if (manifest?.schemaVersion !== 1) errors.push('fixture schemaVersion must be 1'); + for (const [field, expected] of Object.entries(ECONOMIC_RESILIENCE_FIXTURE)) { + if (manifest?.[field] !== expected) errors.push(`fixture ${field} drifted`); + } + if (!deepEqual(manifest?.provenance, ECONOMIC_RESILIENCE_FIXTURE_PROVENANCE)) { + errors.push('fixture provenance drifted from the immutable generation record'); + } + if (sourceBytes.length !== ECONOMIC_RESILIENCE_FIXTURE.byteSize) { + errors.push('fixture byte size mismatch'); + } + if (sha256(sourceBytes) !== ECONOMIC_RESILIENCE_FIXTURE.sha256) { + errors.push('fixture SHA-256 mismatch'); + } + const jpeg = inspectJpegStructure(sourceBytes); + if (jpeg.status !== 'passed') errors.push(jpeg.error); + if ( + jpeg.width !== ECONOMIC_RESILIENCE_FIXTURE.width || + jpeg.height !== ECONOMIC_RESILIENCE_FIXTURE.height + ) { + errors.push('fixture JPEG geometry mismatch'); + } + if (jpeg.app1Count !== 0 || jpeg.app13Count !== 0 || jpeg.commentCount !== 0) { + errors.push('fixture must not contain APP1, APP13, or JPEG comment metadata'); + } + return { + status: errors.length === 0 ? 'passed' : 'failed', + fixtureId: manifest?.id ?? null, + byteSize: sourceBytes.length, + sha256: sha256(sourceBytes), + geometry: jpeg.width && jpeg.height ? `${jpeg.width}x${jpeg.height}` : null, + metadataFree: jpeg.app1Count === 0 && jpeg.app13Count === 0 && jpeg.commentCount === 0, + error: errors.length > 0 ? errors.join(' | ') : null, + }; +} + +export function inspectNativeEconomicResiliencePayload(payload) { + const errors = []; + if (!sameFields(payload, NATIVE_PAYLOAD_FIELDS)) { + errors.push('native payload fields drifted'); + } + if (!sameFields(payload?.implementation, ['name'])) { + errors.push('native implementation fields drifted'); + } + if (!sameFields(payload?.fixture, NATIVE_FIXTURE_FIELDS)) { + errors.push('native fixture fields drifted'); + } + if (!sameFields(payload?.timing, ['clock', 'boundary', 'warmupIterations', 'measuredIterations'])) { + errors.push('native timing fields drifted'); + } + if (!sameFields(payload?.representative, ['measuredIteration', 'stagedOutputUri', 'inspection'])) { + errors.push('native representative fields drifted'); + } + if (!sameFields(payload?.cleanup, AGGREGATE_CLEANUP_FIELDS)) { + errors.push('native aggregate cleanup fields drifted'); + } + if (payload?.schemaVersion !== ECONOMIC_RESILIENCE_SCHEMA_VERSION) { + errors.push('native schemaVersion must be 1'); + } + if (payload?.scenarioId !== ECONOMIC_RESILIENCE_SCENARIO_ID) { + errors.push('native scenarioId drifted'); + } + if (payload?.implementation?.name !== 'react-native-image-compression-kit') { + errors.push('native implementation name is invalid'); + } + if (!['android', 'ios'].includes(payload?.platform)) { + errors.push('native platform must be android or ios'); + } + if (!['legacy', 'new'].includes(payload?.architecture)) { + errors.push('native architecture must be legacy or new'); + } + if (!['hermes', 'jsc'].includes(payload?.jsEngine)) { + errors.push('native jsEngine must be hermes or jsc'); + } + for (const [field, expected] of Object.entries(ECONOMIC_RESILIENCE_FIXTURE)) { + if (payload?.fixture?.[field] !== expected) { + errors.push(`native fixture ${field} drifted`); + } + } + if (!nonEmpty(payload?.fixture?.sourceUri)) errors.push('native sourceUri is required'); + if (payload?.fixture?.remainsAfterRun !== true) { + errors.push('native source must remain after the run'); + } + errors.push( + ...inspectImageInspection(payload?.fixture?.inspection, { + byteSize: ECONOMIC_RESILIENCE_FIXTURE.byteSize, + sha256: ECONOMIC_RESILIENCE_FIXTURE.sha256, + width: 4000, + height: 3000, + }).map((error) => `native source ${error}`) + ); + if (!deepEqual(payload?.operation, ECONOMIC_RESILIENCE_OPERATION)) { + errors.push('native operation drifted'); + } + if ( + !['performance.now', 'Date.now'].includes(payload?.timing?.clock) || + payload?.timing?.boundary !== 'compressImage-call-only' || + payload?.timing?.warmupIterations !== 2 || + payload?.timing?.measuredIterations !== 10 + ) { + errors.push('native timing contract drifted'); + } + errors.push(...inspectCapabilities(payload?.capabilities, payload?.platform)); + + const samples = Array.isArray(payload?.samples) ? payload.samples : []; + if (samples.length !== 12) errors.push('native samples must contain 2 warmups and 10 measured calls'); + samples.forEach((sample, index) => { + if (!sameFields(sample, NATIVE_SAMPLE_FIELDS)) { + errors.push(`native sample ${index + 1} fields drifted`); + } + if (!sameFields(sample?.result, COMPRESSION_RESULT_FIELDS)) { + errors.push(`native sample ${index + 1} result fields drifted`); + } + if (!sameFields(sample?.cleanup, SAMPLE_CLEANUP_FIELDS)) { + errors.push(`native sample ${index + 1} cleanup fields drifted`); + } + const expectedPhase = index < 2 ? 'warmup' : 'measured'; + const expectedIteration = index < 2 ? index + 1 : index - 1; + if (sample?.phase !== expectedPhase || sample?.iteration !== expectedIteration) { + errors.push(`native sample ${index + 1} phase or iteration is invalid`); + } + if (!finitePositive(sample?.elapsedMs)) { + errors.push(`native sample ${index + 1} elapsedMs must be positive`); + } + errors.push(...inspectSampleResult(sample, index + 1)); + if ( + sample?.cleanup?.packageOutputRemoved !== true || + sample?.cleanup?.existsAfterRemoval !== false || + sample?.cleanup?.residualByteSize !== 0 + ) { + errors.push(`native sample ${index + 1} cleanup did not reach zero residual`); + } + }); + const representativeSample = samples.find( + ({ phase, iteration }) => phase === 'measured' && iteration === 10 + ); + if ( + payload?.representative?.measuredIteration !== 10 || + !nonEmpty(payload?.representative?.stagedOutputUri) + ) { + errors.push('native representative must stage measured iteration 10'); + } + if ( + representativeSample && + !deepEqual(payload?.representative?.inspection, representativeSample.outputInspection) + ) { + errors.push('native representative inspection does not match measured iteration 10'); + } + if ( + payload?.cleanup?.attemptedPackageOutputs !== 12 || + payload?.cleanup?.removedPackageOutputs !== 12 || + payload?.cleanup?.residualPackageOutputs !== 0 || + payload?.cleanup?.residualPackageOutputBytes !== 0 + ) { + errors.push('native aggregate cleanup must remove all 12 package outputs'); + } + return errors; +} + +export function buildEconomicResilienceEvidence({ + payload, + packageVersion, + sourceCommit, + runId, + runAttempt, + capturedAt, + runUrl, + environment, + fixtureManifest, + sourceBytes, + outputBytes, + visualAgreement, +}) { + const errors = inspectNativeEconomicResiliencePayload(payload); + if (!exactSemver(packageVersion)) errors.push('packageVersion must be exact semver'); + if (!/^[0-9a-f]{40}$/.test(sourceCommit ?? '')) { + errors.push('sourceCommit must be a full lowercase SHA'); + } + if (!positiveInteger(runId)) errors.push('runId must be a positive integer'); + if (!positiveInteger(runAttempt)) errors.push('runAttempt must be a positive integer'); + if (!canonicalIsoTimestamp(capturedAt)) { + errors.push('capturedAt must be an ISO timestamp'); + } + if (!validRunUrl(runUrl)) errors.push('runUrl must identify the capture workflow run'); + if (validRunUrl(runUrl) && Number(runUrl.split('/').at(-1)) !== runId) { + errors.push('runUrl must end with runId'); + } + errors.push(...inspectEnvironment(environment, payload)); + const fixtureReport = inspectFixtureManifest(fixtureManifest, sourceBytes); + if (fixtureReport.status !== 'passed') errors.push(fixtureReport.error); + const outputJpeg = inspectJpegStructure(outputBytes); + if (outputJpeg.status !== 'passed') errors.push(outputJpeg.error); + const representative = payload?.representative?.inspection; + if ( + outputBytes.length !== representative?.byteSize || + sha256(outputBytes) !== representative?.sha256 || + outputJpeg.width !== representative?.width || + outputJpeg.height !== representative?.height + ) { + errors.push('staged output file does not match native representative inspection'); + } + if (outputJpeg.app1Count !== 0 || outputJpeg.app13Count !== 0 || outputJpeg.commentCount !== 0) { + errors.push('strip output contains APP1, APP13, or JPEG comment metadata'); + } + const visualReport = inspectDemoVisualAgreement(visualAgreement, { + sourceBytes, + outputBytes, + resizeOptions: ECONOMIC_RESILIENCE_OPERATION.resize, + }); + if ( + visualAgreement?.schemaVersion !== 3 || + visualAgreement?.comparisonProfile !== + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE + ) { + errors.push('economic evidence requires the portable visual agreement profile'); + } + if (visualReport.status !== 'passed' || visualReport.agreementStatus !== 'passed') { + errors.push(`visual agreement failed: ${visualReport.error ?? visualAgreement?.status}`); + } + if (errors.length > 0) throw new Error(errors.join(' | ')); + + const measuredSamples = payload.samples.filter(({ phase }) => phase === 'measured'); + const representativeSample = measuredSamples.find(({ iteration }) => iteration === 10); + return { + schemaVersion: ECONOMIC_RESILIENCE_SCHEMA_VERSION, + status: 'passed', + scenarioId: ECONOMIC_RESILIENCE_SCENARIO_ID, + implementation: { + name: 'react-native-image-compression-kit', + version: packageVersion, + buildSource: 'checked-out-source-tree', + }, + sourceCommit, + runId, + runAttempt, + capturedAt, + runUrl, + environment: { + ...environment, + reactNativeArchitecture: payload.architecture, + }, + capabilities: payload.capabilities, + fixture: { + id: fixtureManifest.id, + file: 'source.jpg', + manifestFile: 'fixture-manifest.json', + byteSize: sourceBytes.length, + sha256: sha256(sourceBytes), + width: ECONOMIC_RESILIENCE_FIXTURE.width, + height: ECONOMIC_RESILIENCE_FIXTURE.height, + remainsAfterRun: true, + }, + operation: ECONOMIC_RESILIENCE_OPERATION, + timing: payload.timing, + samples: payload.samples, + measuredSummary: summarizeBenchmarkSamples( + measuredSamples.map(({ iteration, elapsedMs, result }) => ({ + iteration, + elapsedMs, + result, + })) + ), + representative: { + measuredIteration: 10, + file: 'output.jpg', + byteSize: outputBytes.length, + sha256: sha256(outputBytes), + width: outputJpeg.width, + height: outputJpeg.height, + }, + economics: { + boundary: 'source-to-output-observation', + sourceOwnership: 'source-remains', + stagedEvidenceOwnership: 'example-owned-copy', + matchedTransferBaseline: null, + sourceBytes: sourceBytes.length, + outputBytes: outputBytes.length, + sourceToOutputByteDifference: + representativeSample.result.originalByteSize - representativeSample.result.byteSize, + costSavingsClaim: null, + }, + cleanup: payload.cleanup, + visualAgreement, + }; +} + +export function inspectEconomicResilienceEvidence(root, evidence) { + const errors = []; + const artifactRoot = path.resolve(root); + let files = []; + try { + files = readArtifactDirectory(artifactRoot).sort(); + } catch (error) { + errors.push(error.message); + } + if (!deepEqual(files, ECONOMIC_RESILIENCE_ASSET_FILES)) { + errors.push('artifact must contain the exact economic resilience asset set'); + } + if (!sameFields(evidence, EVIDENCE_FIELDS)) errors.push('evidence fields drifted'); + if (!sameFields(evidence?.implementation, ['name', 'version', 'buildSource'])) { + errors.push('evidence implementation fields drifted'); + } + if (!sameFields(evidence?.fixture, [ + 'id', + 'file', + 'manifestFile', + 'byteSize', + 'sha256', + 'width', + 'height', + 'remainsAfterRun', + ])) { + errors.push('evidence fixture fields drifted'); + } + if (!sameFields(evidence?.representative, [ + 'measuredIteration', + 'file', + 'byteSize', + 'sha256', + 'width', + 'height', + ])) { + errors.push('evidence representative fields drifted'); + } + if (!sameFields(evidence?.economics, [ + 'boundary', + 'sourceOwnership', + 'stagedEvidenceOwnership', + 'matchedTransferBaseline', + 'sourceBytes', + 'outputBytes', + 'sourceToOutputByteDifference', + 'costSavingsClaim', + ])) { + errors.push('evidence economics fields drifted'); + } + if (evidence?.schemaVersion !== 1 || evidence?.status !== 'passed') { + errors.push('evidence schemaVersion/status is invalid'); + } + if (evidence?.scenarioId !== ECONOMIC_RESILIENCE_SCENARIO_ID) { + errors.push('evidence scenarioId drifted'); + } + if (evidence?.implementation?.name !== 'react-native-image-compression-kit') { + errors.push('evidence implementation name is invalid'); + } + if (!exactSemver(evidence?.implementation?.version)) { + errors.push('evidence implementation version must be exact semver'); + } + if (evidence?.implementation?.buildSource !== 'checked-out-source-tree') { + errors.push('evidence implementation must identify the checked-out source tree'); + } + if (!/^[0-9a-f]{40}$/.test(evidence?.sourceCommit ?? '')) { + errors.push('evidence sourceCommit must be a full lowercase SHA'); + } + if (!positiveInteger(evidence?.runId)) errors.push('evidence runId is invalid'); + if (!positiveInteger(evidence?.runAttempt)) errors.push('evidence runAttempt is invalid'); + if (!canonicalIsoTimestamp(evidence?.capturedAt)) { + errors.push('evidence capturedAt must be an ISO timestamp'); + } + if (!validRunUrl(evidence?.runUrl)) errors.push('evidence runUrl is invalid'); + if ( + validRunUrl(evidence?.runUrl) && + Number(evidence.runUrl.split('/').at(-1)) !== evidence?.runId + ) { + errors.push('evidence runUrl does not match runId'); + } + errors.push(...inspectEnvironment(evidence?.environment, { + platform: evidence?.environment?.platform, + architecture: evidence?.environment?.reactNativeArchitecture, + jsEngine: evidence?.environment?.jsEngine, + })); + errors.push(...inspectCapabilities(evidence?.capabilities, evidence?.environment?.platform)); + if (!deepEqual(evidence?.operation, ECONOMIC_RESILIENCE_OPERATION)) { + errors.push('evidence operation drifted'); + } + if ( + evidence?.cleanup?.attemptedPackageOutputs !== 12 || + evidence?.cleanup?.removedPackageOutputs !== 12 || + evidence?.cleanup?.residualPackageOutputs !== 0 || + evidence?.cleanup?.residualPackageOutputBytes !== 0 + ) { + errors.push('evidence cleanup must report zero residual across 12 outputs'); + } + if ( + evidence?.economics?.boundary !== 'source-to-output-observation' || + evidence?.economics?.sourceOwnership !== 'source-remains' || + evidence?.economics?.stagedEvidenceOwnership !== 'example-owned-copy' || + evidence?.economics?.matchedTransferBaseline !== null || + evidence?.economics?.costSavingsClaim !== null + ) { + errors.push('evidence economics boundary is invalid'); + } + if ( + evidence?.fixture?.id !== ECONOMIC_RESILIENCE_FIXTURE.id || + evidence?.fixture?.file !== 'source.jpg' || + evidence?.fixture?.manifestFile !== 'fixture-manifest.json' || + evidence?.fixture?.byteSize !== ECONOMIC_RESILIENCE_FIXTURE.byteSize || + evidence?.fixture?.sha256 !== ECONOMIC_RESILIENCE_FIXTURE.sha256 || + evidence?.fixture?.width !== 4000 || + evidence?.fixture?.height !== 3000 || + evidence?.fixture?.remainsAfterRun !== true + ) { + errors.push('evidence fixture identity drifted'); + } + if ( + evidence?.representative?.measuredIteration !== 10 || + evidence?.representative?.file !== 'output.jpg' || + evidence?.representative?.width !== 1600 || + evidence?.representative?.height !== 1200 + ) { + errors.push('evidence representative identity drifted'); + } + + const source = readSecureAsset(artifactRoot, 'source.jpg', errors); + const output = readSecureAsset(artifactRoot, 'output.jpg', errors); + const fixtureManifest = readJsonAsset(artifactRoot, 'fixture-manifest.json', errors); + const environment = readJsonAsset(artifactRoot, 'environment.json', errors); + const visualAgreement = readJsonAsset(artifactRoot, 'visual-agreement.json', errors); + if (source && fixtureManifest) { + const fixtureReport = inspectFixtureManifest(fixtureManifest, source); + if (fixtureReport.status !== 'passed') errors.push(fixtureReport.error); + } + if (output) { + const jpeg = inspectJpegStructure(output); + if (jpeg.status !== 'passed') errors.push(jpeg.error); + if ( + output.length !== evidence?.representative?.byteSize || + sha256(output) !== evidence?.representative?.sha256 || + jpeg.width !== evidence?.representative?.width || + jpeg.height !== evidence?.representative?.height + ) { + errors.push('representative output asset does not match evidence'); + } + if (jpeg.app1Count !== 0 || jpeg.app13Count !== 0 || jpeg.commentCount !== 0) { + errors.push('representative output contains stripped metadata'); + } + } + if (source && output && visualAgreement) { + const report = inspectDemoVisualAgreement(visualAgreement, { + sourceBytes: source, + outputBytes: output, + resizeOptions: ECONOMIC_RESILIENCE_OPERATION.resize, + }); + if (report.status !== 'passed' || report.agreementStatus !== 'passed') { + errors.push(`visual agreement asset is invalid: ${report.error}`); + } + if ( + visualAgreement?.schemaVersion !== 3 || + visualAgreement?.comparisonProfile !== + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE + ) { + errors.push('economic evidence requires the portable visual agreement profile'); + } + if (!deepEqual(evidence?.visualAgreement, visualAgreement)) { + errors.push('embedded visual agreement does not match its asset'); + } + } + if (environment && !deepEqual(environment, evidence?.environment)) { + errors.push('environment asset does not match evidence environment'); + } + if ( + source && output && + (evidence?.economics?.sourceBytes !== source.length || + evidence?.economics?.outputBytes !== output.length || + evidence?.economics?.sourceToOutputByteDifference !== source.length - output.length) + ) { + errors.push('signed source-to-output byte difference is inconsistent'); + } + if (Array.isArray(evidence?.samples)) { + const nativeLike = { + schemaVersion: evidence?.schemaVersion, + scenarioId: evidence?.scenarioId, + implementation: { name: evidence?.implementation?.name }, + platform: evidence?.environment?.platform, + architecture: evidence?.environment?.reactNativeArchitecture, + jsEngine: evidence?.environment?.jsEngine, + fixture: { + ...ECONOMIC_RESILIENCE_FIXTURE, + sourceUri: 'file:///retained-source.jpg', + inspection: { + exists: true, + byteSize: evidence?.fixture?.byteSize, + sha256: evidence?.fixture?.sha256, + mediaType: 'image/jpeg', + width: evidence?.fixture?.width, + height: evidence?.fixture?.height, + }, + remainsAfterRun: evidence?.fixture?.remainsAfterRun, + }, + representative: { + measuredIteration: evidence?.representative?.measuredIteration, + stagedOutputUri: 'file:///staged-output.jpg', + inspection: { + exists: true, + byteSize: evidence?.representative?.byteSize, + sha256: evidence?.representative?.sha256, + mediaType: 'image/jpeg', + width: evidence?.representative?.width, + height: evidence?.representative?.height, + }, + }, + operation: evidence?.operation, + capabilities: evidence?.capabilities, + timing: evidence?.timing, + samples: evidence?.samples, + cleanup: evidence?.cleanup, + }; + errors.push(...inspectNativeEconomicResiliencePayload(nativeLike)); + const measuredSamples = evidence.samples.filter(({ phase }) => phase === 'measured'); + if (measuredSamples.length === 10) { + const expectedSummary = summarizeBenchmarkSamples( + measuredSamples.map(({ iteration, elapsedMs, result }) => ({ + iteration, + elapsedMs, + result, + })) + ); + if (!deepEqual(evidence?.measuredSummary, expectedSummary)) { + errors.push('measured summary does not match the raw samples'); + } + } + } else { + errors.push('evidence samples are required'); + } + return { + schemaVersion: 1, + status: errors.length === 0 ? 'passed' : 'failed', + scenarioId: evidence?.scenarioId ?? null, + platform: evidence?.environment?.platform ?? null, + sourceCommit: evidence?.sourceCommit ?? null, + representative: errors.length === 0 ? evidence.representative : null, + economics: errors.length === 0 ? evidence.economics : null, + error: errors.length > 0 ? errors.join(' | ') : null, + }; +} + +function inspectSampleResult(sample, sampleIndex) { + const errors = []; + const result = sample?.result; + if ( + result?.format !== 'jpeg' || + result?.width !== 1600 || + result?.height !== 1200 || + !positiveInteger(result?.byteSize) || + result.byteSize > 500_000 || + result?.originalByteSize !== ECONOMIC_RESILIENCE_FIXTURE.byteSize || + !finitePositive(result?.compressionRatio) || + Math.abs(result.compressionRatio - result.byteSize / result.originalByteSize) > 1e-6 + ) { + errors.push(`native sample ${sampleIndex} result is invalid`); + } + if (sample?.sourceToOutputByteDifference !== result?.originalByteSize - result?.byteSize) { + errors.push(`native sample ${sampleIndex} signed byte difference is inconsistent`); + } + errors.push( + ...inspectImageInspection(sample?.outputInspection, { + byteSize: result?.byteSize, + width: result?.width, + height: result?.height, + }).map((error) => `native sample ${sampleIndex} output ${error}`) + ); + return errors; +} + +function inspectImageInspection(inspection, expected = {}) { + const errors = []; + if (!sameFields(inspection, IMAGE_INSPECTION_FIELDS)) { + errors.push('fields drifted'); + } + if (inspection?.exists !== true) errors.push('must exist'); + if (!positiveInteger(inspection?.byteSize)) errors.push('byteSize is invalid'); + if (!/^[0-9a-f]{64}$/.test(inspection?.sha256 ?? '')) errors.push('SHA-256 is invalid'); + if (inspection?.mediaType !== 'image/jpeg') errors.push('mediaType must be image/jpeg'); + if (!positiveInteger(inspection?.width) || !positiveInteger(inspection?.height)) { + errors.push('geometry is invalid'); + } + for (const field of ['byteSize', 'sha256', 'width', 'height']) { + if (expected[field] !== undefined && inspection?.[field] !== expected[field]) { + errors.push(`${field} does not match`); + } + } + return errors; +} + +function inspectCapabilities(capabilities, platform) { + const errors = []; + if (!sameFields(capabilities, CAPABILITY_FIELDS)) { + errors.push('capability fields drifted'); + } + if (containsUnknown(capabilities)) errors.push('capabilities contain unknown values'); + if (!Array.isArray(capabilities?.formats) || capabilities.formats.length !== 7) { + errors.push('capabilities must contain exactly seven formats'); + } else { + if (!deepEqual(capabilities.formats.map(({ format }) => format), IMAGE_FORMATS)) { + errors.push('capability formats must use the canonical order without duplicates'); + } + capabilities.formats.forEach((format, index) => { + if (!sameFields(format, FORMAT_CAPABILITY_FIELDS)) { + errors.push(`capability format ${index + 1} fields drifted`); + } + if ( + format?.format !== IMAGE_FORMATS[index] || + typeof format?.input !== 'boolean' || + typeof format?.output !== 'boolean' || + typeof format?.supportsAlpha !== 'boolean' || + typeof format?.supportsAnimation !== 'boolean' || + !Array.isArray(format?.notes) || + format.notes.length === 0 || + format.notes.some((note) => !nonEmpty(note)) + ) { + errors.push(`capability format ${index + 1} shape is invalid`); + } + }); + } + if (!deepEqual(capabilities?.metadataPolicies, ['preserve', 'safe', 'strip'])) { + errors.push('capability metadata policies drifted'); + } + if (!sameFields(capabilities?.resourceLimits, RESOURCE_LIMIT_FIELDS)) { + errors.push('capability resource limit fields drifted'); + } + const jpeg = capabilities?.formats?.find?.(({ format }) => format === 'jpeg'); + if ( + capabilities?.platform !== platform || + jpeg?.input !== true || + jpeg?.output !== true || + capabilities?.supportsTargetSizeCompression !== true || + capabilities?.supportsCancellation !== true || + capabilities?.supportsDecodeDownsampling !== true || + !capabilities?.metadataPolicies?.includes?.('strip') || + !positiveInteger(capabilities?.maxConcurrentOperations) || + !positiveInteger(capabilities?.resourceLimits?.maxSourceDimension) || + capabilities.resourceLimits.maxSourceDimension < 4000 || + !positiveInteger(capabilities?.resourceLimits?.maxSourcePixels) || + capabilities.resourceLimits.maxSourcePixels < 12_000_000 || + !positiveInteger(capabilities?.resourceLimits?.maxWorkingPixels) || + capabilities.resourceLimits.maxWorkingPixels < 1_920_000 + ) { + errors.push('capabilities do not satisfy the scenario'); + } + return errors; +} + +function inspectEnvironment(environment, payload) { + const errors = []; + if (!sameFields(environment, ENVIRONMENT_FIELDS)) errors.push('environment fields drifted'); + if (!sameFields(environment?.runner, RUNNER_FIELDS)) errors.push('runner fields drifted'); + if (!sameFields(environment?.toolchain, TOOLCHAIN_FIELDS)) errors.push('toolchain fields drifted'); + if (containsUnknown(environment)) errors.push('environment contains empty or unknown values'); + for (const field of [ + 'platform', + 'runtime', + 'osBuild', + 'device', + 'deviceKind', + 'abi', + 'reactNativeArchitecture', + 'reactNativeVersion', + 'jsEngine', + 'buildType', + ]) { + if (!nonEmpty(environment?.[field])) errors.push(`environment ${field} must be text`); + } + for (const field of RUNNER_FIELDS) { + if (!nonEmpty(environment?.runner?.[field])) { + errors.push(`environment runner ${field} must be text`); + } + } + for (const field of TOOLCHAIN_FIELDS) { + if (!nonEmpty(environment?.toolchain?.[field])) { + errors.push(`environment toolchain ${field} must be text`); + } + } + if (environment?.platform !== payload?.platform) errors.push('environment platform mismatch'); + if (environment?.reactNativeArchitecture !== payload?.architecture) { + errors.push('environment architecture mismatch'); + } + if (environment?.jsEngine !== payload?.jsEngine) { + errors.push('environment JS engine mismatch'); + } + if (!['emulator', 'simulator'].includes(environment?.deviceKind)) { + errors.push('environment deviceKind must be emulator or simulator'); + } + if ( + (environment?.platform === 'android' && + (environment.deviceKind !== 'emulator' || + environment?.runner?.label !== 'ubuntu-latest')) || + (environment?.platform === 'ios' && + (environment.deviceKind !== 'simulator' || + environment?.runner?.label !== 'macos-latest')) + ) { + errors.push('environment platform, device kind, and runner label disagree'); + } + if (!exactSemver(environment?.reactNativeVersion)) { + errors.push('environment React Native version must be exact semver'); + } + if (!['hermes', 'jsc'].includes(environment?.jsEngine)) { + errors.push('environment jsEngine must be hermes or jsc'); + } + if (environment?.buildType !== 'debug') errors.push('environment buildType must be debug'); + return errors; +} + +function containsUnknown(value) { + if (value === null || value === undefined) return true; + if (typeof value === 'string') { + return value.trim() === '' || /^(?:unknown|null|undefined|n\/a)$/i.test(value.trim()); + } + if (Array.isArray(value)) return value.length === 0 || value.some(containsUnknown); + if (typeof value === 'object') { + const values = Object.values(value); + return values.length === 0 || values.some(containsUnknown); + } + return false; +} + +function sameFields(value, expected) { + return value && deepEqual(Object.keys(value).sort(), [...expected].sort()); +} + +function readSecureAsset(root, relative, errors) { + const candidate = path.resolve(root, relative); + if (!candidate.startsWith(`${root}${path.sep}`) || !existsSync(candidate)) { + errors.push(`asset is missing: ${relative}`); + return null; + } + const status = lstatSync(candidate); + if (!status.isFile() || status.isSymbolicLink()) { + errors.push(`asset must be a regular non-symlink file: ${relative}`); + return null; + } + return readFileSync(candidate); +} + +function readJsonAsset(root, relative, errors) { + const bytes = readSecureAsset(root, relative, errors); + if (!bytes) return null; + try { + return JSON.parse(bytes.toString('utf8')); + } catch (error) { + errors.push(`asset JSON is invalid: ${relative}: ${error.message}`); + return null; + } +} + +function readArtifactDirectory(root) { + const status = lstatSync(root); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('artifact root must be a regular directory'); + } + // Kept synchronous so the verifier has one deterministic filesystem snapshot. + const entries = []; + for (const entry of readdirSync(root)) { + const fullPath = path.join(root, entry); + const status = lstatSync(fullPath); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`artifact contains a non-regular entry: ${entry}`); + } + entries.push(entry); + } + return entries; +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function deepEqual(left, right) { + return isDeepStrictEqual(left, right); +} + +function positiveInteger(value) { + return Number.isInteger(value) && value > 0; +} + +function finitePositive(value) { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function nonEmpty(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function exactSemver(value) { + return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test( + value ?? '' + ); +} + +function canonicalIsoTimestamp(value) { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) + ) { + return false; + } + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value; +} + +function validRunUrl(value) { + return /^https:\/\/github\.com\/GGULBAE\/react-native-image-compression-kit\/actions\/runs\/\d+$/.test( + value ?? '' + ); +} diff --git a/scripts/inspect-ios-simulator-metadata.mjs b/scripts/inspect-ios-simulator-metadata.mjs new file mode 100644 index 0000000..b3842db --- /dev/null +++ b/scripts/inspect-ios-simulator-metadata.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { inspectBootedIosSimulatorMetadata } from './ios-simulator-metadata-core.mjs'; + +const options = parseArgs(process.argv.slice(2)); +for (const field of ['devices', 'runtimes', 'appArchitectures', 'runnerArch']) { + if (!options[field]) throw new Error(`--${field} is required`); +} +const report = inspectBootedIosSimulatorMetadata({ + devices: JSON.parse(readFileSync(options.devices, 'utf8')), + runtimes: JSON.parse(readFileSync(options.runtimes, 'utf8')), + appArchitectures: options.appArchitectures, + runnerArch: options.runnerArch, + udid: options.udid ?? null, +}); +if (report.status !== 'passed') throw new Error(report.error); +process.stdout.write(`${JSON.stringify(report)}\n`); + +function parseArgs(args) { + const parsed = {}; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flag?.startsWith('--') || !value) { + throw new Error(`invalid argument: ${flag ?? ''}`); + } + parsed[ + flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + ] = value; + } + return parsed; +} diff --git a/scripts/ios-simulator-metadata-core.mjs b/scripts/ios-simulator-metadata-core.mjs new file mode 100644 index 0000000..83e8c4c --- /dev/null +++ b/scripts/ios-simulator-metadata-core.mjs @@ -0,0 +1,141 @@ +const IOS_RUNTIME_PREFIX = 'com.apple.CoreSimulator.SimRuntime.iOS-'; +const CANONICAL_UDID = + /^[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$/; + +export function inspectBootedIosSimulatorMetadata({ + devices, + runtimes, + appArchitectures, + runnerArch, + udid = null, +}) { + const errors = []; + if (udid !== null && !CANONICAL_UDID.test(udid)) { + errors.push('udid must be a canonical simulator identifier'); + } + if (!plainObject(devices?.devices)) { + errors.push('devices payload must contain a devices object'); + } + if (!Array.isArray(runtimes?.runtimes)) { + errors.push('runtimes payload must contain a runtimes array'); + } + const abi = inspectAppArchitecture(appArchitectures, runnerArch, errors); + + const matches = []; + if (plainObject(devices?.devices)) { + for (const [runtimeIdentifier, candidates] of Object.entries( + devices.devices + )) { + if (!runtimeIdentifier.startsWith(IOS_RUNTIME_PREFIX)) continue; + if (!Array.isArray(candidates)) { + errors.push(`device list must be an array: ${runtimeIdentifier}`); + continue; + } + for (const candidate of candidates) { + if (udid === null || candidate?.udid === udid) { + matches.push({ runtimeIdentifier, candidate }); + } + } + } + } + if (matches.length !== 1) { + errors.push('selection must identify exactly one iOS simulator device'); + } + const match = matches[0]; + if (match?.candidate?.state !== 'Booted') { + errors.push('selected simulator must be booted'); + } + if (match?.candidate?.isAvailable === false) { + errors.push('selected simulator device must be available'); + } + if (!CANONICAL_UDID.test(match?.candidate?.udid ?? '')) { + errors.push('selected simulator udid must be canonical'); + } + if (!safeLabel(match?.candidate?.name)) { + errors.push('selected simulator name is required'); + } + + const runtimeMatches = Array.isArray(runtimes?.runtimes) + ? runtimes.runtimes.filter( + (runtime) => runtime?.identifier === match?.runtimeIdentifier + ) + : []; + if (runtimeMatches.length !== 1) { + errors.push('device runtime must identify exactly one installed runtime'); + } + const runtime = runtimeMatches[0]; + if (runtime?.isAvailable !== true) { + errors.push('selected simulator runtime must be available'); + } + if (!safeLabel(runtime?.name) || !runtime.name.startsWith('iOS ')) { + errors.push('selected simulator runtime name must identify iOS'); + } + if (!safeLabel(runtime?.buildversion)) { + errors.push('selected simulator runtime buildversion is required'); + } + + if (errors.length > 0) { + return failed(errors); + } + return { + status: 'passed', + udid: match.candidate.udid, + runtimeIdentifier: match.runtimeIdentifier, + runtime: runtime.name, + osBuild: runtime.buildversion, + device: match.candidate.name, + abi, + error: null, + }; +} + +function failed(errors) { + return { + status: 'failed', + udid: null, + runtimeIdentifier: null, + runtime: null, + osBuild: null, + device: null, + abi: null, + error: errors.join(' | '), + }; +} + +function inspectAppArchitecture(appArchitectures, runnerArch, errors) { + if (!safeLabel(appArchitectures)) { + errors.push('built app architectures must be a safe non-empty label'); + return null; + } + const architectures = appArchitectures.split(/\s+/); + if ( + architectures.length !== 1 || + !['arm64', 'x86_64'].includes(architectures[0]) + ) { + errors.push('built simulator app must contain exactly one supported architecture'); + return null; + } + const expectedAbi = { ARM64: 'arm64', X64: 'x86_64' }[runnerArch]; + if (!expectedAbi) { + errors.push('runner architecture must be ARM64 or X64'); + return null; + } + if (architectures[0] !== expectedAbi) { + errors.push('built simulator app architecture must match the runner'); + return null; + } + return architectures[0]; +} + +function plainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function safeLabel(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.trim() && + !/[\u0000-\u001f\u007f]/.test(value) + ); +} diff --git a/scripts/ios-validation.mjs b/scripts/ios-validation.mjs index 13b90b3..83014fc 100644 --- a/scripts/ios-validation.mjs +++ b/scripts/ios-validation.mjs @@ -126,6 +126,17 @@ const JPEG_METADATA_TEST_SOURCE = path.join( 'ios-native', 'RCTImageCompressionJpegMetadataTests.mm' ); +const JPEG_SEGMENT_SANITIZER_CORE_SOURCE = path.join( + ROOT, + 'ios', + 'RCTImageCompressionJpegSegmentSanitizer.mm' +); +const JPEG_SEGMENT_SANITIZER_TEST_SOURCE = path.join( + ROOT, + 'test', + 'ios-native', + 'RCTImageCompressionJpegSegmentSanitizerTests.mm' +); const IOS_VALIDATION_CONFIG = createIOSValidationConfig(process.env); const METRO_PORT = IOS_VALIDATION_CONFIG.metroPort; const METRO_READY_TIMEOUT_MS = IOS_VALIDATION_CONFIG.metroReadyTimeoutMs; @@ -204,6 +215,11 @@ async function main() { return; } + if (mode === 'jpeg-sanitizer-test') { + runJpegSegmentSanitizerTests(); + return; + } + if (mode === 'build') { checkIOSBuildEnvironment(); ensurePodsInstalled(); @@ -225,6 +241,7 @@ async function main() { runOutputTests(); runPipelineTests(); runLargeImageTests(); + runJpegSegmentSanitizerTests(); ensurePackageJSBuild(); ensurePodsInstalled(); const simulator = selectSimulator(); @@ -355,6 +372,7 @@ function runLargeImageTests() { path.join(ROOT, 'ios', 'RCTImageCompressionCGImage.mm'), path.join(ROOT, 'ios', 'RCTImageCompressionUIKitImageDecoder.mm'), path.join(ROOT, 'ios', 'RCTImageCompressionUIKitImageTransformer.mm'), + JPEG_SEGMENT_SANITIZER_CORE_SOURCE, path.join(ROOT, 'ios', 'RCTImageCompressionUIKitImageEncoder.mm'), path.join(ROOT, 'ios', 'RCTImageCompressionDefaultPipeline.mm'), LARGE_IMAGE_TEST_SOURCE, @@ -388,6 +406,17 @@ function runJpegMetadataTests() { ); } +function runJpegSegmentSanitizerTests() { + runNativeTests( + 'RCTImageCompressionJpegSegmentSanitizerTests', + [ + JPEG_SEGMENT_SANITIZER_CORE_SOURCE, + JPEG_SEGMENT_SANITIZER_TEST_SOURCE, + ], + ['Foundation'] + ); +} + function runNativeTests(executableName, sourceFiles, frameworks) { mustRun('xcrun', ['--sdk', 'macosx', '--show-sdk-path'], { failureHint: 'Install Xcode Command Line Tools with the macOS SDK.', @@ -539,6 +568,7 @@ function ensurePodsInstalled() { 'RCTImageCompressionImageEncoder.mm', 'RCTImageCompressionImageTransformer.mm', 'RCTImageCompressionJpegMetadata.mm', + 'RCTImageCompressionJpegSegmentSanitizer.mm', 'RCTImageCompressionUIKitImageDecoder.mm', 'RCTImageCompressionUIKitImageEncoder.mm', 'RCTImageCompressionUIKitImageTransformer.mm', diff --git a/scripts/measure-demo-visual-agreement.mjs b/scripts/measure-demo-visual-agreement.mjs index 80be8a4..bfb4b84 100644 --- a/scripts/measure-demo-visual-agreement.mjs +++ b/scripts/measure-demo-visual-agreement.mjs @@ -8,6 +8,8 @@ import { createDemoVisualAgreementReport, parseFfmpegFrameDimensions, parseFfmpegSsim, + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT, + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, } from './demo-visual-agreement-core.mjs'; const options = parseArgs(process.argv.slice(2)); @@ -24,12 +26,27 @@ for (const field of [ if (options.resizeMode !== 'contain') { throw new Error('--resize-mode must be contain'); } +if ( + options.comparisonProfile !== undefined && + options.comparisonProfile !== PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE +) { + throw new Error('--comparison-profile is unsupported'); +} const maxWidth = positiveInteger(options.maxWidth, '--max-width'); const maxHeight = positiveInteger(options.maxHeight, '--max-height'); const source = path.resolve(options.source); const output = path.resolve(options.output); const sourceDimensions = inspectAutoOrientedDimensions(source); const outputDimensions = inspectDimensions(output); +const portable = + options.comparisonProfile === PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE; +const sourceColorRange = portable ? inspectColorRange(source) : undefined; +const outputColorRange = portable ? inspectColorRange(output) : undefined; +if (portable && (sourceColorRange !== 'pc' || outputColorRange !== 'pc')) { + throw new Error( + '--comparison-profile requires full-range source and output JPEG frames' + ); +} const expectedDimensions = calculateContainDimensions({ sourceWidth: sourceDimensions.width, sourceHeight: sourceDimensions.height, @@ -40,13 +57,15 @@ const uprightSimilarity = measureSimilarity( source, output, expectedDimensions, - false + false, + options.comparisonProfile ); const verticalFlipSimilarity = measureSimilarity( source, output, expectedDimensions, - true + true, + options.comparisonProfile ); const report = createDemoVisualAgreementReport({ sourceBytes: readFileSync(source), @@ -60,6 +79,9 @@ const report = createDemoVisualAgreementReport({ maxHeight, uprightSimilarity, verticalFlipSimilarity, + comparisonProfile: options.comparisonProfile, + sourceColorRange, + outputColorRange, }); writeFileSync(path.resolve(options.report), `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`${JSON.stringify(report)}\n`); @@ -93,11 +115,47 @@ function inspectDimensions(file) { return { width: stream.width, height: stream.height }; } -function measureSimilarity(source, output, { width, height }, flipVertically) { +function inspectColorRange(file) { + const result = mustRun('ffprobe', [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=color_range', + '-of', 'json', + file, + ]); + const colorRange = JSON.parse(result.stdout).streams?.[0]?.color_range; + if (typeof colorRange !== 'string' || colorRange.length === 0) { + throw new Error('ffprobe did not return an input color range'); + } + return colorRange; +} + +function measureSimilarity( + source, + output, + { width, height }, + flipVertically, + comparisonProfile +) { + const rangeContract = + comparisonProfile === PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE + ? `:in_range=${PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.inputColorRange}` + + `:out_range=${PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.comparisonColorRange}` + : ''; + const scaler = portableValue( + comparisonProfile, + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.scaler, + 'lanczos' + ); + const pixelFormat = portableValue( + comparisonProfile, + PORTABLE_DEMO_VISUAL_AGREEMENT_CONTRACT.pixelFormat, + 'yuv444p' + ); const referenceFilters = [ - `scale=${width}:${height}:flags=lanczos`, + `scale=${width}:${height}:flags=${scaler}${rangeContract}`, ...(flipVertically ? ['vflip'] : []), - 'format=yuv444p', + `format=${pixelFormat}`, ].join(','); const result = mustRun('ffmpeg', [ '-hide_banner', @@ -106,7 +164,7 @@ function measureSimilarity(source, output, { width, height }, flipVertically) { '-i', output, '-filter_complex', `[0:v]${referenceFilters}[reference];` + - `[1:v]scale=${width}:${height}:flags=lanczos,format=yuv444p[candidate];` + + `[1:v]scale=${width}:${height}:flags=${scaler}${rangeContract},format=${pixelFormat}[candidate];` + '[reference][candidate]ssim', '-frames:v', '1', '-f', 'null', @@ -115,6 +173,12 @@ function measureSimilarity(source, output, { width, height }, flipVertically) { return parseFfmpegSsim(result.stderr); } +function portableValue(profile, portable, legacy) { + return profile === PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE + ? portable + : legacy; +} + function mustRun(command, args) { const result = spawnSync(command, args, { encoding: 'utf8', diff --git a/scripts/verify-economic-resilience-evidence.mjs b/scripts/verify-economic-resilience-evidence.mjs new file mode 100644 index 0000000..0b82744 --- /dev/null +++ b/scripts/verify-economic-resilience-evidence.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node + +import { + existsSync, + linkSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + ECONOMIC_RESILIENCE_ASSET_FILES, + inspectEconomicResilienceEvidence, +} from './economic-resilience-evidence-core.mjs'; +import { + comparePortableDemoVisualAgreement, + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, +} from './demo-visual-agreement-core.mjs'; + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); + +const options = parseArgs(process.argv.slice(2)); +if (!options.artifactDir) throw new Error('--artifact-dir is required'); +const artifactDir = secureArtifactRoot(options.artifactDir); +const reportFile = options.reportFile + ? secureReportDestination(options.reportFile, artifactDir) + : null; +preflightArtifact(artifactDir); +const evidence = JSON.parse( + readFileSync(path.join(artifactDir, 'economic-resilience.json'), 'utf8') +); +const structuralReport = inspectEconomicResilienceEvidence(artifactDir, evidence); +let report = { + ...structuralReport, + replay: { + status: 'not-run', + localFfmpegVersion: null, + capturedFfmpegVersion: evidence?.environment?.toolchain?.ffmpeg ?? null, + ffmpegVersionsMatch: null, + localFfprobeVersion: null, + capturedFfprobeVersion: evidence?.environment?.toolchain?.ffprobe ?? null, + ffprobeVersionsMatch: null, + measurementMatch: null, + measurementMode: null, + measurementTolerance: null, + outcomesPassed: null, + exactShapes: null, + stableFieldsMatch: null, + uprightSimilarityDelta: null, + verticalFlipSimilarityDelta: null, + }, +}; +if (structuralReport.status === 'passed') { + try { + const replay = replayVisualAgreement(artifactDir, evidence); + report = { ...structuralReport, replay }; + } catch (error) { + report = { + ...structuralReport, + status: 'failed', + representative: null, + economics: null, + replay: { + status: 'failed', + localFfmpegVersion: error.localFfmpegVersion ?? null, + capturedFfmpegVersion: evidence?.environment?.toolchain?.ffmpeg ?? null, + ffmpegVersionsMatch: + error.localFfmpegVersion === undefined + ? null + : error.localFfmpegVersion === evidence?.environment?.toolchain?.ffmpeg, + localFfprobeVersion: error.localFfprobeVersion ?? null, + capturedFfprobeVersion: evidence?.environment?.toolchain?.ffprobe ?? null, + ffprobeVersionsMatch: + error.localFfprobeVersion === undefined + ? null + : error.localFfprobeVersion === evidence?.environment?.toolchain?.ffprobe, + measurementMatch: false, + measurementMode: error.measurementMode ?? null, + measurementTolerance: error.measurementTolerance ?? null, + outcomesPassed: error.outcomesPassed ?? null, + exactShapes: error.exactShapes ?? null, + stableFieldsMatch: error.stableFieldsMatch ?? null, + uprightSimilarityDelta: error.uprightSimilarityDelta ?? null, + verticalFlipSimilarityDelta: error.verticalFlipSimilarityDelta ?? null, + }, + error: `visual replay failed: ${error.message}`, + }; + } +} +const serialized = `${JSON.stringify(report)}\n`; +if (reportFile) writeReportAtomic(reportFile, serialized); +process.stdout.write(serialized); +if (report.status !== 'passed') process.exitCode = 1; + +function replayVisualAgreement(root, evidence) { + const temporary = mkdtempSync(path.join(os.tmpdir(), 'rnick-visual-replay-')); + const replay = path.join(temporary, 'visual-agreement.json'); + try { + const version = spawnSync('ffmpeg', ['-version'], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (version.error) throw version.error; + if (version.status !== 0) throw new Error(version.stderr.trim()); + const versionLine = version.stdout.split(/\r?\n/, 1)[0]; + const probeVersion = spawnSync('ffprobe', ['-version'], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (probeVersion.error) throw probeVersion.error; + if (probeVersion.status !== 0) throw new Error(probeVersion.stderr.trim()); + const probeVersionLine = probeVersion.stdout.split(/\r?\n/, 1)[0]; + const result = spawnSync( + process.execPath, + [ + path.join(SCRIPT_DIRECTORY, 'measure-demo-visual-agreement.mjs'), + '--source', + path.join(root, 'source.jpg'), + '--output', + path.join(root, 'output.jpg'), + '--resize-mode', + 'contain', + '--max-width', + '1600', + '--max-height', + '1200', + '--comparison-profile', + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, + '--report', + replay, + ], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 } + ); + if (result.error) throw result.error; + if (result.status !== 0 || !existsSync(replay)) { + const error = new Error( + result.stderr.trim() || result.stdout.trim() || 'measurement failed' + ); + error.localFfmpegVersion = versionLine; + error.localFfprobeVersion = probeVersionLine; + throw error; + } + const measured = JSON.parse(readFileSync(replay, 'utf8')); + const ffmpegVersionsMatch = + versionLine === evidence.environment.toolchain.ffmpeg; + const ffprobeVersionsMatch = + probeVersionLine === evidence.environment.toolchain.ffprobe; + const comparison = comparePortableDemoVisualAgreement( + evidence.visualAgreement, + measured + ); + if (comparison.status !== 'passed') { + const error = new Error(comparison.error); + error.localFfmpegVersion = versionLine; + error.localFfprobeVersion = probeVersionLine; + error.measurementMode = comparison.mode; + error.measurementTolerance = comparison.tolerance; + error.outcomesPassed = comparison.outcomesPassed; + error.exactShapes = comparison.exactShapes; + error.stableFieldsMatch = comparison.stableFieldsMatch; + error.uprightSimilarityDelta = comparison.uprightSimilarityDelta; + error.verticalFlipSimilarityDelta = comparison.verticalFlipSimilarityDelta; + throw error; + } + return { + status: 'passed', + localFfmpegVersion: versionLine, + capturedFfmpegVersion: evidence.environment.toolchain.ffmpeg, + ffmpegVersionsMatch, + localFfprobeVersion: probeVersionLine, + capturedFfprobeVersion: evidence.environment.toolchain.ffprobe, + ffprobeVersionsMatch, + measurementMatch: comparison.measurementMatch, + measurementMode: comparison.mode, + measurementTolerance: comparison.tolerance, + outcomesPassed: comparison.outcomesPassed, + exactShapes: comparison.exactShapes, + stableFieldsMatch: comparison.stableFieldsMatch, + uprightSimilarityDelta: comparison.uprightSimilarityDelta, + verticalFlipSimilarityDelta: comparison.verticalFlipSimilarityDelta, + }; + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +function secureArtifactRoot(value) { + const requested = path.resolve(value); + const status = lstatSync(requested); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('--artifact-dir must be a regular non-symlink directory'); + } + return realpathSync(requested); +} + +function preflightArtifact(root) { + const entries = readdirSync(root).sort(); + if (JSON.stringify(entries) !== JSON.stringify(ECONOMIC_RESILIENCE_ASSET_FILES)) { + throw new Error('artifact must contain the exact economic resilience asset set'); + } + for (const entry of entries) { + const candidate = path.join(root, entry); + const status = lstatSync(candidate); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`artifact entry must be a regular non-symlink file: ${entry}`); + } + } +} + +function secureReportDestination(value, artifactRoot) { + const requested = path.resolve(value); + const requestedParent = path.dirname(requested); + const requestedParentStatus = lstatSync(requestedParent); + const canonicalParent = realpathSync(requestedParent); + const parentStatus = lstatSync(canonicalParent); + if ( + !requestedParentStatus.isDirectory() || + requestedParentStatus.isSymbolicLink() || + !parentStatus.isDirectory() || + parentStatus.isSymbolicLink() + ) { + throw new Error('--report-file parent must resolve to a regular directory'); + } + const destination = path.join(canonicalParent, path.basename(requested)); + if ( + destination === artifactRoot || + destination.startsWith(`${artifactRoot}${path.sep}`) + ) { + throw new Error('--report-file must be outside the artifact directory'); + } + try { + lstatSync(destination); + throw new Error('--report-file must not already exist'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + return destination; +} + +function writeReportAtomic(destination, contents) { + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp` + ); + try { + writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 }); + // A same-filesystem hard link atomically publishes complete bytes and, + // unlike rename(), refuses to replace a destination created in a race. + linkSync(temporary, destination); + } finally { + try { + unlinkSync(temporary); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } +} + +function parseArgs(args) { + const parsed = {}; + const normalizedArgs = args.filter((value) => value !== '--'); + for (let index = 0; index < normalizedArgs.length; index += 2) { + const flag = normalizedArgs[index]; + const value = normalizedArgs[index + 1]; + if (!flag?.startsWith('--') || !value) { + throw new Error(`invalid argument: ${flag ?? ''}`); + } + parsed[flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = + value; + } + return parsed; +} diff --git a/test/demoCaptureScriptContract.test.ts b/test/demoCaptureScriptContract.test.ts index 9b8be0d..5d983a1 100644 --- a/test/demoCaptureScriptContract.test.ts +++ b/test/demoCaptureScriptContract.test.ts @@ -23,6 +23,75 @@ describe('Android demo screenshot capture', () => { expect(source).toContain('RNICK_BENCHMARK_COMPARISON_PASS'); expect(source).toContain('create-benchmark-comparison-evidence.mjs'); expect(source).toContain('verify-benchmark-comparison-evidence.mjs'); + expect(source).toContain('RNICK_ECONOMIC_RESILIENCE_PASS'); + expect(source).toContain('create-economic-resilience-environment.mjs'); + expect(source).toContain('create-economic-resilience-evidence.mjs'); + expect(source).toContain('verify-economic-resilience-evidence.mjs'); + expect(source).toContain('--run-id "$GITHUB_RUN_ID"'); + expect(source).toContain('--run-attempt "$GITHUB_RUN_ATTEMPT"'); + expect(source).toContain('ffprobe_version=$(ffprobe -version | head -n 1)'); + expect(source).toContain('--ffprobe "$ffprobe_version"'); + expect(source).toContain('--max-width 1600'); + expect(source).toContain('--max-height 1200'); + }); + + it('streams filtered Android logs cumulatively until all evidence markers pass', () => { + const streamStart = source.indexOf( + "adb logcat -v threadtime -s RNICK_DEMO:I '*:S' > /tmp/rnick-demo-raw/native.log &", + ); + const captureLaunch = source.indexOf( + 'adb shell am start -n com.imagecompressionkit.example/.MainActivity --ez rnick-demo-capture true', + ); + const completionGate = source.indexOf( + "grep -q 'RNICK_ECONOMIC_RESILIENCE_PASS'", + ); + const streamStop = source.indexOf('stop_logcat_stream', captureLaunch); + const firstBuilder = source.indexOf('node scripts/normalize-demo-recording.mjs'); + + expect(source).toContain('logcat_pid=$!'); + expect(source).toContain('kill -TERM "$logcat_pid"'); + expect(source).toContain('kill -KILL "$logcat_pid"'); + expect(source).toContain('for _attempt in $(seq 1 50)'); + expect(source).toContain('wait "$logcat_pid"'); + expect(source).not.toContain('adb logcat -d'); + expect(source).toContain("grep -q 'RNICK_DEMO_FAIL'"); + expect(source).toContain('tail -n 200 /tmp/rnick-demo-raw/native.log'); + expect(source).toContain('tail -n 200 /tmp/rnick-metro.log'); + expect(streamStart).toBeGreaterThan(-1); + expect(streamStart).toBeLessThan(captureLaunch); + expect(completionGate).toBeGreaterThan(captureLaunch); + expect(streamStop).toBeGreaterThan(completionGate); + expect(streamStop).toBeLessThan(firstBuilder); + }); + + it('pins dispatch source and retains complete platform logs for the 12 MP bundle', () => { + expect(workflow).toContain('source_sha:'); + expect(workflow.match(/Verify exact source checkout/g)?.length).toBe(2); + expect(workflow).toContain('test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA"'); + expect(workflow).toContain('RNICK_ECONOMIC_RESILIENCE_'); + expect(workflow).toContain('RNICK_ECONOMIC_RESILIENCE_PASS'); + expect(workflow).toContain('log show --style compact --last 15m'); + expect(workflow).not.toContain('--start "$capture_started_at"'); + expect(workflow).not.toContain('log show --style compact --last 3m'); + expect(workflow).toContain('for attempt in $(seq 1 300)'); + expect(workflow).toContain("grep -q 'RNICK_DEMO_FAIL'"); + expect(workflow).toContain('tail -n 200 /tmp/rnick-demo-raw/native.log'); + expect(workflow).toContain('tail -n 200 /tmp/rnick-metro.log'); + expect(workflow).toContain('create-economic-resilience-environment.mjs'); + expect(workflow).toContain('create-economic-resilience-evidence.mjs'); + expect(workflow).toContain('verify-economic-resilience-evidence.mjs'); + expect(workflow).toContain('--run-id "$GITHUB_RUN_ID"'); + expect(workflow).toContain('--run-attempt "$GITHUB_RUN_ATTEMPT"'); + expect(workflow).toContain('ffprobe_version=$(ffprobe -version | head -n 1)'); + expect(workflow).toContain('--ffprobe "$ffprobe_version"'); + expect(workflow).toContain('xcrun simctl list runtimes --json'); + expect(workflow).toContain('inspect-ios-simulator-metadata.mjs'); + expect(workflow).toContain("require('/tmp/rnick-sim-metadata.json').udid"); + expect(workflow).not.toContain('simctl spawn "$udid" sw_vers'); + expect(workflow).not.toContain('simctl spawn "$udid" uname'); + expect(workflow).toContain('xcrun lipo -archs "$app_executable"'); + expect(workflow).toContain('--runner-arch "$RUNNER_ARCH"'); + expect(workflow).toContain("require('/tmp/rnick-sim-metadata.json').abi"); }); it('records the complete guided walkthrough on both native platforms', () => { diff --git a/test/demoVisualAgreement.test.mjs b/test/demoVisualAgreement.test.mjs index 82bff4a..5958cdc 100644 --- a/test/demoVisualAgreement.test.mjs +++ b/test/demoVisualAgreement.test.mjs @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import { createDemoVisualAgreementReport, + comparePortableDemoVisualAgreement, inspectDemoVisualAgreement, calculateContainDimensions, parseFfmpegFrameDimensions, parseFfmpegSsim, + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, } from '../scripts/demo-visual-agreement-core.mjs'; const sourceBytes = Buffer.from('asymmetric source'); @@ -128,7 +130,7 @@ describe('native demo visual agreement', () => { }); expect(inspection.status).toBe('failed'); for (const message of [ - 'schemaVersion must be 2', + 'schemaVersion must be 2 or 3', 'status must be passed or failed', 'algorithm is unsupported', 'resizeMode must be contain', @@ -153,6 +155,86 @@ describe('native demo visual agreement', () => { })).toEqual({ width: null, height: null }); }); + it('binds the portable range contract and allows only narrow score drift', () => { + const captured = createDemoVisualAgreementReport({ + sourceBytes, + outputBytes, + sourceWidth: 4_000, + sourceHeight: 3_000, + width: 1_600, + height: 1_200, + resizeMode: 'contain', + maxWidth: 1_600, + maxHeight: 1_200, + uprightSimilarity: 0.944431, + verticalFlipSimilarity: 0.690423, + comparisonProfile: PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, + sourceColorRange: 'pc', + outputColorRange: 'pc', + }); + const replayed = { + ...captured, + uprightSimilarity: 0.944398, + verticalFlipSimilarity: 0.690907, + }; + + expect(captured).toMatchObject({ + schemaVersion: 3, + algorithm: 'ffmpeg-auto-oriented-contain-limited-range-ssim-v3', + inputColorRange: 'pc', + comparisonColorRange: 'tv', + comparisonPixelFormat: 'yuv444p', + comparisonScaler: 'lanczos', + scoreTolerance: 0.001, + sourceColorRange: 'pc', + outputColorRange: 'pc', + }); + expect(comparePortableDemoVisualAgreement(captured, replayed)).toMatchObject({ + status: 'passed', + mode: 'portable-tolerance', + measurementMatch: true, + outcomesPassed: true, + exactShapes: true, + stableFieldsMatch: true, + tolerance: 0.001, + uprightSimilarityDelta: 0.000033, + verticalFlipSimilarityDelta: 0.000484, + }); + expect( + comparePortableDemoVisualAgreement(captured, { + ...replayed, + uprightSimilarity: 0.943, + }) + ).toMatchObject({ status: 'failed', measurementMatch: false }); + const failedPair = { + ...captured, + status: 'failed', + checks: { ...captured.checks, minimumSimilarity: false }, + }; + expect(comparePortableDemoVisualAgreement(failedPair, failedPair)).toMatchObject({ + status: 'failed', + outcomesPassed: false, + }); + expect( + comparePortableDemoVisualAgreement( + { ...captured, perceptuallyLossless: true }, + replayed + ) + ).toMatchObject({ status: 'failed', exactShapes: false }); + expect( + inspectDemoVisualAgreement( + { ...captured, checks: { ...captured.checks, extra: true } }, + { sourceBytes, outputBytes } + ).error + ).toContain('portable visual agreement check fields drifted'); + expect( + inspectDemoVisualAgreement( + { ...captured, comparisonColorRange: 'pc' }, + { sourceBytes, outputBytes } + ).error + ).toContain('comparisonColorRange does not match the visual schema'); + }); + it('calculates rounded contain geometry and parses auto-oriented dimensions', () => { expect(calculateContainDimensions({ sourceWidth: 320, diff --git a/test/economicResilienceBenchmark.test.mjs b/test/economicResilienceBenchmark.test.mjs new file mode 100644 index 0000000..0c4c3b0 --- /dev/null +++ b/test/economicResilienceBenchmark.test.mjs @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ECONOMIC_RESILIENCE_FIXTURE, + ECONOMIC_RESILIENCE_PASS_MARKER, + runEconomicResilienceBenchmark, + selectEconomicResilienceClock, +} from '../example/src/economicResilienceBenchmark.ts'; +import { parseNativeEconomicResiliencePayload } from '../scripts/economic-resilience-evidence-core.mjs'; + +const OUTPUT_SHA = 'a'.repeat(64); +const OUTPUT_BYTES = 240_000; + +describe('kit-only 12 MP economic resilience runner', () => { + it('times only 2 warmups + 10 measured calls, stages #10, and removes all 12 outputs', async () => { + const harness = createHarness(); + const result = await runEconomicResilienceBenchmark( + harness.sampleModule, + 'android', + harness.dependencies + ); + + expect(harness.compress).toHaveBeenCalledTimes(12); + expect(harness.timingEvents.slice(0, 3)).toEqual([ + 'clock', + 'compress', + 'clock', + ]); + expect(harness.timingEvents).toEqual( + Array.from({ length: 12 }, () => ['clock', 'compress', 'clock']).flat() + ); + expect(harness.removeOutput).toHaveBeenCalledTimes(12); + expect(harness.stageOutput).toHaveBeenCalledTimes(1); + expect(harness.stageOutput).toHaveBeenCalledWith('file:///cache/output-12.jpg'); + expect(result.payload.samples.slice(0, 2).map(({ phase, iteration }) => [phase, iteration])) + .toEqual([['warmup', 1], ['warmup', 2]]); + expect(result.payload.samples.slice(2).map(({ phase, iteration }) => [phase, iteration])) + .toEqual(Array.from({ length: 10 }, (_, index) => ['measured', index + 1])); + expect(result.payload.cleanup).toEqual({ + attemptedPackageOutputs: 12, + removedPackageOutputs: 12, + residualPackageOutputs: 0, + residualPackageOutputBytes: 0, + }); + expect(result.payload.fixture.remainsAfterRun).toBe(true); + expect(result.payload.representative).toMatchObject({ + measuredIteration: 10, + stagedOutputUri: 'file:///cache/staged-output.jpg', + }); + expect(result.logs.at(-1)).toContain(ECONOMIC_RESILIENCE_PASS_MARKER); + expect(parseNativeEconomicResiliencePayload(`${result.logs.join('\n')}\n`)).toEqual( + result.payload + ); + }); + + it('still removes a package output when inspection or acceptance fails', async () => { + const harness = createHarness({ invalidFirstOutput: true }); + await expect( + runEconomicResilienceBenchmark( + harness.sampleModule, + 'android', + harness.dependencies + ) + ).rejects.toThrow('primary: warmup output 1 must be an existing, hashed, decodable JPEG'); + expect(harness.removeOutput).toHaveBeenCalledTimes(1); + expect(harness.removed).toContain('file:///cache/output-1.jpg'); + }); + + it('still removes a package output when the post-call clock fails', async () => { + const harness = createHarness({ failSecondClock: true }); + await expect( + runEconomicResilienceBenchmark( + harness.sampleModule, + 'android', + harness.dependencies + ) + ).rejects.toThrow('primary: clock failed'); + expect(harness.removeOutput).toHaveBeenCalledTimes(1); + }); + + it('combines primary and cleanup errors and never emits PASS', async () => { + const harness = createHarness({ + invalidFirstOutput: true, + failFirstRemoval: true, + reportFirstResidual: true, + }); + await expect( + runEconomicResilienceBenchmark( + harness.sampleModule, + 'android', + harness.dependencies + ) + ).rejects.toThrow(/primary: .* \| cleanup: removal failed \| cleanup: .* remained/); + expect(harness.removeOutput).toHaveBeenCalledTimes(1); + }); + + it('rejects an output that remains after a resolved cleanup call', async () => { + const harness = createHarness({ reportFirstResidual: true }); + await expect( + runEconomicResilienceBenchmark( + harness.sampleModule, + 'android', + harness.dependencies + ) + ).rejects.toThrow('cleanup: warmup output 1 remained after removal'); + expect(harness.removeOutput).toHaveBeenCalledTimes(1); + }); + + it('rejects a non-finite compression ratio before any PASS marker', async () => { + const harness = createHarness({ invalidCompressionRatio: true }); + await expect( + runEconomicResilienceBenchmark( + harness.sampleModule, + 'android', + harness.dependencies + ) + ).rejects.toThrow('Compression output does not satisfy the 12 MP acceptance contract'); + expect(harness.removeOutput).toHaveBeenCalledTimes(1); + }); + + it('selects the recorded clock and falls back to Date.now as one bound pair', () => { + const performanceClock = selectEconomicResilienceClock({ + performance: { now: () => 12.5 }, + Date: { now: () => 99 }, + }); + expect(performanceClock.clock).toBe('performance.now'); + expect(performanceClock.now()).toBe(12.5); + + const dateClock = selectEconomicResilienceClock({ + performance: { now: 'not-a-function' }, + Date: { now: () => 99 }, + }); + expect(dateClock.clock).toBe('Date.now'); + expect(dateClock.now()).toBe(99); + }); +}); + +function createHarness({ + invalidFirstOutput = false, + failFirstRemoval = false, + reportFirstResidual = false, + invalidCompressionRatio = false, + failSecondClock = false, +} = {}) { + let outputIndex = 0; + let clock = 0; + const timingEvents = []; + const removed = new Set(); + const sourceUri = 'file:///cache/source.jpg'; + const stagedUri = 'file:///cache/staged-output.jpg'; + const compress = vi.fn(async () => { + timingEvents.push('compress'); + outputIndex += 1; + return { + uri: `file:///cache/output-${outputIndex}.jpg`, + format: 'jpeg', + width: 1600, + height: 1200, + byteSize: OUTPUT_BYTES, + originalByteSize: ECONOMIC_RESILIENCE_FIXTURE.byteSize, + compressionRatio: invalidCompressionRatio + ? Number.NaN + : OUTPUT_BYTES / ECONOMIC_RESILIENCE_FIXTURE.byteSize, + }; + }); + const removeOutput = vi.fn(async (uri) => { + if (failFirstRemoval && uri.endsWith('output-1.jpg')) { + throw new Error('removal failed'); + } + removed.add(uri); + }); + const stageOutput = vi.fn(async () => stagedUri); + const inspectEvidenceImage = vi.fn(async (uri) => { + if (uri === sourceUri) return sourceInspection(); + if (uri === stagedUri) return outputInspection(); + if (invalidFirstOutput && uri.endsWith('output-1.jpg') && !removed.has(uri)) { + return { exists: true, byteSize: 0 }; + } + if (removed.has(uri) && !(reportFirstResidual && uri.endsWith('output-1.jpg'))) { + return { exists: false, byteSize: 0 }; + } + return outputInspection(); + }); + return { + compress, + removeOutput, + stageOutput, + removed, + timingEvents, + sampleModule: { + copySampleJpegToCache: vi.fn(async () => sourceUri), + copyEconomicResilienceJpegToCache: vi.fn(async () => sourceUri), + copyEconomicResilienceOutputForEvidence: stageOutput, + inspectEvidenceImage, + getReactNativeArchitecture: vi.fn(async () => 'new'), + }, + dependencies: { + compress, + removeOutput, + capabilities: vi.fn(async () => capabilities()), + now: () => { + timingEvents.push('clock'); + clock += 1; + if (failSecondClock && clock === 2) throw new Error('clock failed'); + return clock; + }, + clock: 'performance.now', + createCaptureId: () => 'economic-resilience-test', + }, + }; +} + +function sourceInspection() { + return { + exists: true, + byteSize: ECONOMIC_RESILIENCE_FIXTURE.byteSize, + sha256: ECONOMIC_RESILIENCE_FIXTURE.sha256, + mediaType: 'image/jpeg', + width: 4000, + height: 3000, + }; +} + +function outputInspection() { + return { + exists: true, + byteSize: OUTPUT_BYTES, + sha256: OUTPUT_SHA, + mediaType: 'image/jpeg', + width: 1600, + height: 1200, + }; +} + +function capabilities() { + return { + platform: 'android', + formats: ['jpeg', 'png', 'webp', 'heic', 'heif', 'avif', 'gif'].map( + (format) => ({ + format, + input: true, + output: ['jpeg', 'png', 'webp'].includes(format), + supportsAlpha: format !== 'jpeg', + supportsAnimation: false, + notes: [`${format} runtime evidence`], + }) + ), + metadataPolicies: ['preserve', 'safe', 'strip'], + supportsTargetSizeCompression: true, + supportsCancellation: true, + maxConcurrentOperations: 2, + supportsDecodeDownsampling: true, + resourceLimits: { + maxSourceDimension: 16_384, + maxSourcePixels: 48_000_000, + maxWorkingPixels: 16_000_000, + }, + }; +} diff --git a/test/economicResilienceEvidence.test.mjs b/test/economicResilienceEvidence.test.mjs new file mode 100644 index 0000000..ccc690c --- /dev/null +++ b/test/economicResilienceEvidence.test.mjs @@ -0,0 +1,1084 @@ +import { createHash } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + ECONOMIC_RESILIENCE_FIXTURE, + ECONOMIC_RESILIENCE_OPERATION, + buildEconomicResilienceEvidence, + inspectEconomicResilienceEvidence, + inspectFixtureManifest, + inspectJpegStructure, + inspectNativeEconomicResiliencePayload, +} from '../scripts/economic-resilience-evidence-core.mjs'; +import { + createDemoVisualAgreementReport, + PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, +} from '../scripts/demo-visual-agreement-core.mjs'; +import { createChunkedNativeLogMessages } from '../example/src/nativeBenchmarkLog.ts'; + +const SOURCE = readFileSync('example/fixtures/kit-only-12mp-v1.jpg'); +const FIXTURE_MANIFEST = JSON.parse( + readFileSync('example/fixtures/kit-only-12mp-v1.json', 'utf8') +); +const OUTPUT = minimalJpeg(1600, 1200); +const OUTPUT_SHA = sha256(OUTPUT); +const VERIFIER = path.resolve('scripts/verify-economic-resilience-evidence.mjs'); +const roots = []; + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop(), { recursive: true, force: true }); +}); + +describe('kit-only 12 MP economic resilience evidence', () => { + it('binds the repository fixture, raw samples, signed bytes, SSIM, environment, and cleanup', () => { + const artifact = createArtifact(); + const report = inspectEconomicResilienceEvidence(artifact.root, artifact.evidence); + expect(report.error).toBe(null); + + expect(inspectFixtureManifest(FIXTURE_MANIFEST, SOURCE)).toMatchObject({ + status: 'passed', + byteSize: 1_721_333, + sha256: ECONOMIC_RESILIENCE_FIXTURE.sha256, + geometry: '4000x3000', + metadataFree: true, + }); + expect(report).toMatchObject({ + status: 'passed', + platform: 'android', + economics: { + boundary: 'source-to-output-observation', + sourceOwnership: 'source-remains', + matchedTransferBaseline: null, + sourceToOutputByteDifference: SOURCE.length - OUTPUT.length, + costSavingsClaim: null, + }, + }); + }); + + it('treats native inspection object key order as non-semantic', () => { + const payload = validPayload(); + payload.representative.inspection = Object.fromEntries( + Object.entries(payload.representative.inspection).reverse() + ); + + expect(inspectNativeEconomicResiliencePayload(payload)).toEqual([]); + }); + + it('fails closed across malformed JPEG markers, scans, segments, and metadata', () => { + const malformed = [ + Buffer.alloc(0), + Buffer.from([0xff, 0xd8, 0xff, 0xff]), + Buffer.from([0xff, 0xd8, 0xff, 0x00, 0xff, 0xd9]), + Buffer.from([0xff, 0xd8, 0xff, 0xda]), + Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9]), + Buffer.from([ + 0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, + 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0x11, 0xff, 0xff, + ]), + Buffer.from([0xff, 0xd8, 0xff, 0x01, 0xff, 0xd9]), + Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]), + Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x01, 0xff, 0xd9]), + ]; + for (const bytes of malformed) { + expect(inspectJpegStructure(bytes).status).toBe('failed'); + } + + const iptc = Buffer.from('Photoshop 3.0', 'latin1'); + const withApp13 = Buffer.concat([ + SOURCE.subarray(0, -2), + Buffer.from([ + 0xff, + 0xed, + ((iptc.length + 2) >> 8) & 0xff, + (iptc.length + 2) & 0xff, + ]), + iptc, + SOURCE.subarray(-2), + ]); + expect(inspectJpegStructure(withApp13)).toMatchObject({ + status: 'passed', + hasIptc: true, + app13Count: 1, + }); + + const incidentalXmpText = Buffer.from( + 'prefix-http://ns.adobe.com/xap/1.0/\0', + 'latin1' + ); + const withIncidentalXmpText = Buffer.concat([ + SOURCE.subarray(0, -2), + Buffer.from([ + 0xff, + 0xe1, + ((incidentalXmpText.length + 2) >> 8) & 0xff, + (incidentalXmpText.length + 2) & 0xff, + ]), + incidentalXmpText, + SOURCE.subarray(-2), + ]); + expect(inspectJpegStructure(withIncidentalXmpText)).toMatchObject({ + status: 'passed', + hasXmp: false, + app1Count: 1, + }); + + for (const identifier of [ + Buffer.from('http://ns.adobe.com/xap/1.0/\0', 'latin1'), + Buffer.from('http://ns.adobe.com/xmp/extension/\0', 'latin1'), + ]) { + const withExactXmpIdentifier = Buffer.concat([ + SOURCE.subarray(0, -2), + Buffer.from([ + 0xff, + 0xe1, + ((identifier.length + 2) >> 8) & 0xff, + (identifier.length + 2) & 0xff, + ]), + identifier, + SOURCE.subarray(-2), + ]); + expect(inspectJpegStructure(withExactXmpIdentifier)).toMatchObject({ + status: 'passed', + hasXmp: true, + app1Count: 1, + }); + } + + const malformedManifest = structuredClone(FIXTURE_MANIFEST); + delete malformedManifest.schemaVersion; + delete malformedManifest.provenance.kind; + malformedManifest.width = 1; + const manifestReport = inspectFixtureManifest(malformedManifest, Buffer.alloc(0)); + expect(manifestReport.status).toBe('failed'); + expect(manifestReport.error).toContain('fixture manifest fields drifted'); + }); + + it('reports every malformed native payload boundary without accepting a PASS payload', () => { + const payload = validPayload(); + payload.extra = true; + payload.implementation = { name: 'other', extra: true }; + payload.schemaVersion = 2; + payload.scenarioId = 'other'; + payload.platform = 'web'; + payload.architecture = 'other'; + payload.jsEngine = 'other'; + payload.fixture = { + ...payload.fixture, + id: 'other', + sourceUri: '', + inspection: {}, + remainsAfterRun: false, + extra: true, + }; + payload.operation = null; + payload.timing = { + clock: 'other', + boundary: 'other', + warmupIterations: 0, + measuredIterations: 0, + extra: true, + }; + payload.capabilities = {}; + payload.samples = [ + { + phase: 'other', + iteration: 0, + elapsedMs: 0, + result: {}, + sourceToOutputByteDifference: 0, + outputInspection: {}, + cleanup: {}, + extra: true, + }, + ]; + payload.representative = { + measuredIteration: 0, + stagedOutputUri: '', + inspection: {}, + extra: true, + }; + payload.cleanup = { + attemptedPackageOutputs: 1, + removedPackageOutputs: 0, + residualPackageOutputs: 1, + residualPackageOutputBytes: 1, + extra: true, + }; + + const errors = inspectNativeEconomicResiliencePayload(payload); + expect(errors.length).toBeGreaterThan(20); + expect(errors.join(' | ')).toContain('native payload fields drifted'); + expect(errors.join(' | ')).toContain('capabilities do not satisfy the scenario'); + }); + + it('rejects invalid build identity, environment, fixture, output, and visual inputs together', () => { + const payload = structuredClone(validPayload()); + payload.platform = 'web'; + expect(() => + buildEconomicResilienceEvidence({ + payload, + packageVersion: 'not-semver', + sourceCommit: 'short', + runId: 0, + runAttempt: 0, + capturedAt: '1', + runUrl: 'https://example.com/run/1', + environment: {}, + fixtureManifest: {}, + sourceBytes: Buffer.alloc(0), + outputBytes: Buffer.alloc(0), + visualAgreement: {}, + }) + ).toThrow(/packageVersion|sourceCommit|runId|capturedAt|visual agreement/); + }); + + it('rejects a comprehensively drifted evidence object and unsafe artifact entries', () => { + const artifact = createArtifact(); + const evidence = structuredClone(artifact.evidence); + evidence.extra = true; + evidence.schemaVersion = 2; + evidence.status = 'failed'; + evidence.scenarioId = 'other'; + evidence.implementation = { name: 'other', version: 'latest', buildSource: 'registry', extra: true }; + evidence.sourceCommit = 'short'; + evidence.runId = 0; + evidence.runAttempt = 0; + evidence.capturedAt = '1'; + evidence.runUrl = 'https://example.com'; + evidence.environment = {}; + evidence.capabilities = {}; + evidence.fixture = {}; + evidence.operation = {}; + evidence.timing = {}; + evidence.samples = null; + evidence.measuredSummary = {}; + evidence.representative = {}; + evidence.economics = {}; + evidence.cleanup = {}; + evidence.visualAgreement = {}; + const report = inspectEconomicResilienceEvidence(artifact.root, evidence); + expect(report.status).toBe('failed'); + expect(report.error).toContain('evidence fields drifted'); + expect(report.error).toContain('evidence samples are required'); + + const linkedArtifact = createArtifact(); + const sourcePath = path.join(linkedArtifact.root, 'source.jpg'); + const externalSource = path.join(path.dirname(linkedArtifact.root), 'external-source.jpg'); + renameSync(sourcePath, externalSource); + symlinkSync(externalSource, sourcePath); + expect( + inspectEconomicResilienceEvidence(linkedArtifact.root, linkedArtifact.evidence).error + ).toMatch(/non-regular|non-symlink/); + + const invalidJsonArtifact = createArtifact(); + writeFileSync(path.join(invalidJsonArtifact.root, 'environment.json'), '{invalid'); + expect( + inspectEconomicResilienceEvidence(invalidJsonArtifact.root, invalidJsonArtifact.evidence).error + ).toContain('asset JSON is invalid: environment.json'); + + const fileRoot = path.join(path.dirname(artifact.root), 'not-a-directory'); + writeFileSync(fileRoot, 'file'); + expect(inspectEconomicResilienceEvidence(fileRoot, evidence).error).toContain( + 'artifact root must be a regular directory' + ); + }); + + it('rejects summary and embedded visual numbers that drift from bound raw evidence', () => { + for (const mutate of [ + (evidence) => { + evidence.measuredSummary.elapsedMs.median += 1; + }, + (evidence) => { + evidence.visualAgreement.uprightSimilarity -= 0.01; + }, + ]) { + const artifact = createArtifact(); + mutate(artifact.evidence); + rewriteEvidence(artifact); + expect(inspectEconomicResilienceEvidence(artifact.root, artifact.evidence).status) + .toBe('failed'); + } + }); + + it('rejects fixture and representative identity or geometry drift', () => { + for (const mutate of [ + (evidence) => { + evidence.fixture.id = 'other-fixture'; + }, + (evidence) => { + evidence.fixture.file = 'renamed.jpg'; + }, + (evidence) => { + evidence.fixture.manifestFile = 'other.json'; + }, + (evidence) => { + evidence.representative.file = 'other.jpg'; + }, + (evidence) => { + evidence.representative.width = 1599; + }, + ]) { + const artifact = createArtifact(); + mutate(artifact.evidence); + rewriteEvidence(artifact); + expect(inspectEconomicResilienceEvidence(artifact.root, artifact.evidence).status) + .toBe('failed'); + } + }); + + it('rejects capability extra fields, missing formats, type drift, and cancellation drift', () => { + for (const mutate of [ + (evidence) => { + evidence.capabilities.extra = true; + }, + (evidence) => { + evidence.capabilities.formats.pop(); + }, + (evidence) => { + evidence.capabilities.formats[1].format = 'jpeg'; + }, + (evidence) => { + evidence.capabilities.resourceLimits.maxSourcePixels = '48000000'; + }, + (evidence) => { + evidence.capabilities.supportsCancellation = false; + }, + ]) { + const artifact = createArtifact(); + mutate(artifact.evidence); + rewriteEvidence(artifact); + expect(inspectEconomicResilienceEvidence(artifact.root, artifact.evidence).status) + .toBe('failed'); + } + }); + + it('rejects empty, unknown, mistyped, and additional environment values', () => { + for (const mutate of [ + (environment) => { + environment.runtime = ''; + }, + (environment) => { + environment.osBuild = 42; + }, + (environment) => { + environment.runner.name = 'unknown'; + }, + (environment) => { + environment.toolchain.extra = 'drift'; + }, + ]) { + const artifact = createArtifact(); + mutate(artifact.evidence.environment); + rewriteEnvironment(artifact); + rewriteEvidence(artifact); + expect(inspectEconomicResilienceEvidence(artifact.root, artifact.evidence).status) + .toBe('failed'); + } + }); + + it('rejects a lifecycle claim when eleven package outputs remain', () => { + const artifact = createArtifact(); + artifact.evidence.cleanup.removedPackageOutputs = 1; + artifact.evidence.cleanup.residualPackageOutputs = 11; + artifact.evidence.cleanup.residualPackageOutputBytes = 11 * OUTPUT.length; + artifact.evidence.samples.slice(1).forEach((sample) => { + sample.cleanup.packageOutputRemoved = false; + sample.cleanup.existsAfterRemoval = true; + sample.cleanup.residualByteSize = OUTPUT.length; + }); + rewriteEvidence(artifact); + const report = inspectEconomicResilienceEvidence(artifact.root, artifact.evidence); + expect(report.status).toBe('failed'); + expect(report.error).toContain('zero residual'); + }); + + it('rejects extra assets and fixture metadata comments', () => { + const artifact = createArtifact(); + writeFileSync(path.join(artifact.root, 'unexpected.txt'), 'drift'); + expect(inspectEconomicResilienceEvidence(artifact.root, artifact.evidence).status) + .toBe('failed'); + + const commented = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xfe, 0x00, 0x09]), + Buffer.from('comment'), + SOURCE.subarray(2), + ]); + expect(inspectFixtureManifest(FIXTURE_MANIFEST, commented).error).toContain( + 'must not contain APP1, APP13, or JPEG comment metadata' + ); + + const postScanComment = Buffer.concat([ + SOURCE.subarray(0, -2), + Buffer.from([0xff, 0xfe, 0x00, 0x09]), + Buffer.from('comment'), + SOURCE.subarray(-2), + ]); + expect(inspectFixtureManifest(FIXTURE_MANIFEST, postScanComment).error).toContain( + 'must not contain APP1, APP13, or JPEG comment metadata' + ); + + const postScanExif = Buffer.concat([ + SOURCE.subarray(0, -2), + Buffer.from([0xff, 0xe1, 0x00, 0x08]), + Buffer.from('Exif\0\0'), + SOURCE.subarray(-2), + ]); + expect(inspectFixtureManifest(FIXTURE_MANIFEST, postScanExif).error).toContain( + 'must not contain APP1, APP13, or JPEG comment metadata' + ); + + const extendedXmpSignature = Buffer.from( + 'http://ns.adobe.com/xmp/extension/\0', + 'latin1' + ); + const extendedXmp = Buffer.concat([ + SOURCE.subarray(0, -2), + Buffer.from([ + 0xff, + 0xe1, + ((extendedXmpSignature.length + 2) >> 8) & 0xff, + (extendedXmpSignature.length + 2) & 0xff, + ]), + extendedXmpSignature, + SOURCE.subarray(-2), + ]); + expect(inspectFixtureManifest(FIXTURE_MANIFEST, extendedXmp).error).toContain( + 'must not contain APP1, APP13, or JPEG comment metadata' + ); + + const trailing = Buffer.concat([SOURCE, Buffer.from('trailing')]); + expect(inspectFixtureManifest(FIXTURE_MANIFEST, trailing).error).toContain( + 'JPEG contains bytes after EOI' + ); + + const driftedManifest = structuredClone(FIXTURE_MANIFEST); + driftedManifest.provenance.generator = 'other encoder'; + expect(inspectFixtureManifest(driftedManifest, SOURCE).error).toContain( + 'provenance drifted from the immutable generation record' + ); + }); + + it('the offline CLI replays decode, geometry, SSIM, and the flip control', () => { + const artifact = createReplayableArtifact(); + const valid = spawnSync( + process.execPath, + [ + VERIFIER, + '--artifact-dir', + artifact.root, + ], + { cwd: os.tmpdir(), encoding: 'utf8' } + ); + expect(valid.status, valid.stderr).toBe(0); + + const evidencePath = path.join( + artifact.root, + 'economic-resilience.json' + ); + const environmentPath = path.join(artifact.root, 'environment.json'); + const evidence = JSON.parse(readFileSync(evidencePath, 'utf8')); + evidence.environment.toolchain.ffmpeg = 'ffmpeg version different-build'; + evidence.environment.toolchain.ffprobe = 'ffprobe version different-build'; + writeFileSync( + environmentPath, + `${JSON.stringify(evidence.environment, null, 2)}\n` + ); + writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + const crossVersion = spawnSync( + process.execPath, + [VERIFIER, '--artifact-dir', artifact.root], + { cwd: os.tmpdir(), encoding: 'utf8' } + ); + expect(crossVersion.status, crossVersion.stderr).toBe(0); + expect(JSON.parse(crossVersion.stdout).replay).toMatchObject({ + status: 'passed', + ffmpegVersionsMatch: false, + ffprobeVersionsMatch: false, + measurementMode: 'portable-tolerance', + measurementTolerance: 0.001, + }); + + const forgedArtifact = createArtifact(); + const forged = spawnSync( + process.execPath, + [ + 'scripts/verify-economic-resilience-evidence.mjs', + '--artifact-dir', + forgedArtifact.root, + ], + { cwd: process.cwd(), encoding: 'utf8' } + ); + expect(forged.status).toBe(1); + expect(forged.stdout).toContain('visual replay failed'); + }, 30_000); + + it('keeps reports outside artifacts and publishes them without replacement', () => { + const artifact = createReplayableArtifact(); + const evidencePath = path.join(artifact.root, 'economic-resilience.json'); + const originalEvidence = readFileSync(evidencePath); + const parent = path.dirname(artifact.root); + const reportPath = path.join(parent, 'verification.json'); + + const valid = runVerifier(artifact.root, reportPath); + expect(valid.status, valid.stderr).toBe(0); + expect(readFileSync(reportPath, 'utf8')).toBe(valid.stdout); + expect(JSON.parse(valid.stdout).replay).toMatchObject({ + status: 'passed', + measurementMatch: true, + measurementMode: 'portable-tolerance', + measurementTolerance: 0.001, + outcomesPassed: true, + stableFieldsMatch: true, + }); + expect(readFileSync(evidencePath)).toEqual(originalEvidence); + + const inside = runVerifier(artifact.root, path.join(artifact.root, 'source.jpg')); + expect(inside.status).not.toBe(0); + expect(readFileSync(path.join(artifact.root, 'source.jpg'))).toEqual(SOURCE); + + const existing = path.join(parent, 'existing.json'); + writeFileSync(existing, 'keep'); + expect(runVerifier(artifact.root, existing).status).not.toBe(0); + expect(readFileSync(existing, 'utf8')).toBe('keep'); + + const linked = path.join(parent, 'linked.json'); + symlinkSync(existing, linked); + expect(runVerifier(artifact.root, linked).status).not.toBe(0); + expect(readFileSync(existing, 'utf8')).toBe('keep'); + + const artifactAlias = path.join(parent, 'artifact-alias'); + symlinkSync(artifact.root, artifactAlias); + expect(runVerifier(artifact.root, path.join(artifactAlias, 'report.json')).status) + .not.toBe(0); + expect(readFileSync(evidencePath)).toEqual(originalEvidence); + }, 30_000); + + it('preflights every artifact entry before parsing evidence JSON', () => { + const artifact = createArtifact(); + const evidencePath = path.join(artifact.root, 'economic-resilience.json'); + const external = path.join(path.dirname(artifact.root), 'external.json'); + renameSync(evidencePath, external); + symlinkSync(external, evidencePath); + writeFileSync(external, '{ definitely not valid JSON'); + + const result = spawnSync( + process.execPath, + [VERIFIER, '--artifact-dir', artifact.root], + { cwd: os.tmpdir(), encoding: 'utf8' } + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('regular non-symlink file: economic-resilience.json'); + expect(result.stderr).not.toContain('SyntaxError'); + }); + + it('writes exact environment bytes atomically and refuses linked inputs or outputs', () => { + const parent = mkdtempSync(path.join(os.tmpdir(), 'rnick-economic-environment-')); + roots.push(parent); + const log = path.join(parent, 'native.log'); + writeFileSync(log, `${createChunkedNativeLogMessages( + validPayload(), + 'economic-environment-test', + { + chunk: 'RNICK_ECONOMIC_RESILIENCE_CHUNK', + pass: 'RNICK_ECONOMIC_RESILIENCE_PASS', + } + ).join('\n')}\n`); + const output = path.join(parent, 'environment.json'); + const args = environmentArgs(log, output); + const created = spawnSync(process.execPath, args, { encoding: 'utf8' }); + expect(created.status, created.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, 'utf8'))).toEqual(validEnvironment()); + + const existing = spawnSync(process.execPath, args, { encoding: 'utf8' }); + expect(existing.status).not.toBe(0); + expect(JSON.parse(readFileSync(output, 'utf8'))).toEqual(validEnvironment()); + + const linkedLog = path.join(parent, 'linked.log'); + symlinkSync(log, linkedLog); + const linkedInput = spawnSync( + process.execPath, + environmentArgs(linkedLog, path.join(parent, 'linked-input.json')), + { encoding: 'utf8' } + ); + expect(linkedInput.status).not.toBe(0); + + const outputLink = path.join(parent, 'output-link.json'); + symlinkSync(output, outputLink); + const linkedOutput = spawnSync( + process.execPath, + environmentArgs(log, outputLink), + { encoding: 'utf8' } + ); + expect(linkedOutput.status).not.toBe(0); + expect(JSON.parse(readFileSync(output, 'utf8'))).toEqual(validEnvironment()); + + const linkedParent = path.join(parent, 'linked-parent'); + const realParent = path.join(parent, 'real-parent'); + mkdirSync(realParent); + symlinkSync(realParent, linkedParent); + const linkedParentOutput = spawnSync( + process.execPath, + environmentArgs(log, path.join(linkedParent, 'environment.json')), + { encoding: 'utf8' } + ); + expect(linkedParentOutput.status).not.toBe(0); + expect(() => readFileSync(path.join(realParent, 'environment.json'))).toThrow(); + }); + + it('builds transactionally through an aliased ancestor and leaves no root on validation failure', () => { + const parent = mkdtempSync(path.join(os.tmpdir(), 'rnick-economic-builder-')); + roots.push(parent); + const actual = path.join(parent, 'actual-inputs'); + const alias = path.join(parent, 'aliased-inputs'); + mkdirSync(actual); + symlinkSync(actual, alias); + const payload = validPayload(); + const visualAgreement = createDemoVisualAgreementReport({ + sourceBytes: SOURCE, + outputBytes: OUTPUT, + sourceWidth: 4000, + sourceHeight: 3000, + width: 1600, + height: 1200, + resizeMode: 'contain', + maxWidth: 1600, + maxHeight: 1200, + uprightSimilarity: 0.95, + verticalFlipSimilarity: 0.5, + comparisonProfile: PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, + sourceColorRange: 'pc', + outputColorRange: 'pc', + }); + const files = { + log: 'native.log', + source: 'source.jpg', + output: 'output.jpg', + manifest: 'fixture.json', + visual: 'visual.json', + environment: 'environment.json', + }; + writeFileSync( + path.join(actual, files.log), + `${createChunkedNativeLogMessages(payload, 'economic-builder-test', { + chunk: 'RNICK_ECONOMIC_RESILIENCE_CHUNK', + pass: 'RNICK_ECONOMIC_RESILIENCE_PASS', + }).join('\n')}\n` + ); + writeFileSync(path.join(actual, files.source), SOURCE); + writeFileSync(path.join(actual, files.output), OUTPUT); + writeFileSync(path.join(actual, files.manifest), `${JSON.stringify(FIXTURE_MANIFEST)}\n`); + writeFileSync(path.join(actual, files.visual), `${JSON.stringify(visualAgreement)}\n`); + writeFileSync(path.join(actual, files.environment), `${JSON.stringify(validEnvironment())}\n`); + + const destination = path.join(parent, 'built'); + const result = spawnSync( + process.execPath, + builderArgs(alias, files, destination, '0.4.1'), + { encoding: 'utf8' } + ); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(path.join(destination, 'economic-resilience', 'economic-resilience.json'))) + .toBe(true); + + const failedDestination = path.join(parent, 'must-not-remain'); + const failed = spawnSync( + process.execPath, + builderArgs(alias, files, failedDestination, 'not-semver'), + { encoding: 'utf8' } + ); + expect(failed.status).not.toBe(0); + expect(existsSync(failedDestination)).toBe(false); + }); +}); + +function createArtifact() { + const parent = mkdtempSync(path.join(os.tmpdir(), 'rnick-economic-evidence-')); + roots.push(parent); + const root = path.join(parent, 'economic-resilience'); + mkdirSync(root); + const payload = validPayload(); + const environment = validEnvironment(); + const visualAgreement = createDemoVisualAgreementReport({ + sourceBytes: SOURCE, + outputBytes: OUTPUT, + sourceWidth: 4000, + sourceHeight: 3000, + width: 1600, + height: 1200, + resizeMode: 'contain', + maxWidth: 1600, + maxHeight: 1200, + uprightSimilarity: 0.95, + verticalFlipSimilarity: 0.5, + comparisonProfile: PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, + sourceColorRange: 'pc', + outputColorRange: 'pc', + }); + const evidence = buildEconomicResilienceEvidence({ + payload, + packageVersion: '0.4.1', + sourceCommit: '1'.repeat(40), + runId: 31_674_626_714, + runAttempt: 1, + capturedAt: '2026-08-13T07:00:00.000Z', + runUrl: + 'https://github.com/GGULBAE/react-native-image-compression-kit/actions/runs/31674626714', + environment, + fixtureManifest: FIXTURE_MANIFEST, + sourceBytes: SOURCE, + outputBytes: OUTPUT, + visualAgreement, + }); + writeFileSync(path.join(root, 'source.jpg'), SOURCE); + writeFileSync(path.join(root, 'output.jpg'), OUTPUT); + writeFileSync( + path.join(root, 'fixture-manifest.json'), + `${JSON.stringify(FIXTURE_MANIFEST, null, 2)}\n` + ); + writeFileSync( + path.join(root, 'visual-agreement.json'), + `${JSON.stringify(visualAgreement, null, 2)}\n` + ); + writeFileSync( + path.join(root, 'environment.json'), + `${JSON.stringify(environment, null, 2)}\n` + ); + writeFileSync( + path.join(root, 'economic-resilience.json'), + `${JSON.stringify(evidence, null, 2)}\n` + ); + return { root, evidence }; +} + +function validPayload() { + const inspection = { + exists: true, + byteSize: OUTPUT.length, + sha256: OUTPUT_SHA, + mediaType: 'image/jpeg', + width: 1600, + height: 1200, + }; + const samples = Array.from({ length: 12 }, (_, index) => ({ + phase: index < 2 ? 'warmup' : 'measured', + iteration: index < 2 ? index + 1 : index - 1, + elapsedMs: index + 1, + result: { + format: 'jpeg', + width: 1600, + height: 1200, + byteSize: OUTPUT.length, + originalByteSize: SOURCE.length, + compressionRatio: OUTPUT.length / SOURCE.length, + }, + sourceToOutputByteDifference: SOURCE.length - OUTPUT.length, + outputInspection: { ...inspection }, + cleanup: { + packageOutputRemoved: true, + existsAfterRemoval: false, + residualByteSize: 0, + }, + })); + return { + schemaVersion: 1, + scenarioId: 'kit-only-12mp-jpeg-v1', + implementation: { name: 'react-native-image-compression-kit' }, + platform: 'android', + architecture: 'new', + jsEngine: 'hermes', + fixture: { + ...ECONOMIC_RESILIENCE_FIXTURE, + sourceUri: 'file:///cache/source.jpg', + inspection: { + exists: true, + byteSize: SOURCE.length, + sha256: sha256(SOURCE), + mediaType: 'image/jpeg', + width: 4000, + height: 3000, + }, + remainsAfterRun: true, + }, + operation: ECONOMIC_RESILIENCE_OPERATION, + capabilities: validCapabilities(), + timing: { + clock: 'performance.now', + boundary: 'compressImage-call-only', + warmupIterations: 2, + measuredIterations: 10, + }, + representative: { + measuredIteration: 10, + stagedOutputUri: 'file:///cache/staged-output.jpg', + inspection: { ...inspection }, + }, + samples, + cleanup: { + attemptedPackageOutputs: 12, + removedPackageOutputs: 12, + residualPackageOutputs: 0, + residualPackageOutputBytes: 0, + }, + }; +} + +function validCapabilities() { + return { + platform: 'android', + formats: ['jpeg', 'png', 'webp', 'heic', 'heif', 'avif', 'gif'].map( + (format) => ({ + format, + input: true, + output: ['jpeg', 'png', 'webp'].includes(format), + supportsAlpha: format !== 'jpeg', + supportsAnimation: false, + notes: [`${format} exact runtime note`], + }) + ), + metadataPolicies: ['preserve', 'safe', 'strip'], + supportsTargetSizeCompression: true, + supportsCancellation: true, + maxConcurrentOperations: 2, + supportsDecodeDownsampling: true, + resourceLimits: { + maxSourceDimension: 16_384, + maxSourcePixels: 48_000_000, + maxWorkingPixels: 16_000_000, + }, + }; +} + +function validEnvironment() { + return { + platform: 'android', + runtime: 'Android 15 / API 35', + osBuild: 'AP3A.241105.008', + device: 'Google sdk_gphone64_x86_64', + deviceKind: 'emulator', + abi: 'x86_64', + reactNativeArchitecture: 'new', + reactNativeVersion: '0.86.2', + jsEngine: 'hermes', + buildType: 'debug', + runner: { + label: 'ubuntu-latest', + os: 'Linux', + arch: 'X64', + name: 'GitHub Actions 123', + imageOS: 'ubuntu24', + imageVersion: '20260810.1', + }, + toolchain: { + node: 'v24.18.0', + ffmpeg: 'ffmpeg version 7.1.1', + ffprobe: 'ffprobe version 7.1.1', + primary: 'openjdk version 21.0.8', + platformSdk: + 'Android compile SDK 36; emulator API 35; build-tools 36.0.0; NDK 27.1.12297006', + }, + }; +} + +function createReplayableArtifact() { + const parent = mkdtempSync(path.join(os.tmpdir(), 'rnick-economic-replay-')); + roots.push(parent); + const output = path.join(parent, 'output.jpg'); + mustRun('ffmpeg', [ + '-hide_banner', + '-loglevel', 'error', + '-i', 'example/fixtures/kit-only-12mp-v1.jpg', + '-vf', 'scale=1600:1200:flags=lanczos', + '-frames:v', '1', + '-c:v', 'mjpeg', + '-q:v', '2', + '-pix_fmt', 'yuvj420p', + '-map_metadata', '-1', + '-flags', '+bitexact', + '-fflags', '+bitexact', + output, + ]); + const outputBytes = readFileSync(output); + const visualPath = path.join(parent, 'visual.json'); + mustRun(process.execPath, [ + 'scripts/measure-demo-visual-agreement.mjs', + '--source', 'example/fixtures/kit-only-12mp-v1.jpg', + '--output', output, + '--resize-mode', 'contain', + '--max-width', '1600', + '--max-height', '1200', + '--comparison-profile', PORTABLE_DEMO_VISUAL_AGREEMENT_PROFILE, + '--report', visualPath, + ]); + const visualAgreement = JSON.parse(readFileSync(visualPath, 'utf8')); + const payload = validPayloadForOutput(outputBytes); + const environment = validEnvironment(); + environment.toolchain.ffmpeg = firstLine( + mustRun('ffmpeg', ['-version'], { encoding: 'utf8' }).stdout + ); + environment.toolchain.ffprobe = firstLine( + mustRun('ffprobe', ['-version'], { encoding: 'utf8' }).stdout + ); + const evidence = buildEconomicResilienceEvidence({ + payload, + packageVersion: '0.4.1', + sourceCommit: '1'.repeat(40), + runId: 31_674_626_714, + runAttempt: 1, + capturedAt: '2026-08-13T07:00:00.000Z', + runUrl: + 'https://github.com/GGULBAE/react-native-image-compression-kit/actions/runs/31674626714', + environment, + fixtureManifest: FIXTURE_MANIFEST, + sourceBytes: SOURCE, + outputBytes, + visualAgreement, + }); + const root = path.join(parent, 'economic-resilience'); + mkdirSync(root); + cpSync('example/fixtures/kit-only-12mp-v1.jpg', path.join(root, 'source.jpg')); + cpSync(output, path.join(root, 'output.jpg')); + writeFileSync(path.join(root, 'fixture-manifest.json'), `${JSON.stringify(FIXTURE_MANIFEST, null, 2)}\n`); + writeFileSync(path.join(root, 'visual-agreement.json'), `${JSON.stringify(visualAgreement, null, 2)}\n`); + writeFileSync(path.join(root, 'environment.json'), `${JSON.stringify(environment, null, 2)}\n`); + writeFileSync(path.join(root, 'economic-resilience.json'), `${JSON.stringify(evidence, null, 2)}\n`); + return { root }; +} + +function validPayloadForOutput(outputBytes) { + const payload = validPayload(); + const inspection = { + exists: true, + byteSize: outputBytes.length, + sha256: sha256(outputBytes), + mediaType: 'image/jpeg', + width: 1600, + height: 1200, + }; + payload.samples.forEach((sample) => { + sample.result.byteSize = outputBytes.length; + sample.result.compressionRatio = outputBytes.length / SOURCE.length; + sample.sourceToOutputByteDifference = SOURCE.length - outputBytes.length; + sample.outputInspection = { ...inspection }; + }); + payload.representative.inspection = { ...inspection }; + return payload; +} + +function mustRun(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: 'utf8', ...options }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result; +} + +function runVerifier(artifactDir, reportFile) { + return spawnSync( + process.execPath, + [ + VERIFIER, + '--artifact-dir', + artifactDir, + '--report-file', + reportFile, + ], + { cwd: os.tmpdir(), encoding: 'utf8' } + ); +} + +function environmentArgs(nativeLog, output) { + const environment = validEnvironment(); + return [ + path.resolve('scripts/create-economic-resilience-environment.mjs'), + '--platform', environment.platform, + '--runtime', environment.runtime, + '--os-build', environment.osBuild, + '--device', environment.device, + '--device-kind', environment.deviceKind, + '--abi', environment.abi, + '--react-native-version', environment.reactNativeVersion, + '--native-log', nativeLog, + '--build-type', environment.buildType, + '--runner-label', environment.runner.label, + '--runner-os', environment.runner.os, + '--runner-arch', environment.runner.arch, + '--runner-name', environment.runner.name, + '--image-os', environment.runner.imageOS, + '--image-version', environment.runner.imageVersion, + '--node', environment.toolchain.node, + '--ffmpeg', environment.toolchain.ffmpeg, + '--ffprobe', environment.toolchain.ffprobe, + '--primary-toolchain', environment.toolchain.primary, + '--platform-sdk', environment.toolchain.platformSdk, + '--output', output, + ]; +} + +function builderArgs(root, files, destination, packageVersion) { + return [ + path.resolve('scripts/create-economic-resilience-evidence.mjs'), + '--platform', 'android', + '--package-version', packageVersion, + '--source-sha', '1'.repeat(40), + '--run-id', '31674626714', + '--run-attempt', '1', + '--run-url', + 'https://github.com/GGULBAE/react-native-image-compression-kit/actions/runs/31674626714', + '--captured-at', '2026-08-13T07:00:00.000Z', + '--log', path.join(root, files.log), + '--source', path.join(root, files.source), + '--output', path.join(root, files.output), + '--fixture-manifest', path.join(root, files.manifest), + '--visual-agreement', path.join(root, files.visual), + '--environment', path.join(root, files.environment), + '--destination', destination, + ]; +} + +function firstLine(value) { + return String(value).split(/\r?\n/, 1)[0]; +} + +function minimalJpeg(width, height) { + return Buffer.from([ + 0xff, 0xd8, + 0xff, 0xc0, 0x00, 0x11, 0x08, + (height >> 8) & 0xff, height & 0xff, + (width >> 8) & 0xff, width & 0xff, + 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, + 0xff, 0xda, 0x00, 0x0c, + 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x3f, 0x00, + 0xff, 0xd9, + ]); +} + +function rewriteEvidence({ root, evidence }) { + writeFileSync( + path.join(root, 'economic-resilience.json'), + `${JSON.stringify(evidence, null, 2)}\n` + ); +} + +function rewriteEnvironment({ root, evidence }) { + writeFileSync( + path.join(root, 'environment.json'), + `${JSON.stringify(evidence.environment, null, 2)}\n` + ); +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/test/economicResilienceNativeSourceContract.test.mjs b/test/economicResilienceNativeSourceContract.test.mjs new file mode 100644 index 0000000..dd8a591 --- /dev/null +++ b/test/economicResilienceNativeSourceContract.test.mjs @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const android = readFileSync( + 'example/android/app/src/main/java/com/imagecompressionkit/example/ExampleImageSourceModule.kt', + 'utf8' +); +const androidBuild = readFileSync('example/android/app/build.gradle', 'utf8'); +const ios = readFileSync( + 'example/ios/ImageCompressionKitExample/ExampleImageSource.m', + 'utf8' +); +const project = readFileSync( + 'example/ios/ImageCompressionKitExample.xcodeproj/project.pbxproj', + 'utf8' +); + +describe('12 MP example native fixture bridge contract', () => { + it('bundles the same repository fixture into both native examples', () => { + expect(androidBuild).toContain('main.assets.srcDirs += ["../../fixtures"]'); + expect(project.match(/kit-only-12mp-v1\.jpg/g)?.length).toBeGreaterThanOrEqual(3); + expect(android).toContain('assets.open(assetName)'); + expect(ios).toContain('pathForResource:@"kit-only-12mp-v1" ofType:@"jpg"'); + }); + + it('rejects outside-cache and linked inputs before inspection or staging', () => { + expect(android).toContain('val requestedStatus = lstatOrNull(requestedFile)'); + expect(android).toContain( + 'requestedStatus != null && !OsConstants.S_ISREG(requestedStatus.st_mode)' + ); + expect(android).toContain('!file.path.startsWith(cachePrefix)'); + expect(android).toContain('!uri.authority.isNullOrEmpty()'); + expect(android).toContain('uri.query != null'); + expect(android).toContain('uri.fragment != null'); + expect(android).toContain('uri.isOpaque'); + expect(android).toContain('Os.lstat(file.absolutePath)'); + expect(android).toContain('OsConstants.S_ISREG(status.st_mode)'); + expect(android).not.toContain('java.nio.file.Files'); + expect(android).toContain('OsConstants.ENOENT'); + expect(ios).toContain('standardizedPath.stringByDeletingLastPathComponent'); + expect(ios).toContain('standardizedParent.stringByResolvingSymlinksInPath'); + expect(ios).toContain('standardizedPath.lastPathComponent'); + expect(ios).toContain('ExampleImageSourcePathIsRegularNonSymlink(path)'); + expect(ios).toContain('lstat(path.fileSystemRepresentation, &status)'); + expect(ios).toContain('URL.host.length > 0'); + expect(ios.indexOf('!ExampleImageSourcePathIsRegularNonSymlink(path)')).toBeLessThan( + ios.indexOf('NSData *data = [NSData dataWithContentsOfFile:path') + ); + }); + + it('refuses unsafe fixed destinations instead of following a symlink', () => { + expect(android).toContain('removeExistingRegularDestination(outputFile)'); + expect(android).toContain('File.createTempFile(".rnick-evidence-", ".tmp", directory)'); + expect(android).toContain('Os.rename(temporary.absolutePath, destination.absolutePath)'); + expect(android).toContain('Evidence destination must be a removable regular file.'); + expect(android).toContain('Sample image cache directory must not be linked.'); + expect(ios).toContain('The iOS example refused an unsafe evidence destination.'); + expect(ios).toContain('The iOS example refused a linked evidence destination.'); + }); +}); diff --git a/test/ios-native/RCTImageCompressionJpegMetadataTests.mm b/test/ios-native/RCTImageCompressionJpegMetadataTests.mm index 10d7f12..8f6a098 100644 --- a/test/ios-native/RCTImageCompressionJpegMetadataTests.mm +++ b/test/ios-native/RCTImageCompressionJpegMetadataTests.mm @@ -160,6 +160,12 @@ static void TestReadsSourcePropertiesOnlyForSupportedPreserve(void) RCTImageCompressionJpegMetadataResult *preserve = [metadata prepareRequest:preserveRequest error:nil]; RCTMetadataAssert(preserve.preservingSourceMetadata, @"supported preserve marks result as preserving"); + RCTMetadataAssertEqualObjects( + preserve.metadataPolicy, + RCTImageCompressionKitPreserveMetadataPolicy, + @"supported preserve retains its policy" + ); + RCTMetadataAssert(!preserve.stripRequested, @"supported preserve does not request stripping"); RCTMetadataAssertEqualObjects(preserve.sourceProperties, expectedProperties, @"supported preserve retains reader properties"); RCTMetadataAssertEqualObjects(receivedSource, expectedSource, @"reader receives immutable request source bytes"); @@ -169,6 +175,11 @@ static void TestReadsSourcePropertiesOnlyForSupportedPreserve(void) error:nil ]; RCTMetadataAssert(!result.preservingSourceMetadata, @"safe and strip do not preserve source metadata"); + RCTMetadataAssertEqualObjects(result.metadataPolicy, policy, @"safe and strip retain the selected policy"); + RCTMetadataAssert( + result.stripRequested == [policy isEqualToString:RCTImageCompressionKitStripMetadataPolicy], + @"only strip requests marker sanitization" + ); RCTMetadataAssert(result.sourceProperties == nil, @"safe and strip expose no source properties"); } RCTMetadataAssert(readerCalls == 1, @"only supported preserve reads source properties"); @@ -224,6 +235,12 @@ static void TestNormalizesPreservedMetadataWithoutMutatingSource(void) initWithPreservingSourceMetadata:YES sourceProperties:source ]; + RCTMetadataAssertEqualObjects( + result.metadataPolicy, + RCTImageCompressionKitPreserveMetadataPolicy, + @"legacy preserving initializer maps to preserve policy" + ); + RCTMetadataAssert(!result.stripRequested, @"legacy preserving initializer does not strip"); NSDictionary *properties = [result destinationPropertiesForQuality:85 pixelWidth:640 diff --git a/test/ios-native/RCTImageCompressionJpegSegmentSanitizerTests.mm b/test/ios-native/RCTImageCompressionJpegSegmentSanitizerTests.mm new file mode 100644 index 0000000..0e543c0 --- /dev/null +++ b/test/ios-native/RCTImageCompressionJpegSegmentSanitizerTests.mm @@ -0,0 +1,241 @@ +#import + +#import "RCTImageCompressionJpegSegmentSanitizer.h" + +#include + +static NSUInteger RCTJpegSanitizerAssertionCount = 0; +static NSUInteger RCTJpegSanitizerFailureCount = 0; + +static void RCTJpegSanitizerAssert(BOOL condition, NSString *context) +{ + RCTJpegSanitizerAssertionCount += 1; + if (!condition) { + RCTJpegSanitizerFailureCount += 1; + fprintf(stderr, "FAIL: %s\n", context.UTF8String); + } +} + +static NSData *RCTJpegSanitizerData(std::initializer_list bytes) +{ + return [NSData dataWithBytes:bytes.begin() length:bytes.size()]; +} + +static void RCTJpegSanitizerAssertEqualData( + NSData *actual, + NSData *expected, + NSString *context +) { + RCTJpegSanitizerAssert( + actual != nil && [actual isEqualToData:expected], + [NSString stringWithFormat: + @"%@ (actual=%@ expected=%@)", + context, + actual, + expected + ] + ); +} + +static NSData *RCTMetadataRichMultiScanJpeg(void) +{ + return RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xe0, 0x00, 0x04, 0x10, 0x11, + 0xff, 0xe1, 0x00, 0x05, 0x20, 0x21, 0x22, + 0xff, 0xe2, 0x00, 0x05, 0x49, 0x43, 0x43, + 0xff, 0xed, 0x00, 0x03, 0x30, + 0xff, 0xfe, 0x00, 0x04, 0x40, 0x41, + 0xff, 0xdb, 0x00, 0x04, 0x50, 0x51, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0xa1, 0xff, 0x00, 0xa2, 0xff, 0xd0, 0xa3, + 0xff, 0xe1, 0x00, 0x04, 0x60, 0x61, + 0xff, 0xc4, 0x00, 0x04, 0x70, 0x71, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0xb1, 0xff, 0xd7, 0xb2, + 0xff, 0xed, 0x00, 0x03, 0x80, + 0xff, 0xfe, 0x00, 0x03, 0x81, + 0xff, 0xd9, + }); +} + +static void TestRemovesMetadataBeforeAndBetweenScans(void) +{ + NSData *actual = [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:RCTMetadataRichMultiScanJpeg() + stripRequested:YES + ]; + NSData *expected = RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xe0, 0x00, 0x04, 0x10, 0x11, + 0xff, 0xe2, 0x00, 0x05, 0x49, 0x43, 0x43, + 0xff, 0xdb, 0x00, 0x04, 0x50, 0x51, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0xa1, 0xff, 0x00, 0xa2, 0xff, 0xd0, 0xa3, + 0xff, 0xc4, 0x00, 0x04, 0x70, 0x71, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0xb1, 0xff, 0xd7, 0xb2, + 0xff, 0xd9, + }); + RCTJpegSanitizerAssertEqualData( + actual, + expected, + @"removes APP1, APP13, and COM around multiple scans" + ); +} + +static void TestPreservesStuffedRestartAndNonSensitiveSegments(void) +{ + NSData *input = RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xff, 0xe0, 0x00, 0x02, + 0xff, 0xe2, 0x00, 0x04, 0x11, 0x22, + 0xff, 0xec, 0x00, 0x04, 0x33, 0x44, + 0xff, 0x01, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0x55, 0xff, 0x00, 0x66, 0xff, 0xd1, 0x77, 0xff, 0x01, 0x88, + 0xff, 0xdc, 0x00, 0x04, 0x00, 0x08, 0x99, + 0xff, 0xff, 0xd9, + }); + NSData *actual = [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:input + stripRequested:YES + ]; + RCTJpegSanitizerAssertEqualData( + actual, + input, + @"preserves APP0, ICC/APP2, other APP markers, fill, TEM, DNL, stuffed bytes, and restart markers" + ); +} + +static void TestBypassesSafeAndPreserveOutputs(void) +{ + NSData *input = RCTMetadataRichMultiScanJpeg(); + NSData *safe = [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:input + stripRequested:NO + ]; + NSData *preserve = [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:input + stripRequested:NO + ]; + RCTJpegSanitizerAssertEqualData(safe, input, @"safe output bytes remain unchanged"); + RCTJpegSanitizerAssertEqualData( + preserve, + input, + @"preserve output bytes remain unchanged" + ); +} + +static void TestRejectsMalformedHeadersAndSegmentLengths(void) +{ + NSArray *invalid = @[ + [NSData data], + RCTJpegSanitizerData({0xff, 0xd8}), + RCTJpegSanitizerData({0x00, 0xd8, 0xff, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0x00, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0x00, 0xff, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0x02, 0x00, 0x02, 0xff, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xd8, 0xff, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xd0, 0xff, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xe1}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xe1, 0x00}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xe1, 0x00, 0x01, 0xff, 0xd9}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xe1, 0x00, 0x05, 0x11, 0xff, 0xd9}), + RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xdc, 0x00, 0x04, 0x00, 0x08, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0xff, 0xd9, + }), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9}), + RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xda, 0x00, 0x08, 0x00, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0xff, 0xd9, + }), + RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xda, 0x00, 0x0a, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0x00, 0x00, + 0xff, 0xd9, + }), + RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xda, 0x00, 0x10, 0x05, + 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00, 0x05, 0x00, + 0x00, 0x3f, 0x00, + 0xff, 0xd9, + }), + RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0x11, + 0xff, 0xdc, 0x00, 0x03, 0x00, + 0xff, 0xd9, + }), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xd9}), + ]; + for (NSUInteger index = 0; index < invalid.count; index += 1) { + RCTJpegSanitizerAssert( + [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:invalid[index] + stripRequested:YES + ] == nil, + [NSString stringWithFormat:@"rejects malformed header/segment case %lu", (unsigned long)index] + ); + } +} + +static void TestRejectsMalformedScanTermination(void) +{ + NSArray *invalid = @[ + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x11}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x11, 0xff}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x11, 0xff, 0xd8}), + RCTJpegSanitizerData({0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x11, 0xff, 0xd9, 0x00}), + RCTJpegSanitizerData({ + 0xff, 0xd8, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, + 0x11, + 0xff, 0xe1, 0x00, 0x04, 0x22, 0x33, + 0x44, + 0xff, 0xd9, + }), + ]; + for (NSUInteger index = 0; index < invalid.count; index += 1) { + RCTJpegSanitizerAssert( + [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:invalid[index] + stripRequested:YES + ] == nil, + [NSString stringWithFormat:@"rejects malformed scan/termination case %lu", (unsigned long)index] + ); + } +} + +int main(void) +{ + @autoreleasepool { + TestRemovesMetadataBeforeAndBetweenScans(); + TestPreservesStuffedRestartAndNonSensitiveSegments(); + TestBypassesSafeAndPreserveOutputs(); + TestRejectsMalformedHeadersAndSegmentLengths(); + TestRejectsMalformedScanTermination(); + if (RCTJpegSanitizerFailureCount > 0) { + fprintf( + stderr, + "iOS JPEG segment sanitizer tests failed: %lu/%lu assertions failed.\n", + (unsigned long)RCTJpegSanitizerFailureCount, + (unsigned long)RCTJpegSanitizerAssertionCount + ); + return 1; + } + printf( + "iOS JPEG segment sanitizer tests passed: %lu assertions across 5 groups.\n", + (unsigned long)RCTJpegSanitizerAssertionCount + ); + } + return 0; +} diff --git a/test/ios-native/RCTImageCompressionLargeImageTests.mm b/test/ios-native/RCTImageCompressionLargeImageTests.mm index 9bfed19..fcb6c94 100644 --- a/test/ios-native/RCTImageCompressionLargeImageTests.mm +++ b/test/ios-native/RCTImageCompressionLargeImageTests.mm @@ -2,6 +2,7 @@ #import #import "RCTImageCompressionImageEncoder.h" +#import "RCTImageCompressionJpegSegmentSanitizer.h" #import "RCTImageCompressionOutput.h" #import "RCTImageCompressionPipeline.h" @@ -17,6 +18,14 @@ static void RCTLargeAssert(BOOL condition, NSString *message) } } +static void RCTLargeAssertEqualObjects(id actual, id expected, NSString *message) +{ + RCTLargeAssert( + actual == expected || [actual isEqual:expected], + [NSString stringWithFormat:@"%@ (actual=%@ expected=%@)", message, actual, expected] + ); +} + static NSData *RCTEncodeImage(CGImageRef image, NSString *type, NSDictionary *properties) { NSMutableData *data = [NSMutableData data]; @@ -122,6 +131,9 @@ static void RCTLargeAssert(BOOL condition, NSString *message) @{ (__bridge NSString *)kCGImageDestinationLossyCompressionQuality : @0.92, (__bridge NSString *)kCGImagePropertyOrientation : @(orientation), + (__bridge NSString *)kCGImagePropertyTIFFDictionary : @{ + (__bridge NSString *)kCGImagePropertyTIFFArtist : @"metadata-test-artist", + }, } ); CGImageRelease(image); @@ -198,16 +210,17 @@ static CGFloat RCTMeanAbsoluteRgbDifference(NSData *left, NSData *right) return path; } -static RCTImageCompressionPipelineResult *RCTCompress( +static RCTImageCompressionPipelineResult *RCTCompressWithMetadata( NSString *sourcePath, NSString *format, NSDictionary *resize, + NSString *metadataPolicy, RCTImageCompressionPipelineError **error ) { NSMutableDictionary *options = [@{ @"source" : @{ @"uri" : [NSURL fileURLWithPath:sourcePath].absoluteString }, @"output" : @{ @"format" : format, @"quality" : @82 }, - @"metadata" : @"safe", + @"metadata" : metadataPolicy, } mutableCopy]; if (resize != nil) options[@"resize"] = resize; RCTImageCompressionPipeline *pipeline = [RCTImageCompressionPipeline defaultPipeline]; @@ -217,6 +230,21 @@ static CGFloat RCTMeanAbsoluteRgbDifference(NSData *left, NSData *right) ]; } +static RCTImageCompressionPipelineResult *RCTCompress( + NSString *sourcePath, + NSString *format, + NSDictionary *resize, + RCTImageCompressionPipelineError **error +) { + return RCTCompressWithMetadata( + sourcePath, + format, + resize, + @"safe", + error + ); +} + static NSArray *RCTCenterPixel(NSString *outputURI) { NSURL *URL = [NSURL URLWithString:outputURI]; @@ -378,6 +406,157 @@ static void TestExifOrientationMatrixPreservesDisplayedLayout(void) } } +static void TestStripSanitizesJpegWithoutChangingGeometry(void) +{ + NSData *jpeg = RCTCreateOrientedQuadrantJpeg(6); + NSString *sourcePath = RCTWriteFixture(jpeg, @"jpg"); + CGImageRef expected = RCTCreateUprightThumbnail(jpeg); + RCTLargeAssert(expected != nil, @"strip integration creates upright reference"); + + RCTImageCompressionPipelineError *stripError = nil; + RCTImageCompressionPipelineResult *stripResult = RCTCompressWithMetadata( + sourcePath, + @"jpeg", + nil, + @"strip", + &stripError + ); + RCTLargeAssert( + stripResult != nil && stripError == nil, + @"strip integration compresses oriented JPEG" + ); + NSURL *stripURL = stripResult == nil + ? nil + : [NSURL URLWithString:stripResult.outputResult.uri]; + NSData *stripData = stripURL == nil ? nil : [NSData dataWithContentsOfURL:stripURL]; + NSData *resanitizedStripData = stripData == nil + ? nil + : [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:stripData + stripRequested:YES + ]; + RCTLargeAssert( + stripData.length > 0 && [stripData isEqualToData:resanitizedStripData], + @"strip output is a strict JPEG with no remaining APP1, APP13, or COM segments" + ); + RCTLargeAssert( + stripResult.outputResult.byteSize == stripData.length, + @"strip result metrics and persisted bytes use the sanitized JPEG" + ); + + CGImageSourceRef stripSource = stripData == nil + ? nil + : CGImageSourceCreateWithData((__bridge CFDataRef)stripData, nil); + CGImageRef stripImage = stripSource == nil + ? nil + : CGImageSourceCreateImageAtIndex(stripSource, 0, nil); + NSDictionary *stripProperties = stripSource == nil + ? nil + : CFBridgingRelease(CGImageSourceCopyPropertiesAtIndex(stripSource, 0, nil)); + if (stripSource != nil) CFRelease(stripSource); + RCTLargeAssert(stripImage != nil, @"strict strip output decodes"); + NSDictionary *stripTiff = stripProperties[ + (__bridge NSString *)kCGImagePropertyTIFFDictionary + ]; + RCTLargeAssert( + stripTiff[(__bridge NSString *)kCGImagePropertyTIFFArtist] == nil, + @"strip output removes source TIFF artist metadata" + ); + NSInteger stripOrientation = [stripProperties[ + (__bridge NSString *)kCGImagePropertyOrientation + ] integerValue]; + RCTLargeAssert( + stripOrientation == 0 || stripOrientation == 1, + @"strip output keeps normalized orientation metadata" + ); + + if (expected != nil && stripImage != nil) { + size_t expectedWidth = CGImageGetWidth(expected); + size_t expectedHeight = CGImageGetHeight(expected); + RCTLargeAssert( + CGImageGetWidth(stripImage) == expectedWidth && + CGImageGetHeight(stripImage) == expectedHeight && + stripResult.outputResult.width == expectedWidth && + stripResult.outputResult.height == expectedHeight, + @"strip output keeps normalized displayed dimensions" + ); + NSData *expectedPixels = RCTRenderedPixels( + expected, + expectedWidth, + expectedHeight + ); + NSData *stripPixels = RCTRenderedPixels( + stripImage, + expectedWidth, + expectedHeight + ); + RCTLargeAssert( + RCTMeanAbsoluteRgbDifference(expectedPixels, stripPixels) < 18.0, + @"strip output keeps upright displayed pixels" + ); + } + + RCTImageCompressionPipelineError *preserveError = nil; + RCTImageCompressionPipelineResult *preserveResult = RCTCompressWithMetadata( + sourcePath, + @"jpeg", + nil, + @"preserve", + &preserveError + ); + RCTLargeAssert( + preserveResult != nil && preserveError == nil, + @"preserve integration compresses oriented JPEG" + ); + NSURL *preserveURL = preserveResult == nil + ? nil + : [NSURL URLWithString:preserveResult.outputResult.uri]; + NSData *preserveData = preserveURL == nil + ? nil + : [NSData dataWithContentsOfURL:preserveURL]; + NSData *sanitizedPreserveData = preserveData == nil + ? nil + : [RCTImageCompressionJpegSegmentSanitizer + sanitizeJpegData:preserveData + stripRequested:YES + ]; + RCTLargeAssert( + sanitizedPreserveData != nil && + ![preserveData isEqualToData:sanitizedPreserveData], + @"preserve output keeps marker metadata that strip would remove" + ); + CGImageSourceRef preserveSource = preserveData == nil + ? nil + : CGImageSourceCreateWithData((__bridge CFDataRef)preserveData, nil); + NSDictionary *preserveProperties = preserveSource == nil + ? nil + : CFBridgingRelease( + CGImageSourceCopyPropertiesAtIndex(preserveSource, 0, nil) + ); + if (preserveSource != nil) CFRelease(preserveSource); + NSDictionary *preserveTiff = preserveProperties[ + (__bridge NSString *)kCGImagePropertyTIFFDictionary + ]; + RCTLargeAssertEqualObjects( + preserveTiff[(__bridge NSString *)kCGImagePropertyTIFFArtist], + @"metadata-test-artist", + @"preserve output retains source TIFF artist metadata" + ); + NSInteger preserveOrientation = [preserveProperties[ + (__bridge NSString *)kCGImagePropertyOrientation + ] integerValue]; + RCTLargeAssert( + preserveOrientation == 0 || preserveOrientation == 1, + @"preserve output keeps normalized orientation metadata" + ); + + if (expected != nil) CGImageRelease(expected); + if (stripImage != nil) CGImageRelease(stripImage); + RCTRemoveResult(stripResult); + RCTRemoveResult(preserveResult); + [[NSFileManager defaultManager] removeItemAtPath:sourcePath error:nil]; +} + static void TestCancellationRemovesPublishedOutput(void) { NSData *jpeg = RCTCreateSolidImageData(64, 48, YES, @"public.jpeg"); @@ -421,6 +600,7 @@ int main(void) TestDownsamples48MPBeforeTransform(); TestAlphaAndJpegBackgroundDecodeBack(); TestExifOrientationMatrixPreservesDisplayedLayout(); + TestStripSanitizesJpegWithoutChangingGeometry(); TestCancellationRemovesPublishedOutput(); if (RCTLargeImageFailures > 0) { fprintf(stderr, "iOS large-image tests failed: %lu/%lu assertions.\n", diff --git a/test/iosSimulatorMetadata.test.mjs b/test/iosSimulatorMetadata.test.mjs new file mode 100644 index 0000000..df63cf4 --- /dev/null +++ b/test/iosSimulatorMetadata.test.mjs @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { inspectBootedIosSimulatorMetadata } from '../scripts/ios-simulator-metadata-core.mjs'; + +const UDID = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'; +const RUNTIME_ID = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0'; + +describe('iOS simulator metadata', () => { + it('binds the selected booted device to its installed runtime build', () => { + expect( + inspectBootedIosSimulatorMetadata({ + devices: { + devices: { + [RUNTIME_ID]: [ + { state: 'Booted', name: 'iPhone 16 Pro', udid: UDID }, + ], + }, + }, + runtimes: { + runtimes: [ + { + identifier: RUNTIME_ID, + name: 'iOS 26.0', + buildversion: '23A340', + isAvailable: true, + }, + ], + }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + udid: UDID, + }) + ).toEqual({ + status: 'passed', + udid: UDID, + runtimeIdentifier: RUNTIME_ID, + runtime: 'iOS 26.0', + osBuild: '23A340', + device: 'iPhone 16 Pro', + abi: 'arm64', + error: null, + }); + }); + + it('rejects malformed and ambiguous simulator identifiers', () => { + const malformed = inspectBootedIosSimulatorMetadata({ + devices: { devices: {} }, + runtimes: { runtimes: [] }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEZ', + }); + expect(malformed.status).toBe('failed'); + expect(malformed.error).toContain('canonical simulator identifier'); + + const ambiguous = inspectBootedIosSimulatorMetadata({ + devices: { + devices: { + [RUNTIME_ID]: [ + { state: 'Booted', name: 'iPhone 16 Pro', udid: UDID }, + { state: 'Booted', name: 'Duplicate', udid: UDID }, + ], + }, + }, + runtimes: { + runtimes: [ + { + identifier: RUNTIME_ID, + name: 'iOS 26.0', + buildversion: '23A340', + isAvailable: true, + }, + ], + }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + udid: UDID, + }); + expect(ambiguous.status).toBe('failed'); + expect(ambiguous.error).toContain('exactly one iOS simulator device'); + }); + + it('rejects non-booted devices and incomplete runtime metadata', () => { + const report = inspectBootedIosSimulatorMetadata({ + devices: { + devices: { + [RUNTIME_ID]: [ + { state: 'Shutdown', name: '', udid: UDID }, + ], + }, + }, + runtimes: { + runtimes: [ + { + identifier: RUNTIME_ID, + name: '', + buildversion: '', + isAvailable: false, + }, + ], + }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + udid: UDID, + }); + expect(report.status).toBe('failed'); + expect(report.error).toContain('selected simulator must be booted'); + expect(report.error).toContain('selected simulator name is required'); + expect(report.error).toContain('runtime must be available'); + expect(report.error).toContain('runtime name must identify iOS'); + expect(report.error).toContain('runtime buildversion is required'); + }); + + it('rejects malformed device and runtime containers', () => { + const report = inspectBootedIosSimulatorMetadata({ + devices: { devices: { [RUNTIME_ID]: null } }, + runtimes: { runtimes: null }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + udid: UDID, + }); + expect(report.status).toBe('failed'); + expect(report.error).toContain('device list must be an array'); + expect(report.error).toContain('runtimes payload must contain a runtimes array'); + }); + + it('ignores booted non-iOS devices while selecting the iOS simulator', () => { + const report = inspectBootedIosSimulatorMetadata({ + devices: { + devices: { + 'com.apple.CoreSimulator.SimRuntime.watchOS-26-0': [ + { state: 'Booted', name: 'Apple Watch', udid: UDID }, + ], + [RUNTIME_ID]: [ + { state: 'Booted', name: 'iPhone 16 Pro', udid: UDID }, + ], + }, + }, + runtimes: { + runtimes: [ + { + identifier: RUNTIME_ID, + name: 'iOS 26.0', + buildversion: '23A340', + isAvailable: true, + }, + ], + }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + }); + expect(report.status).toBe('passed'); + expect(report.runtimeIdentifier).toBe(RUNTIME_ID); + }); + + it('rejects unavailable devices and unsafe runtime labels', () => { + const report = inspectBootedIosSimulatorMetadata({ + devices: { + devices: { + [RUNTIME_ID]: [ + { + state: 'Booted', + name: 'iPhone 16 Pro', + udid: UDID, + isAvailable: false, + }, + ], + }, + }, + runtimes: { + runtimes: [ + { + identifier: RUNTIME_ID, + name: 'watchOS 26.0', + buildversion: '23A340\nforged', + isAvailable: true, + }, + ], + }, + appArchitectures: 'arm64', + runnerArch: 'ARM64', + }); + expect(report.status).toBe('failed'); + expect(report.error).toContain('device must be available'); + expect(report.error).toContain('runtime name must identify iOS'); + expect(report.error).toContain('runtime buildversion is required'); + }); + + it('binds one built-app architecture to the hosted runner', () => { + const base = { + devices: { + devices: { + [RUNTIME_ID]: [ + { state: 'Booted', name: 'iPhone 16 Pro', udid: UDID }, + ], + }, + }, + runtimes: { + runtimes: [ + { + identifier: RUNTIME_ID, + name: 'iOS 26.0', + buildversion: '23A340', + isAvailable: true, + }, + ], + }, + udid: UDID, + }; + expect( + inspectBootedIosSimulatorMetadata({ + ...base, + appArchitectures: 'x86_64', + runnerArch: 'X64', + }).abi + ).toBe('x86_64'); + + for (const mutation of [ + { appArchitectures: 'arm64 x86_64', runnerArch: 'ARM64' }, + { appArchitectures: 'x86_64', runnerArch: 'ARM64' }, + { appArchitectures: 'arm64', runnerArch: 'unknown' }, + ]) { + expect( + inspectBootedIosSimulatorMetadata({ ...base, ...mutation }).status + ).toBe('failed'); + } + }); +}); diff --git a/test/iosSourceContract.test.ts b/test/iosSourceContract.test.ts index cf13cfd..76f3054 100644 --- a/test/iosSourceContract.test.ts +++ b/test/iosSourceContract.test.ts @@ -64,6 +64,9 @@ describe('iOS source contract', () => { expect(podspec).toContain('"ios/RCTImageCompressionImageEncoder.h"'); expect(podspec).toContain('"ios/RCTImageCompressionImageTransformer.h"'); expect(podspec).toContain('"ios/RCTImageCompressionJpegMetadata.h"'); + expect(podspec).toContain( + '"ios/RCTImageCompressionJpegSegmentSanitizer.h"' + ); expect(podspec).toContain('"ios/RCTImageCompressionOutput.h"'); expect(podspec).toContain('"ios/RCTImageCompressionPipeline.h"'); expect(podspec).toContain('"ios/RCTImageCompressionInput.h"'); @@ -485,6 +488,8 @@ describe('iOS source contract', () => { '@interface RCTImageCompressionJpegMetadataResult : NSObject', '@interface RCTImageCompressionJpegMetadata : NSObject', 'RCTImageCompressionJpegSourcePropertyReader', + '@property (nonatomic, copy, readonly) NSString *metadataPolicy;', + '@property (nonatomic, readonly) BOOL stripRequested;', ]) { expect(header).toContain(identifier); } @@ -505,6 +510,12 @@ describe('iOS source contract', () => { expect(metadata).not.toMatch( /(?:CGImageDestinationCreateWithData|CGImageDestinationAddImage|CGImageDestinationFinalize|UIImage|maxBytes|writeToFile:|RCTPromise)/ ); + expect(metadata).toContain( + 'initWithMetadataPolicy:request.metadataPolicy' + ); + expect(metadata).toContain( + '_stripRequested = [metadataPolicy isEqualToString:RCTImageCompressionKitStripMetadataPolicy]' + ); expect(defaultPipeline).toContain( '[RCTImageCompressionJpegMetadata defaultMetadata]' ); @@ -541,6 +552,98 @@ describe('iOS source contract', () => { ); }); + it('strictly strips encoder-generated JPEG metadata markers only for strip policy', () => { + const header = readProjectFile( + 'ios/RCTImageCompressionJpegSegmentSanitizer.h' + ); + const sanitizer = readProjectFile( + 'ios/RCTImageCompressionJpegSegmentSanitizer.mm' + ); + const uiKitEncoder = readProjectFile( + 'ios/RCTImageCompressionUIKitImageEncoder.mm' + ); + const metadataTests = readProjectFile( + 'test/ios-native/RCTImageCompressionJpegMetadataTests.mm' + ); + const nativeTests = readProjectFile( + 'test/ios-native/RCTImageCompressionJpegSegmentSanitizerTests.mm' + ); + const largeImageTests = readProjectFile( + 'test/ios-native/RCTImageCompressionLargeImageTests.mm' + ); + const runner = readProjectFile('scripts/ios-validation.mjs'); + const podspec = readProjectFile( + 'react-native-image-compression-kit.podspec' + ); + const sanitizerTestNames = [ + ...nativeTests.matchAll(/static void (Test\w+)\(void\)/g), + ].map((match) => match[1]); + const largeImageRunner = runner.slice( + runner.indexOf('function runLargeImageTests()'), + runner.indexOf('function runImageTransformerTests()') + ); + + expect(header).toContain( + '@interface RCTImageCompressionJpegSegmentSanitizer : NSObject' + ); + expect(header).toContain('sanitizeJpegData:(NSData *)jpegData'); + expect(header).toContain('stripRequested:(BOOL)stripRequested'); + expect(sanitizer).not.toMatch(/#import <(?:UIKit|ImageIO|React)/); + expect(sanitizer).toContain('if (!stripRequested) return [jpegData copy]'); + expect(sanitizer).toContain('bytes[0] != 0xff || bytes[1] != 0xd8'); + expect(sanitizer).toContain('marker == 0xe1 || marker == 0xed || marker == 0xfe'); + expect(sanitizer).toContain('marker == 0x00 || marker == 0x01'); + expect(sanitizer).toContain('marker >= 0xd0 && marker <= 0xd7'); + expect(sanitizer).toContain('segmentLength < 2 || segmentLength > length - cursor'); + expect(sanitizer).toContain('segmentLength != 6 + (2 * componentCount)'); + expect(sanitizer).toContain('componentCount == 0 || componentCount > 4'); + expect(sanitizer).toContain( + '!resumeEntropyAfterSegment || segmentLength != 4' + ); + expect(sanitizer).toContain('if (!sawScan || cursor != length) return nil'); + expect(sanitizer).toContain('resumeEntropyAfterSegment = marker == 0xdc'); + expect(uiKitEncoder).toContain( + '#import "RCTImageCompressionJpegSegmentSanitizer.h"' + ); + expect(uiKitEncoder).toContain('stripRequested:metadata.stripRequested'); + expect(metadataTests).toContain( + '@"only strip requests marker sanitization"' + ); + expect(sanitizerTestNames).toEqual( + expect.arrayContaining([ + 'TestRemovesMetadataBeforeAndBetweenScans', + 'TestPreservesStuffedRestartAndNonSensitiveSegments', + 'TestBypassesSafeAndPreserveOutputs', + 'TestRejectsMalformedHeadersAndSegmentLengths', + 'TestRejectsMalformedScanTermination', + ]) + ); + expect(sanitizerTestNames).toHaveLength(5); + expect(largeImageTests).toContain( + 'TestStripSanitizesJpegWithoutChangingGeometry' + ); + expect(largeImageTests).toContain( + '@"preserve output retains source TIFF artist metadata"' + ); + expect(packageJson.scripts['example:ios:jpeg-sanitizer-test']).toBe( + 'node scripts/ios-validation.mjs jpeg-sanitizer-test' + ); + expect(runner).toContain("if (mode === 'jpeg-sanitizer-test')"); + expect(runner).toContain('function runJpegSegmentSanitizerTests()'); + expect(runner).toMatch( + /runLargeImageTests\(\);\s*runJpegSegmentSanitizerTests\(\);/ + ); + expect(largeImageRunner).toContain( + 'JPEG_SEGMENT_SANITIZER_CORE_SOURCE' + ); + expect(runner).toContain( + "'RCTImageCompressionJpegSegmentSanitizer.mm'" + ); + expect(podspec).toContain( + '"ios/RCTImageCompressionJpegSegmentSanitizer.h"' + ); + }); + it('isolates output encoding and target-size search behind native tables', () => { const header = readProjectFile('ios/RCTImageCompressionImageEncoder.h'); const encoder = readProjectFile('ios/RCTImageCompressionImageEncoder.mm'); diff --git a/test/packageContract.test.ts b/test/packageContract.test.ts index 1f99bf4..04505f4 100644 --- a/test/packageContract.test.ts +++ b/test/packageContract.test.ts @@ -139,6 +139,10 @@ describe('npm package contract', () => { 'node scripts/create-benchmark-comparison-evidence.mjs', 'verify:benchmark-comparison-evidence': 'node scripts/verify-benchmark-comparison-evidence.mjs', + 'economic-resilience:evidence': + 'node scripts/create-economic-resilience-evidence.mjs', + 'verify:economic-resilience-evidence': + 'node scripts/verify-economic-resilience-evidence.mjs', 'release:dry-run': 'node scripts/release-dry-run.mjs', 'android:doctor': 'node scripts/android-verification.mjs doctor', 'android:codegen': 'node scripts/android-verification.mjs codegen', @@ -156,6 +160,8 @@ describe('npm package contract', () => { 'node scripts/ios-validation.mjs encoder-test', 'example:ios:metadata-test': 'node scripts/ios-validation.mjs metadata-test', + 'example:ios:jpeg-sanitizer-test': + 'node scripts/ios-validation.mjs jpeg-sanitizer-test', 'example:ios:transformer-test': 'node scripts/ios-validation.mjs transformer-test', 'example:ios:input-test': 'node scripts/ios-validation.mjs input-test', @@ -237,6 +243,10 @@ describe('npm package contract', () => { expect(readProjectFile('scripts/docker-android.mjs')).toContain( "'RNICK_DOCKER=1'" ); + const dockerfile = readProjectFile('Dockerfile'); + expect(dockerfile).toContain('ffmpeg \\'); + expect(dockerfile).toContain('ffmpeg -version | head -n 1'); + expect(dockerfile).toContain('ffprobe -version | head -n 1'); }); it('keeps Vitest and its V8 coverage provider on one exact version', () => { diff --git a/website/guide/files-metadata.md b/website/guide/files-metadata.md index 366e5ae..bac78ac 100644 --- a/website/guide/files-metadata.md +++ b/website/guide/files-metadata.md @@ -56,9 +56,15 @@ and backend effects. | Policy | Behavior | | --- | --- | | `safe` | Default. Avoids forwarding privacy-sensitive source metadata. Android copies a filtered JPEG EXIF allowlist; iOS re-encodes without source metadata. | -| `strip` | Re-encodes without copying source metadata. | +| `strip` | Re-encodes without copying source metadata. For iOS JPEG output, it also removes every APP1, APP13, and COM segment generated by ImageIO after encoding. | | `preserve` | Supported only for JPEG source to JPEG output. Orientation is rendered into pixels and normalized. | +On iOS, `safe` prevents source metadata from being forwarded but otherwise +keeps the encoder output unchanged. JPEG `strip` adds a strict marker-level +pass: malformed, truncated, or trailing encoder output fails with +`ERR_ENCODE_FAILED` instead of publishing a partially parsed file. APP0, ICC +profiles in APP2, and other non-target JPEG segments remain intact. + The application remains responsible for its own consent, retention, upload, and privacy disclosures. Verify output metadata if legal or product policy requires stronger guarantees than the package contract. diff --git a/website/reference/evidence.md b/website/reference/evidence.md index a13cb9a..c6c5098 100644 --- a/website/reference/evidence.md +++ b/website/reference/evidence.md @@ -189,6 +189,25 @@ mean better visual quality. - The exact-plan timing benchmark is an environment-bound observation and is documented separately from these product-contract metrics. +## Large-photo source-tree capture contract + +The Native Demo Evidence workflow also captures a separate, kit-only 12 MP +JPEG scenario from the checked-out source tree. It uses one project-generated, +non-personal 4,000 × 3,000 fixture, a 1,600 × 1,200 contain request, JPEG +quality 90, a 500,000-byte ceiling, a `strip` metadata policy, two warmups, and ten +measured calls. Iteration 10 is the representative output. + +This source-tree bundle is intentionally separate from the current v0.4.0 site +snapshot. A result is accepted only when source/output bytes and hashes, +decoded geometry, the exact environment and capabilities, SSIM ≥ 0.90, an +upright-over-vertical-flip margin ≥ 0.02, source retention, and cleanup of all +12 package-owned outputs agree. The source-to-output byte difference is signed; +it is not an avoided-transfer, storage-savings, or cost claim because the +source remains and no matched baseline is supplied. + +See the [benchmark methodology](https://github.com/GGULBAE/react-native-image-compression-kit/blob/master/docs/benchmarks/README.md#12-mp-kit-only-economic-resilience) +for the complete timing boundary, artifact verifier, and interpretation limits. + ## Reproduce the evidence ```bash