lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 70e808bfa685cc205034bc0feb947bac6a68162d
parent fc0a0d69f9af0adae4ca2c43c966195d10e39d5c
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 00:13:38 +0000

blossom: bound raster decoder resources

- pin a statically vendored WebP backend and decode into one checked fallible RGBA buffer
- enforce closed container process and work budgets with exact boundary tests
- measure twelve maximum-pixel raster paths three times below the 128 MiB RSS ceiling
- verify crates.io packaging supported-host execution and aarch64-apple-ios static linkage

Diffstat:
MCargo.lock | 24++++++++++++++++++++++++
MCargo.toml | 5++++-
Mbuild/nix/apps.nix | 8++++++++
Mbuild/nix/checks.nix | 6++++++
Mbuild/nix/common.nix | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild/nix/toolchains.nix | 2++
Mcrates/blossom/Cargo.toml | 11++++++++++-
Mcrates/blossom/README | 24+++++++++++++++++++-----
Mcrates/blossom/src/error.rs | 6++++++
Mcrates/blossom/src/publication_readiness.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/blossom/src/publication_readiness/sequential_jpeg.rs | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/blossom/tests/decoder_security.rs | 420+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust-toolchain-ios.toml | 4++++
13 files changed, 1034 insertions(+), 33 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3150,6 +3150,28 @@ dependencies = [ ] [[package]] +name = "libwebp" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7792a82f95b5b2528d9fe1642231f972d2cdd73b91ccbcb6ab214c2cf1a74f4" +dependencies = [ + "libwebp-sys2", +] + +[[package]] +name = "libwebp-sys2" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4790186411a6843ecc0a141c8948c8e623a0bb5e886834b1b6c90f3dfa85bb99" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "linked_list_allocator" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4583,10 +4605,12 @@ version = "1.0.0-alpha.1" dependencies = [ "hex", "image", + "libwebp", "mediatype", "serde", "serde_json", "sha2", + "tempfile", "unicode-general-category", "url", "zune-core", diff --git a/Cargo.toml b/Cargo.toml @@ -142,11 +142,14 @@ hkdf = { version = "0.12", default-features = false } hex = { version = "0.4" } image = { version = "=0.25.10", default-features = false, features = [ "png", - "webp", ] } jiff-tzdb = { version = "=0.1.8", default-features = false } jsonschema = { version = "0.48.1", default-features = false } js-sys = { version = "0.3" } +libwebp = { version = "=0.1.2", default-features = false, features = [ + "1_1", + "static", +] } mediatype = { version = "0.21", default-features = false } keyring = { version = "3.6.3", default-features = false, features = [ "apple-native", diff --git a/build/nix/apps.nix b/build/nix/apps.nix @@ -110,3 +110,11 @@ in }; } +// lib.optionalAttrs pkgs.stdenv.isDarwin { + blossom-raster-ios-compile-link = mkRepoApp { + name = "blossom-raster-ios-compile-link"; + description = "Compile and statically link Blossom raster decoding for aarch64-apple-ios"; + runtimeInputs = common.runtimeInputs.decoderSecurityIos; + command = common.decoderSecurityIosCommand; + }; +} diff --git a/build/nix/checks.nix b/build/nix/checks.nix @@ -46,10 +46,16 @@ let inherit (common) cargoArtifacts; pname = "radroots-blossom-raster-decode-test"; doCheck = false; + nativeBuildInputs = common.commonCraneArgs.nativeBuildInputs ++ [ + pkgs.imagemagick + pkgs.jq + pkgs.time + ]; buildPhaseCargoCommand = '' cargo test -p radroots_blossom --no-default-features --features serde \ --test publication_readiness_persistence cargo test -p radroots_blossom --no-default-features --features raster-decode,serde + ${common.decoderSecurityStableCommand} ''; installPhaseCommand = "mkdir -p $out"; } diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -29,6 +29,7 @@ let ../../build/nix/toolchains.nix ../../dto_bindgen.toml ../../rust-toolchain.toml + ../../rust-toolchain-ios.toml ../../contracts ../../crates ../../tools @@ -93,6 +94,13 @@ let toolchains.coverage cargoLlvmCov ]; + decoderSecurityStableRuntimeInputs = stableRuntimeInputs ++ [ + pkgs.imagemagick + pkgs.time + ]; + decoderSecurityIosRuntimeInputs = stableRuntimeInputs ++ [ + toolchains.ios + ]; releaseRuntimeInputs = coverageRuntimeInputs; coreContractCrates = [ "xtask" @@ -193,6 +201,155 @@ let cargo test -q ${coreContractCargoArgs} cargo run -q -p xtask -- contract validate ''; + decoderSecurityStableCommand = '' + stable_cargo=${toolchains.stable}/bin/cargo + magick=${pkgs.imagemagick}/bin/magick + + test_executable="$($stable_cargo test -p radroots_blossom \ + --no-default-features \ + --features raster-decode,serde \ + --test decoder_security \ + --no-run \ + --message-format=json \ + | jq -r 'select(.profile.test == true and .target.name == "decoder_security") | .executable' \ + | tail -n 1)" + if [ -z "$test_executable" ] || [ ! -x "$test_executable" ]; then + echo "failed to resolve decoder_security test executable" >&2 + exit 1 + fi + + ${lib.optionalString pkgs.stdenv.isDarwin '' + if otool -L "$test_executable" | grep -i 'libwebp'; then + echo "decoder test executable must not dynamically link libwebp" >&2 + exit 1 + fi + ''} + + cargo_target_root="''${CARGO_TARGET_DIR:-$PWD/target}" + mkdir -p "$cargo_target_root" + resource_root="$(mktemp -d "$cargo_target_root/decoder-security-resource-matrix.XXXXXX")" + fixture_root="$resource_root/fixtures" + evidence_root="$resource_root/rss" + mkdir -p "$fixture_root" "$evidence_root" + + "$magick" -size 5000x4000 xc:'#204060' -strip -colorspace Gray \ + -sampling-factor 1x1 -quality 85 "$fixture_root/jpeg_grayscale.jpg" + "$magick" -size 5000x4000 xc:'#204060' -strip -type TrueColor \ + -colorspace sRGB -sampling-factor 2x2 -quality 85 "$fixture_root/jpeg_rgb.jpg" + "$magick" -size 5000x4000 xc:'cmyk(10%,20%,30%,5%)' -strip \ + -colorspace CMYK -type ColorSeparation -sampling-factor 1x1 -quality 85 \ + "$fixture_root/jpeg_cmyk.jpg" + cp "$fixture_root/jpeg_rgb.jpg" "$fixture_root/jpeg_sof1.jpg" + perl -0777pi -e 's/\xFF\xC0/\xFF\xC1/ or die "SOF0 marker missing\n"' \ + "$fixture_root/jpeg_sof1.jpg" + + "$magick" -size 5000x4000 xc:'#204060' -strip -type TrueColor -depth 8 \ + "PNG24:$fixture_root/png_rgb.png" + "$magick" -size 5000x4000 pattern:checkerboard -strip -type Palette -depth 8 \ + -define png:color-type=3 -define png:bit-depth=8 \ + "$fixture_root/png_palette.png" + "$magick" -size 5000x4000 xc:'rgba(32,64,96,0.5)' -strip -alpha on -depth 8 \ + "PNG32:$fixture_root/png_rgba.png" + "$magick" -size 5000x4000 xc:'rgba(32,64,96,0.5)' -strip -alpha on -depth 8 \ + -interlace PNG "PNG32:$fixture_root/png_adam7.png" + + "$magick" -size 5000x4000 xc:'#204060' -strip -type TrueColor \ + -define webp:lossless=false -quality 75 "$fixture_root/webp_vp8_rgb.webp" + "$magick" -size 5000x4000 xc:'#204060' -strip -alpha set -channel A \ + -evaluate set 50% +channel -define webp:lossless=false -quality 75 \ + "$fixture_root/webp_vp8_alpha.webp" + "$magick" -size 5000x4000 xc:'#204060' -strip -type TrueColor \ + -define webp:lossless=true "$fixture_root/webp_vp8l_rgb.webp" + "$magick" -size 5000x4000 xc:'#204060' -strip -alpha set -channel A \ + -evaluate set 50% +channel -define webp:lossless=true \ + "$fixture_root/webp_vp8l_alpha.webp" + + "$magick" -size 16384x1 xc:'#204060' -strip -type TrueColor -depth 8 \ + "PNG24:$fixture_root/axis_width_16384.png" + "$magick" -size 1x16384 xc:'#204060' -strip -type TrueColor -depth 8 \ + "PNG24:$fixture_root/axis_height_16384.png" + + resource_cases='jpeg_grayscale jpeg_rgb jpeg_cmyk jpeg_sof1 png_rgb png_palette png_rgba png_adam7 webp_vp8_rgb webp_vp8_alpha webp_vp8l_rgb webp_vp8l_alpha' + evidence_file="$resource_root/maximum-rss-kib.tsv" + printf 'case_id\tmaximum_rss_kib\n' > "$evidence_file" + for resource_case in $resource_cases; do + highest_rss_kib=0 + for repetition in 1 2 3; do + rss_file="$evidence_root/$resource_case.$repetition.rss-kib" + ${pkgs.time}/bin/time -f '%M' -o "$rss_file" \ + env \ + RADROOTS_DECODER_RESOURCE_CASE="$resource_case" \ + RADROOTS_DECODER_RESOURCE_FIXTURE_ROOT="$fixture_root" \ + "$test_executable" maximum_resource_probe --ignored --exact + peak_rss_kib="$(tr -d '[:space:]' < "$rss_file")" + case "$peak_rss_kib" in + ""|*[!0-9]*) + echo "invalid peak RSS measurement for $resource_case: $peak_rss_kib" >&2 + exit 1 + ;; + esac + if [ "$peak_rss_kib" -gt 131072 ]; then + echo "$resource_case peak RSS $peak_rss_kib KiB exceeds 131072 KiB" >&2 + exit 1 + fi + if [ "$peak_rss_kib" -gt "$highest_rss_kib" ]; then + highest_rss_kib="$peak_rss_kib" + fi + done + printf '%s\t%s\n' "$resource_case" "$highest_rss_kib" >> "$evidence_file" + echo "$resource_case peak RSS: $highest_rss_kib KiB (limit: 131072 KiB)" + done + + for axis_case in width_16384 height_16384; do + env \ + RADROOTS_DECODER_RESOURCE_AXIS_CASE="$axis_case" \ + RADROOTS_DECODER_RESOURCE_FIXTURE_ROOT="$fixture_root" \ + "$test_executable" axis_resource_probe --ignored --exact + done + echo "decoder resource evidence: $evidence_file" + ''; + decoderSecurityIosCommand = '' + if [ "$(uname -s)" != Darwin ]; then + echo "the aarch64-apple-ios compile/link lane requires a Darwin host" >&2 + exit 1 + fi + + ios_xcrun=/usr/bin/xcrun + ios_sdk="$(env -u DEVELOPER_DIR -u SDKROOT "$ios_xcrun" --sdk iphoneos --show-sdk-path)" + ios_clang="$(env -u DEVELOPER_DIR -u SDKROOT "$ios_xcrun" --sdk iphoneos --find clang)" + ios_ar="$(env -u DEVELOPER_DIR -u SDKROOT "$ios_xcrun" --sdk iphoneos --find ar)" + unset DEVELOPER_DIR + export SDKROOT="$ios_sdk" + export IPHONEOS_DEPLOYMENT_TARGET=16.0 + export CC_aarch64_apple_ios="$ios_clang" + export AR_aarch64_apple_ios="$ios_ar" + export CFLAGS_aarch64_apple_ios="--target=arm64-apple-ios16.0 -isysroot $ios_sdk" + export CARGO_TARGET_AARCH64_APPLE_IOS_LINKER="$ios_clang" + export CARGO_TARGET_AARCH64_APPLE_IOS_RUSTFLAGS="-C link-arg=-isysroot -C link-arg=$ios_sdk -C link-arg=-miphoneos-version-min=16.0" + export RUSTC=${toolchains.ios}/bin/rustc + export RUSTDOC=${toolchains.ios}/bin/rustdoc + + ios_cargo=${toolchains.ios}/bin/cargo + "$ios_cargo" rustc -p radroots_blossom \ + --lib \ + --crate-type staticlib \ + --target aarch64-apple-ios \ + --no-default-features \ + --features raster-decode,serde + + ios_archive="''${CARGO_TARGET_DIR:?}/aarch64-apple-ios/debug/libradroots_blossom.a" + if [ ! -f "$ios_archive" ]; then + echo "missing aarch64-apple-ios static archive: $ios_archive" >&2 + exit 1 + fi + lipo -info "$ios_archive" | grep -F 'arm64' >/dev/null + archive_members="$(${pkgs.cctools}/bin/ar -t "$ios_archive")" + printf '%s\n' "$archive_members" | grep -F 'libwebp_sys' >/dev/null + printf '%s\n' "$archive_members" | grep -F -- '-webp_dec.o' >/dev/null + printf '%s\n' "$archive_members" | grep -F -- '-vp8_dec.o' >/dev/null + printf '%s\n' "$archive_members" | grep -F -- '-vp8l_dec.o' >/dev/null + echo "aarch64-apple-ios static link verified: $ios_archive" + ''; releasePreflightCommand = '' cargo check -q cargo test -q -p xtask @@ -344,6 +501,8 @@ in coverageReportCommand craneLib ensureRepoRoot + decoderSecurityIosCommand + decoderSecurityStableCommand mkRepoCheck releasePreflightCommand coreContractCargoArgs @@ -358,6 +517,8 @@ in runtimeInputs = { stable = stableRuntimeInputs; coverage = coverageRuntimeInputs; + decoderSecurityIos = decoderSecurityIosRuntimeInputs; + decoderSecurityStable = decoderSecurityStableRuntimeInputs; release = releaseRuntimeInputs; }; } diff --git a/build/nix/toolchains.nix b/build/nix/toolchains.nix @@ -3,4 +3,6 @@ stable = pkgs.rust-bin.fromRustupToolchainFile ../../rust-toolchain.toml; coverage = pkgs.rust-bin.fromRustupToolchainFile ../../rust-toolchain-coverage.toml; + + ios = pkgs.rust-bin.fromRustupToolchainFile ../../rust-toolchain-ios.toml; } diff --git a/crates/blossom/Cargo.toml b/crates/blossom/Cargo.toml @@ -16,10 +16,17 @@ readme = "README" default = ["serde"] serde = ["dep:serde", "dep:serde_json"] std = ["serde?/std", "sha2/std", "url_nostd/std"] -raster-decode = ["std", "dep:image", "dep:zune-core", "dep:zune-jpeg"] +raster-decode = [ + "std", + "dep:image", + "dep:libwebp", + "dep:zune-core", + "dep:zune-jpeg", +] [dependencies] image = { workspace = true, optional = true } +libwebp = { workspace = true, optional = true } mediatype = { workspace = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, default-features = false, features = [ @@ -33,7 +40,9 @@ zune-jpeg = { workspace = true, optional = true } [dev-dependencies] hex = { workspace = true } +image = { workspace = true, features = ["webp"] } serde_json = { workspace = true, features = ["std"] } +tempfile = { workspace = true } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/blossom/README b/crates/blossom/README @@ -27,11 +27,25 @@ restart marker, and frame component without synthesizing missing input. The same bytes are then fully decoded to RGB pixels by exactly pinned `zune-jpeg` `0.5.15` and `zune-core` `0.5.1` in strict mode with unsafe intrinsics disabled. The profile accepts only 8-bit sequential SOF0/SOF1 JPEG; progressive SOF2 and -every other process are rejected. `image` `0.25.10` is enabled only for PNG and WebP. The profile -rejects PNG and WebP animation, bounds decoded output to `160,000,000` bytes -before allocation, decodes the complete body, and derives dimensions -internally. It checks complete-byte, URL, hash, MIME, length, container, -animation, and dimension agreement before emitting deterministic per-URL +every other process are rejected. `image` `0.25.10` is enabled in production +only for PNG. WebP uses the safe `libwebp` `0.1.2` wrapper over statically +vendored `libwebp-sys2` `0.1.11` and decodes directly into one checked, +fallibly allocated RGBA buffer. Both dependencies are BSD-3-Clause; the native +and unsafe implementation remains confined to those pinned dependencies, and +the production boundary uses no subprocess, system dylib discovery, or network +access. The profile rejects PNG and WebP animation, bounds decoded output to +`80,000,000` bytes before allocation, decodes the complete body, and derives dimensions +internally. PNG is limited to static 8-bit images with approved color types, +one ordered palette, no unknown critical chunks, and at most `65,536` +container records; WebP is limited to static images whose extended header +carries no reserved flags and exactly one primary chunk under the same record +budget. Sequential JPEG validation charges bounded scan, block, +coefficient-step, marker-record, and entropy bit-read budgets, and +unsupported PNG or WebP processes fail with the stable +`publication_raster_process_forbidden` error. The decoder boundary is exercised by an isolated twelve-process +maximum-resource matrix held under a 128 MiB peak-RSS gate and an +`aarch64-apple-ios` static compile/link lane. It checks complete-byte, URL, hash, MIME, length, +container, animation, and dimension agreement before emitting deterministic per-URL evidence. The crate performs no HTTP: an owning runtime supplies only the transport observations and exact complete body. The portable `no_std` core remains available without `raster-decode`, but cannot construct readiness diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -67,6 +67,7 @@ pub enum RadrootsBlossomError { UnsupportedPublicationRasterMediaType, InvalidPublicationRaster, PublicationJpegProcessForbidden, + PublicationRasterProcessForbidden, PublicationRasterAnimationForbidden, PublicationRasterDimensionsOutOfRange { width: u32, height: u32 }, PublicationRasterPixelLimitExceeded { pixels: u64 }, @@ -164,6 +165,7 @@ impl RadrootsBlossomError { } Self::InvalidPublicationRaster => "invalid_publication_raster", Self::PublicationJpegProcessForbidden => "publication_jpeg_process_forbidden", + Self::PublicationRasterProcessForbidden => "publication_raster_process_forbidden", Self::PublicationRasterAnimationForbidden => "publication_raster_animation_forbidden", Self::PublicationRasterDimensionsOutOfRange { .. } => { "publication_raster_dimensions_out_of_range" @@ -395,6 +397,9 @@ impl fmt::Display for RadrootsBlossomError { Self::PublicationJpegProcessForbidden => { f.write_str("publication JPEG must use an 8-bit sequential SOF0 or SOF1 process") } + Self::PublicationRasterProcessForbidden => f.write_str( + "publication PNG and WebP rasters must use an approved static 8-bit process", + ), Self::PublicationRasterAnimationForbidden => { f.write_str("publication raster animation is forbidden") } @@ -537,6 +542,7 @@ mod tests { RadrootsBlossomError::UnsupportedPublicationRasterMediaType, RadrootsBlossomError::InvalidPublicationRaster, RadrootsBlossomError::PublicationJpegProcessForbidden, + RadrootsBlossomError::PublicationRasterProcessForbidden, RadrootsBlossomError::PublicationRasterAnimationForbidden, RadrootsBlossomError::PublicationRasterDimensionsOutOfRange { width: 0, diff --git a/crates/blossom/src/publication_readiness.rs b/crates/blossom/src/publication_readiness.rs @@ -3,10 +3,9 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; #[cfg(feature = "raster-decode")] -use image::{ - ImageDecoder, Limits, - codecs::{png::PngDecoder, webp::WebPDecoder}, -}; +use image::{ImageDecoder, Limits, codecs::png::PngDecoder}; +#[cfg(feature = "raster-decode")] +use libwebp::{WebPDecodeRGBAInto, WebPGetInfo}; #[cfg(any(feature = "raster-decode", feature = "serde"))] use sha2::{Digest, Sha256}; #[cfg(feature = "raster-decode")] @@ -35,11 +34,13 @@ pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION: u32 = pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES: usize = 8 * 1024; pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES: usize = 4 * 1024; pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES: u64 = 10_485_760; -pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES: u64 = - RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS * 8; +pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES: u64 = 80_000_000; pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION: u32 = 16_384; pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_000; +#[cfg(any(feature = "raster-decode", test))] +const PUBLICATION_RASTER_MAX_CONTAINER_RECORDS: usize = 65_536; + #[cfg(any(feature = "raster-decode", feature = "serde"))] const READINESS_EVIDENCE_DIGEST_DOMAIN: &[u8] = b"radroots.blossom.publication-readiness-evidence.v1\0"; @@ -578,15 +579,37 @@ fn decode_raster( } RadrootsBlossomRasterFormat::StillWebP => { let container = inspect_webp_container(bytes)?; - let decoder = WebPDecoder::new(Cursor::new(bytes)) - .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; - reject_animation(container.animated, decoder.has_animation())?; - decode_complete_raster(decoder, container.dimensions) + reject_animation(container.animated, false)?; + decode_complete_webp(bytes, container.dimensions) } } } #[cfg(feature = "raster-decode")] +fn decode_complete_webp( + bytes: &[u8], + container_dimensions: RadrootsBlossomRasterDimensions, +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + let (width, height) = + WebPGetInfo(bytes).map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let dimensions = RadrootsBlossomRasterDimensions::new(width, height)?; + require_matching_dimensions(dimensions, container_dimensions)?; + + let decoded_bytes = bounded_decoded_byte_length( + u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)), + )?; + let stride = width + .checked_mul(4) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let mut decoded = allocate_decoded_buffer(decoded_bytes)?; + WebPDecodeRGBAInto(bytes, &mut decoded, stride) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok(dimensions) +} + +#[cfg(feature = "raster-decode")] fn decode_complete_jpeg( bytes: &[u8], container: JpegContainerInspection, @@ -1008,8 +1031,18 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr let mut position = SIGNATURE.len(); let mut dimensions = None; let mut has_image_data = false; + let mut image_data_ended = false; + let mut color_type = None; + let mut has_palette = false; let mut animated = false; + let mut records = 0_usize; while position < bytes.len() { + records = records + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { + return invalid_raster(); + } let header_end = position .checked_add(8) .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; @@ -1051,10 +1084,33 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr .try_into() .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, ); + let bit_depth = data[8]; + let parsed_color_type = data[9]; + if bit_depth != 8 || data[10] != 0 || data[11] != 0 || data[12] > 1 { + return Err(RadrootsBlossomError::PublicationRasterProcessForbidden); + } + if !matches!(parsed_color_type, 0 | 2 | 3 | 4 | 6) { + return Err(RadrootsBlossomError::PublicationRasterDecodeFailed); + } dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + color_type = Some(parsed_color_type); } b"IHDR" => return invalid_raster(), - b"IDAT" if dimensions.is_some() => has_image_data = true, + b"PLTE" if dimensions.is_some() && !has_palette && !has_image_data => { + if data.is_empty() || data.len() % 3 != 0 || data.len() > 768 { + return invalid_raster(); + } + has_palette = true; + } + b"PLTE" => return invalid_raster(), + b"IDAT" + if dimensions.is_some() + && !image_data_ended + && (color_type != Some(3) || has_palette) => + { + has_image_data = true; + } + b"IDAT" => return invalid_raster(), b"acTL" | b"fcTL" | b"fdAT" => animated = true, b"IEND" if data.is_empty() && has_image_data && position == bytes.len() => { return Ok(RasterContainerInspection { @@ -1064,7 +1120,8 @@ fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, Radr } b"IEND" => return invalid_raster(), _ if dimensions.is_none() => return invalid_raster(), - _ => {} + _ if kind[0] & 0x20 == 0 => return invalid_raster(), + _ => image_data_ended |= has_image_data, } } invalid_raster() @@ -1088,7 +1145,15 @@ fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, Rad let mut dimensions = None; let mut primary_chunks = 0_u8; let mut animated = false; + let mut extended = false; + let mut records = 0_usize; while position < bytes.len() { + records = records + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { + return invalid_raster(); + } let header_end = position .checked_add(8) .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; @@ -1119,7 +1184,11 @@ fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, Rad match &kind { b"ANIM" | b"ANMF" => animated = true, - b"VP8X" if data.len() == 10 => { + b"VP8X" if data.len() == 10 && !extended && primary_chunks == 0 => { + if data[0] & 0b1100_0001 != 0 || data[1..4] != [0, 0, 0] { + return Err(RadrootsBlossomError::PublicationRasterProcessForbidden); + } + extended = true; animated |= data[0] & 0b0000_0010 != 0; let width = 1 + read_u24_le(&data[4..7]); let height = 1 + read_u24_le(&data[7..10]); @@ -1194,17 +1263,28 @@ fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, Radro let mut position = 2_usize; let mut dimensions = None; let mut components = None; + let mut records = 0_usize; loop { + records = records + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { + return invalid_raster(); + } if bytes.get(position) != Some(&0xff) { return invalid_raster(); } while bytes.get(position) == Some(&0xff) { - position += 1; + position = position + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; } let marker = *bytes .get(position) .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; - position += 1; + position = position + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; match marker { 0xd9 if position == bytes.len() => { return Ok(JpegContainerInspection { @@ -1277,18 +1357,26 @@ fn is_jpeg_start_of_frame(marker: u8) -> bool { fn jpeg_scan_end(bytes: &[u8], mut position: usize) -> Result<usize, RadrootsBlossomError> { while position < bytes.len() { if bytes[position] != 0xff { - position += 1; + position = position + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; continue; } let marker_start = position; while bytes.get(position) == Some(&0xff) { - position += 1; + position = position + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; } let marker = *bytes .get(position) .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; match marker { - 0x00 | 0xd0..=0xd7 => position += 1, + 0x00 | 0xd0..=0xd7 => { + position = position + .checked_add(1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + } _ => return Ok(marker_start), } } @@ -1384,6 +1472,23 @@ mod tests { output } + fn png_with_record_count(record_count: usize) -> Vec<u8> { + assert!(record_count >= 3); + let ihdr = &PNG[16..29]; + let idat = &PNG[41..54]; + let mut output = b"\x89PNG\r\n\x1a\n".to_vec(); + for (kind, data) in core::iter::once((*b"IHDR", ihdr)) + .chain(core::iter::repeat_n((*b"tEXt", &[][..]), record_count - 3)) + .chain([(*b"IDAT", idat), (*b"IEND", &[][..])]) + { + output.extend_from_slice(&(data.len() as u32).to_be_bytes()); + output.extend_from_slice(&kind); + output.extend_from_slice(data); + output.extend_from_slice(&[0; 4]); + } + output + } + fn webp_with_chunks(chunks: &[([u8; 4], &[u8])]) -> Vec<u8> { let mut output = b"RIFF\0\0\0\0WEBP".to_vec(); for (kind, data) in chunks { @@ -1582,6 +1687,24 @@ mod tests { #[test] fn raster_dimensions_reject_each_axis_boundary() { + assert_eq!( + RadrootsBlossomRasterDimensions::new( + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, + 1, + ) + .unwrap() + .pixels(), + u64::from(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION) + ); + assert_eq!( + RadrootsBlossomRasterDimensions::new( + 1, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, + ) + .unwrap() + .pixels(), + u64::from(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION) + ); for (width, height) in [ (0, 1), (1, 0), @@ -1595,6 +1718,18 @@ mod tests { "publication_raster_dimensions_out_of_range" ); } + assert_eq!( + RadrootsBlossomRasterDimensions::new(5_000, 4_000) + .unwrap() + .pixels(), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS + ); + assert_eq!( + RadrootsBlossomRasterDimensions::new(5_001, 4_000) + .unwrap_err() + .code(), + "publication_raster_pixel_limit_exceeded" + ); } #[cfg(feature = "raster-decode")] @@ -1773,12 +1908,74 @@ mod tests { } #[test] + fn png_container_enforces_eight_bit_process_palette_and_record_limits() { + for index in [24, 26, 27, 28] { + let mut forbidden = PNG.to_vec(); + forbidden[index] = match index { + 24 => 16, + 28 => 2, + _ => 1, + }; + assert_eq!( + validate_png_container(&forbidden).unwrap_err().code(), + "publication_raster_process_forbidden" + ); + } + + let mut malformed_color = PNG.to_vec(); + malformed_color[25] = 1; + assert_eq!( + validate_png_container(&malformed_color).unwrap_err().code(), + "publication_raster_decode_failed" + ); + + let mut indexed_ihdr = PNG[16..29].to_vec(); + indexed_ihdr[9] = 3; + let missing_palette = png_with_chunks(&[ + (*b"IHDR", &indexed_ihdr), + (*b"IDAT", &PNG[41..54]), + (*b"IEND", &[]), + ]); + assert_eq!( + validate_png_container(&missing_palette).unwrap_err().code(), + "invalid_publication_raster" + ); + let indexed = png_with_chunks(&[ + (*b"IHDR", &indexed_ihdr), + (*b"PLTE", &[0, 0, 0]), + (*b"IDAT", &PNG[41..54]), + (*b"IEND", &[]), + ]); + assert_eq!( + validate_png_container(&indexed).unwrap(), + RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + ); + + assert!( + validate_png_container(&png_with_record_count( + PUBLICATION_RASTER_MAX_CONTAINER_RECORDS + )) + .is_ok() + ); + assert_eq!( + validate_png_container(&png_with_record_count( + PUBLICATION_RASTER_MAX_CONTAINER_RECORDS + 1 + )) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + } + + #[test] fn webp_container_covers_extended_lossless_and_lossy_boundaries() { let vp8x_1x1 = [0_u8; 10]; let mut vp8x_2x1 = vp8x_1x1; vp8x_2x1[4] = 1; let mut vp8x_animated = vp8x_1x1; vp8x_animated[0] = 0x02; + let mut vp8x_reserved = vp8x_1x1; + vp8x_reserved[0] = 0x01; let vp8l_1x1 = [0x2f, 0, 0, 0, 0]; let vp8_1x1 = [0, 0, 0, 0x9d, 0x01, 0x2a, 1, 0, 1, 0]; @@ -1828,6 +2025,21 @@ mod tests { "invalid_publication_raster" ); assert_eq!( + validate_webp_container(&webp_with_chunks(&[(*b"VP8X", &vp8x_reserved)])) + .unwrap_err() + .code(), + "publication_raster_process_forbidden" + ); + assert_eq!( + validate_webp_container(&webp_with_chunks(&[ + (*b"VP8X", &vp8x_1x1), + (*b"VP8X", &vp8x_1x1), + ])) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + assert_eq!( validate_webp_container(&webp_with_chunks(&[(*b"VP8L", &[0; 5])])) .unwrap_err() .code(), @@ -2000,6 +2212,10 @@ mod tests { #[cfg(feature = "raster-decode")] #[test] fn decoder_authority_rejects_animation_resource_and_agreement_failures() { + assert_eq!( + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, + 80_000_000 + ); reject_animation(false, false).unwrap(); for (container_animated, decoder_animated) in [(true, false), (false, true), (true, true)] { assert_eq!( diff --git a/crates/blossom/src/publication_readiness/sequential_jpeg.rs b/crates/blossom/src/publication_readiness/sequential_jpeg.rs @@ -1,10 +1,14 @@ use alloc::vec::Vec; use super::{ - JpegContainerInspection, RadrootsBlossomError, RadrootsBlossomRasterDimensions, - is_jpeg_start_of_frame, + JpegContainerInspection, PUBLICATION_RASTER_MAX_CONTAINER_RECORDS, RadrootsBlossomError, + RadrootsBlossomRasterDimensions, is_jpeg_start_of_frame, }; +const MAX_SEQUENTIAL_JPEG_SCANS: u8 = 4; +const MAX_SEQUENTIAL_JPEG_BLOCKS: u64 = 3_200_000; +const MAX_SEQUENTIAL_JPEG_COEFFICIENT_STEPS: u64 = MAX_SEQUENTIAL_JPEG_BLOCKS * 64; + #[derive(Clone, Copy)] struct SequentialJpegComponent { id: u8, @@ -126,19 +130,28 @@ struct SequentialJpegEntropyReader<'a> { position: usize, current_byte: u8, bits_remaining: u8, + bit_reads_remaining: u64, } impl<'a> SequentialJpegEntropyReader<'a> { - const fn new(bytes: &'a [u8], position: usize) -> Self { + fn new(bytes: &'a [u8], position: usize) -> Self { Self { bytes, position, current_byte: 0, bits_remaining: 0, + bit_reads_remaining: u64::try_from(bytes.len()) + .ok() + .and_then(|length| length.checked_mul(8)) + .unwrap_or(0), } } fn read_bit(&mut self) -> Result<u8, RadrootsBlossomError> { + self.bit_reads_remaining = self + .bit_reads_remaining + .checked_sub(1) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; if self.bits_remaining == 0 { self.current_byte = self.read_entropy_byte()?; self.bits_remaining = 8; @@ -204,6 +217,46 @@ impl<'a> SequentialJpegEntropyReader<'a> { } } +struct SequentialJpegWorkBudget { + scans_remaining: u8, + blocks_remaining: u64, + coefficient_steps_remaining: u64, +} + +impl SequentialJpegWorkBudget { + const fn new() -> Self { + Self { + scans_remaining: MAX_SEQUENTIAL_JPEG_SCANS, + blocks_remaining: MAX_SEQUENTIAL_JPEG_BLOCKS, + coefficient_steps_remaining: MAX_SEQUENTIAL_JPEG_COEFFICIENT_STEPS, + } + } + + fn charge_scan(&mut self) -> Result<(), RadrootsBlossomError> { + self.scans_remaining = self + .scans_remaining + .checked_sub(1) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok(()) + } + + fn charge_block(&mut self) -> Result<(), RadrootsBlossomError> { + self.blocks_remaining = self + .blocks_remaining + .checked_sub(1) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok(()) + } + + fn charge_coefficient_step(&mut self) -> Result<(), RadrootsBlossomError> { + self.coefficient_steps_remaining = self + .coefficient_steps_remaining + .checked_sub(1) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok(()) + } +} + pub(super) fn validate( bytes: &[u8], container: JpegContainerInspection, @@ -218,7 +271,15 @@ pub(super) fn validate( let mut restart_interval = 0_usize; let mut seen_components = [false; 4]; let mut saw_scan = false; + let mut records = 0_usize; + let mut work_budget = SequentialJpegWorkBudget::new(); loop { + records = records + .checked_add(1) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + if records > PUBLICATION_RASTER_MAX_CONTAINER_RECORDS { + return invalid_entropy(); + } let (marker, after_marker) = strict_marker(bytes, position)?; match marker { 0xd9 => { @@ -271,6 +332,7 @@ pub(super) fn validate( position = next; } 0xda => { + work_budget.charge_scan()?; let current_frame = frame .as_ref() .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; @@ -290,6 +352,7 @@ pub(super) fn validate( &dc_tables, &ac_tables, restart_interval, + &mut work_budget, )?; for component in &scan.components { seen_components[component.frame_index] = true; @@ -500,6 +563,7 @@ fn validate_scan_entropy( dc_tables: &[Option<SequentialJpegHuffmanTable>; 4], ac_tables: &[Option<SequentialJpegHuffmanTable>; 4], restart_interval: usize, + work_budget: &mut SequentialJpegWorkBudget, ) -> Result<usize, RadrootsBlossomError> { let interleaved = scan.components.len() > 1; let mcu_count = scan_mcu_count(frame, scan, interleaved)?; @@ -531,7 +595,8 @@ fn validate_scan_entropy( .and_then(Option::as_ref) .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; for _ in 0..blocks { - validate_block(&mut reader, dc_table, ac_table)?; + work_budget.charge_block()?; + validate_block(&mut reader, dc_table, ac_table, work_budget)?; } } } @@ -596,11 +661,13 @@ fn validate_block( reader: &mut SequentialJpegEntropyReader<'_>, dc_table: &SequentialJpegHuffmanTable, ac_table: &SequentialJpegHuffmanTable, + work_budget: &mut SequentialJpegWorkBudget, ) -> Result<(), RadrootsBlossomError> { let dc_magnitude = dc_table.decode_symbol(reader)?; reader.discard_bits(dc_magnitude)?; let mut coefficient = 1_usize; while coefficient < 64 { + work_budget.charge_coefficient_step()?; let symbol = ac_table.decode_symbol(reader)?; let run = usize::from(symbol >> 4); let magnitude = symbol & 0x0f; @@ -1069,11 +1136,22 @@ mod tests { let dc = one_symbol_table(0, 0); let eob = one_symbol_table(1, 0); let mut eob_reader = SequentialJpegEntropyReader::new(&[0x3f], 0); - validate_block(&mut eob_reader, &dc, &eob).unwrap(); + validate_block( + &mut eob_reader, + &dc, + &eob, + &mut SequentialJpegWorkBudget::new(), + ) + .unwrap(); let zrl = one_symbol_table(1, 0xf0); let mut zrl_reader = SequentialJpegEntropyReader::new(&[0x07], 0); - assert_decode_failed(validate_block(&mut zrl_reader, &dc, &zrl)); + assert_decode_failed(validate_block( + &mut zrl_reader, + &dc, + &zrl, + &mut SequentialJpegWorkBudget::new(), + )); let overflowing_run = one_symbol_table(1, 0xf1); let mut overflowing_reader = SequentialJpegEntropyReader::new(&[0x00], 0); @@ -1081,17 +1159,30 @@ mod tests { &mut overflowing_reader, &dc, &overflowing_run, + &mut SequentialJpegWorkBudget::new(), )); let exact_run = one_symbol_table(1, 0x81); let mut exact_reader = SequentialJpegEntropyReader::new(&[0x00, 0x00], 0); - validate_block(&mut exact_reader, &dc, &exact_run).unwrap(); + validate_block( + &mut exact_reader, + &dc, + &exact_run, + &mut SequentialJpegWorkBudget::new(), + ) + .unwrap(); let mut mixed_counts = [0_u8; 16]; mixed_counts[1] = 2; let mixed = SequentialJpegHuffmanTable::new(1, mixed_counts, &[0x11, 0]).unwrap(); let mut mixed_reader = SequentialJpegEntropyReader::new(&[0x07], 0); - validate_block(&mut mixed_reader, &dc, &mixed).unwrap(); + validate_block( + &mut mixed_reader, + &dc, + &mixed, + &mut SequentialJpegWorkBudget::new(), + ) + .unwrap(); } #[test] @@ -1205,6 +1296,7 @@ mod tests { &dc_tables, &ac_tables, 0, + &mut SequentialJpegWorkBudget::new(), )); let missing_dc_scan = SequentialJpegScan { @@ -1222,6 +1314,7 @@ mod tests { &dc_tables, &ac_tables, 0, + &mut SequentialJpegWorkBudget::new(), )); let missing_ac_scan = SequentialJpegScan { @@ -1239,6 +1332,41 @@ mod tests { &dc_tables, &ac_tables, 0, + &mut SequentialJpegWorkBudget::new(), )); } + + #[test] + fn entropy_and_structural_work_budgets_fail_closed_at_exhaustion() { + assert_eq!(MAX_SEQUENTIAL_JPEG_SCANS, 4); + assert_eq!(MAX_SEQUENTIAL_JPEG_BLOCKS, 3_200_000); + assert_eq!(MAX_SEQUENTIAL_JPEG_COEFFICIENT_STEPS, 204_800_000); + + let mut reader = SequentialJpegEntropyReader::new(&[0; 1], 0); + for _ in 0..8 { + reader.read_bit().unwrap(); + } + assert_decode_failed(reader.read_bit()); + + let mut scan_budget = SequentialJpegWorkBudget { + scans_remaining: 1, + ..SequentialJpegWorkBudget::new() + }; + scan_budget.charge_scan().unwrap(); + assert_decode_failed(scan_budget.charge_scan()); + + let mut block_budget = SequentialJpegWorkBudget { + blocks_remaining: 1, + ..SequentialJpegWorkBudget::new() + }; + block_budget.charge_block().unwrap(); + assert_decode_failed(block_budget.charge_block()); + + let mut coefficient_budget = SequentialJpegWorkBudget { + coefficient_steps_remaining: 1, + ..SequentialJpegWorkBudget::new() + }; + coefficient_budget.charge_coefficient_step().unwrap(); + assert_decode_failed(coefficient_budget.charge_coefficient_step()); + } } diff --git a/crates/blossom/tests/decoder_security.rs b/crates/blossom/tests/decoder_security.rs @@ -0,0 +1,420 @@ +#![cfg(feature = "raster-decode")] + +use radroots_blossom::{ + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, RadrootsBlossomAuthoredRasterDimensions, + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomBud01GetObservation, + RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, + RadrootsBlossomError, RadrootsBlossomMediaType, RadrootsBlossomSha256, + verify_publication_readiness, +}; +use std::{env, fs, path::PathBuf}; + +const RESOURCE_CASE_ENV: &str = "RADROOTS_DECODER_RESOURCE_CASE"; +const RESOURCE_FIXTURE_ROOT_ENV: &str = "RADROOTS_DECODER_RESOURCE_FIXTURE_ROOT"; +const RESOURCE_AXIS_CASE_ENV: &str = "RADROOTS_DECODER_RESOURCE_AXIS_CASE"; +const RESOURCE_WIDTH: u32 = 5_000; +const RESOURCE_HEIGHT: u32 = 4_000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResourceProbeCase { + JpegGrayscale, + JpegRgb, + JpegCmyk, + JpegSof1, + PngRgb, + PngPalette, + PngRgba, + PngAdam7, + WebpVp8Rgb, + WebpVp8Alpha, + WebpVp8lRgb, + WebpVp8lAlpha, +} + +impl ResourceProbeCase { + const ALL: [Self; 12] = [ + Self::JpegGrayscale, + Self::JpegRgb, + Self::JpegCmyk, + Self::JpegSof1, + Self::PngRgb, + Self::PngPalette, + Self::PngRgba, + Self::PngAdam7, + Self::WebpVp8Rgb, + Self::WebpVp8Alpha, + Self::WebpVp8lRgb, + Self::WebpVp8lAlpha, + ]; + + const fn id(self) -> &'static str { + match self { + Self::JpegGrayscale => "jpeg_grayscale", + Self::JpegRgb => "jpeg_rgb", + Self::JpegCmyk => "jpeg_cmyk", + Self::JpegSof1 => "jpeg_sof1", + Self::PngRgb => "png_rgb", + Self::PngPalette => "png_palette", + Self::PngRgba => "png_rgba", + Self::PngAdam7 => "png_adam7", + Self::WebpVp8Rgb => "webp_vp8_rgb", + Self::WebpVp8Alpha => "webp_vp8_alpha", + Self::WebpVp8lRgb => "webp_vp8l_rgb", + Self::WebpVp8lAlpha => "webp_vp8l_alpha", + } + } + + const fn fixture_name(self) -> &'static str { + match self { + Self::JpegGrayscale => "jpeg_grayscale.jpg", + Self::JpegRgb => "jpeg_rgb.jpg", + Self::JpegCmyk => "jpeg_cmyk.jpg", + Self::JpegSof1 => "jpeg_sof1.jpg", + Self::PngRgb => "png_rgb.png", + Self::PngPalette => "png_palette.png", + Self::PngRgba => "png_rgba.png", + Self::PngAdam7 => "png_adam7.png", + Self::WebpVp8Rgb => "webp_vp8_rgb.webp", + Self::WebpVp8Alpha => "webp_vp8_alpha.webp", + Self::WebpVp8lRgb => "webp_vp8l_rgb.webp", + Self::WebpVp8lAlpha => "webp_vp8l_alpha.webp", + } + } + + const fn format(self) -> &'static str { + match self { + Self::JpegGrayscale | Self::JpegRgb | Self::JpegCmyk | Self::JpegSof1 => "jpeg", + Self::PngRgb | Self::PngPalette | Self::PngRgba | Self::PngAdam7 => "png", + Self::WebpVp8Rgb | Self::WebpVp8Alpha | Self::WebpVp8lRgb | Self::WebpVp8lAlpha => { + "webp" + } + } + } + + const fn logical_decoded_bytes(self) -> u64 { + match self { + Self::JpegGrayscale + | Self::JpegRgb + | Self::JpegCmyk + | Self::JpegSof1 + | Self::PngRgb + | Self::PngPalette => 60_000_000, + Self::PngRgba + | Self::PngAdam7 + | Self::WebpVp8Rgb + | Self::WebpVp8Alpha + | Self::WebpVp8lRgb + | Self::WebpVp8lAlpha => 80_000_000, + } + } + + fn from_id(id: &str) -> Option<Self> { + Self::ALL.into_iter().find(|case| case.id() == id) + } + + fn validate_process(self, bytes: &[u8]) { + match self { + Self::JpegGrayscale => assert_eq!(jpeg_process(bytes), (0xc0, 1)), + Self::JpegRgb => assert_eq!(jpeg_process(bytes), (0xc0, 3)), + Self::JpegCmyk => assert_eq!(jpeg_process(bytes), (0xc0, 4)), + Self::JpegSof1 => assert_eq!(jpeg_process(bytes), (0xc1, 3)), + Self::PngRgb => assert_eq!(png_process(bytes), (2, 0)), + Self::PngPalette => assert_eq!(png_process(bytes), (3, 0)), + Self::PngRgba => assert_eq!(png_process(bytes), (6, 0)), + Self::PngAdam7 => assert_eq!(png_process(bytes), (6, 1)), + Self::WebpVp8Rgb => assert_eq!(webp_process(bytes), (*b"VP8 ", false)), + Self::WebpVp8Alpha => assert_eq!(webp_process(bytes), (*b"VP8 ", true)), + Self::WebpVp8lRgb => assert_eq!(webp_process(bytes), (*b"VP8L", false)), + Self::WebpVp8lAlpha => assert_eq!(webp_process(bytes), (*b"VP8L", true)), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AxisProbeCase { + Width, + Height, +} + +impl AxisProbeCase { + fn from_id(id: &str) -> Option<Self> { + match id { + "width_16384" => Some(Self::Width), + "height_16384" => Some(Self::Height), + _ => None, + } + } + + const fn fixture_name(self) -> &'static str { + match self { + Self::Width => "axis_width_16384.png", + Self::Height => "axis_height_16384.png", + } + } + + const fn dimensions(self) -> (u32, u32) { + match self { + Self::Width => (16_384, 1), + Self::Height => (1, 16_384), + } + } +} + +fn media(format: &str) -> (&'static str, &'static str) { + match format { + "jpeg" => ("image/jpeg", "jpg"), + "png" => ("image/png", "png"), + "webp" => ("image/webp", "webp"), + other => panic!("unsupported fixture format {other}"), + } +} + +fn png_chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> { + let mut output = Vec::with_capacity(data.len() + 12); + output.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + output.extend_from_slice(&kind); + output.extend_from_slice(data); + let mut crc_input = kind.to_vec(); + crc_input.extend_from_slice(data); + output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); + output +} + +fn verify(bytes: &[u8], format: &str) -> Result<(u32, u32), RadrootsBlossomError> { + let (media_type, extension) = media(format); + let hash = RadrootsBlossomSha256::digest(bytes); + let url = format!("https://cdn.example/{hash}.{extension}"); + let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); + let descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&url).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_800_000_000, + )?; + let authored = descriptor + .clone() + .approve_reference()? + .verify_bytes(bytes, &media_type)?; + let upload = RadrootsBlossomBud02UploadObservation::new(201, descriptor)?; + let approved_url = RadrootsBlossomBlobUrl::parse(&url)?.approve()?; + let head = RadrootsBlossomBud01HeadObservation::new( + 200, + approved_url.clone(), + bytes.len() as u64, + media_type, + )?; + let get = RadrootsBlossomBud01GetObservation::from_complete_body( + 200, + approved_url, + bytes.len() as u64, + bytes, + )?; + let evidence = verify_publication_readiness( + &authored, + bytes, + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + &upload, + &head, + &get, + )?; + Ok(( + evidence.dimensions().width(), + evidence.dimensions().height(), + )) +} + +#[test] +#[ignore = "executed in isolation by the governed peak-RSS lane"] +fn maximum_resource_probe() { + let case_id = env::var(RESOURCE_CASE_ENV).expect("resource case must be selected"); + let case = ResourceProbeCase::from_id(&case_id).expect("resource case must be governed"); + let bytes = resource_fixture(case.fixture_name()); + assert_eq!( + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, + 80_000_000 + ); + assert!(!bytes.is_empty()); + assert!(bytes.len() as u64 <= RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES); + assert!(case.logical_decoded_bytes() <= RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES); + case.validate_process(&bytes); + assert_eq!( + verify(&bytes, case.format()).unwrap(), + (RESOURCE_WIDTH, RESOURCE_HEIGHT) + ); +} + +#[test] +#[ignore = "executed with prepared fixtures by the governed axis-boundary lane"] +fn axis_resource_probe() { + let case_id = env::var(RESOURCE_AXIS_CASE_ENV).expect("axis case must be selected"); + let case = AxisProbeCase::from_id(&case_id).expect("axis case must be governed"); + let bytes = resource_fixture(case.fixture_name()); + assert!(!bytes.is_empty()); + assert!(bytes.len() as u64 <= RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES); + assert_eq!(png_process(&bytes), (2, 0)); + assert_eq!(verify(&bytes, "png").unwrap(), case.dimensions()); +} + +#[test] +fn resource_probe_inventory_is_closed() { + assert_eq!( + ResourceProbeCase::ALL.map(ResourceProbeCase::id), + [ + "jpeg_grayscale", + "jpeg_rgb", + "jpeg_cmyk", + "jpeg_sof1", + "png_rgb", + "png_palette", + "png_rgba", + "png_adam7", + "webp_vp8_rgb", + "webp_vp8_alpha", + "webp_vp8l_rgb", + "webp_vp8l_alpha", + ] + ); + for case in ResourceProbeCase::ALL { + assert_eq!(ResourceProbeCase::from_id(case.id()), Some(case)); + assert!(case.logical_decoded_bytes() > 0); + assert!( + case.logical_decoded_bytes() <= RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES + ); + } + assert!(ResourceProbeCase::from_id("png_gray").is_none()); + assert!(AxisProbeCase::from_id("width_16384").is_some()); + assert!(AxisProbeCase::from_id("height_16384").is_some()); + assert!(AxisProbeCase::from_id("width_16385").is_none()); +} + +#[test] +fn encoded_byte_boundary_executes_the_public_operation() { + let exact = padded_png( + &hex::decode("89504e470d0a1a0a0000000d49484452000000020000000108020000007b40e8dd0000000f4944415408d763f8cfc0c0c0f01f00070001ff76d5a7600000000049454e44ae426082").unwrap(), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES as usize, + ); + assert_eq!( + exact.len() as u64, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES + ); + assert_eq!(verify(&exact, "png").unwrap(), (2, 1)); + + let mut one_over = exact; + one_over.push(0); + assert_eq!( + verify(&one_over, "png").unwrap_err().code(), + "publication_raster_byte_limit_exceeded" + ); +} + +fn resource_fixture_root() -> PathBuf { + env::var_os(RESOURCE_FIXTURE_ROOT_ENV) + .map(PathBuf::from) + .expect("resource fixture root must be supplied") +} + +fn resource_fixture(name: &str) -> Vec<u8> { + fs::read(resource_fixture_root().join(name)) + .expect("prepared resource fixture must be readable") +} + +fn jpeg_process(bytes: &[u8]) -> (u8, u8) { + let frames = bytes + .windows(10) + .filter_map(|window| { + (window[0] == 0xff && is_jpeg_start_of_frame(window[1])) + .then_some((window[1], window[4], window[9])) + }) + .collect::<Vec<_>>(); + assert_eq!(frames.len(), 1); + let (process, precision, components) = frames[0]; + assert_eq!(precision, 8); + (process, components) +} + +fn is_jpeg_start_of_frame(marker: u8) -> bool { + matches!( + marker, + 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf + ) +} + +fn png_process(bytes: &[u8]) -> (u8, u8) { + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR")); + assert_eq!(bytes[24], 8); + (bytes[25], bytes[28]) +} + +fn webp_process(bytes: &[u8]) -> ([u8; 4], bool) { + assert!(bytes.len() >= 20); + assert_eq!(&bytes[..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WEBP"); + assert_eq!( + u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize + 8, + bytes.len() + ); + + let mut position = 12_usize; + let mut primary = None; + let mut vp8x_alpha = false; + let mut alpha_chunk = false; + let mut vp8l_alpha = false; + while position < bytes.len() { + let kind: [u8; 4] = bytes[position..position + 4].try_into().unwrap(); + let length = + u32::from_le_bytes(bytes[position + 4..position + 8].try_into().unwrap()) as usize; + let data_start = position + 8; + let data_end = data_start + length; + let data = &bytes[data_start..data_end]; + position = data_end + (length & 1); + match &kind { + b"VP8X" => vp8x_alpha = data[0] & 0x10 != 0, + b"ALPH" => alpha_chunk = true, + b"VP8 " => assert!(primary.replace(kind).is_none()), + b"VP8L" => { + assert!(primary.replace(kind).is_none()); + let bits = u32::from_le_bytes(data[1..5].try_into().unwrap()); + vp8l_alpha = bits & (1 << 28) != 0; + } + _ => {} + } + } + assert_eq!(position, bytes.len()); + let primary = primary.expect("WebP primary chunk must exist"); + let alpha = if primary == *b"VP8L" { + vp8l_alpha + } else { + assert_eq!(vp8x_alpha, alpha_chunk); + vp8x_alpha + }; + (primary, alpha) +} + +fn padded_png(base: &[u8], target_length: usize) -> Vec<u8> { + assert!(base.len() >= 12); + assert!(base.ends_with(&png_chunk(*b"IEND", &[]))); + let padding_length = target_length + .checked_sub(base.len() + 12) + .expect("target length must leave room for an ancillary chunk"); + let iend_start = base.len() - 12; + let mut output = Vec::with_capacity(target_length); + output.extend_from_slice(&base[..iend_start]); + output.extend_from_slice(&png_chunk(*b"raDr", &vec![0; padding_length])); + output.extend_from_slice(&base[iend_start..]); + assert_eq!(output.len(), target_length); + output +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = u32::MAX; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = 0_u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} diff --git a/rust-toolchain-ios.toml b/rust-toolchain-ios.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.0" +profile = "minimal" +targets = ["aarch64-apple-ios"]