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:
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"]