From dd8116db2492690ff79444b170430c430193b2fe Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Fri, 15 May 2026 15:07:09 -0400 Subject: [PATCH 1/2] dev-mcp: add view_image tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new tool on `sprout-dev-mcp` that loads an image from a file path, http(s) URL, or `data:` URL and returns it as an MCP `image` content block. The MCP host translates the block into the right multimodal shape for whichever provider it talks to — for OpenAI-compatible endpoints it becomes `image_url` with a data URL; for Anthropic it becomes a base64 `image` source. There is no provider-specific code in this crate. Default behaviour caps the longest edge at 1568px (Anthropic's published recommendation, comfortably inside OpenAI's high-detail tile budget) and the final payload at ~4 MiB on the wire (≤ 3 MiB raw × 4/3 base64 expansion). Already-small PNG/JPEG/GIF/WebP pass through verbatim; oversized inputs are decoded, resized with Lanczos3, and re-encoded as PNG (if the decoded image has alpha) or JPEG q85. Defences (all reachable from attacker-controlled `source`): - Source bytes hard-capped at 20 MiB across path/URL/data URL. - HTTP fetch streams via reqwest `chunk()` with a running byte counter; up-front rejection if Content-Length advertises over budget. 10-second connect+read timeout. Only http(s) accepted — other `scheme://` forms are rejected so they don't become filesystem paths. - Data URLs precheck encoded length before base64 decoding so we cannot allocate past the cap. Only base64 payloads accepted (no percent-encoded data URLs). - Pixel-count cap of 64 megapixels enforced after a cheap header-only dimension probe and before any decode, protecting against compressed sources that expand to hundreds of megabytes. - `image::Limits { max_alloc: 256 MiB }` set on the resize decoder as defence in depth. - Animation rejected with a clear error rather than collapsed to a silent first frame. Detection is an allocation-free byte-level scan of the GIF block structure / WebP VP8X+ANIM bit — we do not hand attacker-controlled dimensions to a decoder. - Path sources funnel through a shared `paths::resolve_within` helper that canonicalises against `workdir` (default cwd) and rejects any escape via `..`, absolute paths, or symlinks. File reads use `File::take(MAX_SOURCE_BYTES + 1)` so a file growing between the metadata check and the read still cannot exceed budget. Deps added to dev-mcp: `base64 = "0.22"`, `reqwest` (workspace — already used by sprout-cli, so this is essentially free in the sprout-dev-mcp binary), and `image = { default-features = false, features = ["jpeg", "png", "gif", "webp"] }`. No URL/HTTP parsing or hand-rolled crypto. `resolve_within` is moved to a new `paths` module shared by `str_replace` and `view_image`; the symlink-escape test moves with it. Tests cover: small-PNG pass-through (byte-equality), oversize-PNG with alpha resizes to PNG, oversize JPEG resizes to JPEG, path escape rejection, BMP rejected at magic-byte sniff, animated GIF rejected, single-frame GIF accepted, animated WebP VP8X+ANIM detection, data-URL round-trip, data-URL non-base64 rejected, data-URL non-image MIME rejected, oversized base64 payload pre-cap rejection, unknown URL scheme rejected, decompression bomb (synthetic 9000×9000 PNG IHDR) rejected at pixel cap. Signed-off-by: Tyler Longwell --- Cargo.lock | 3 + crates/sprout-dev-mcp/Cargo.toml | 5 + crates/sprout-dev-mcp/src/main.rs | 13 + crates/sprout-dev-mcp/src/paths.rs | 64 ++ crates/sprout-dev-mcp/src/str_replace.rs | 46 +- crates/sprout-dev-mcp/src/view_image.rs | 934 +++++++++++++++++++++++ 6 files changed, 1020 insertions(+), 45 deletions(-) create mode 100644 crates/sprout-dev-mcp/src/paths.rs create mode 100644 crates/sprout-dev-mcp/src/view_image.rs diff --git a/Cargo.lock b/Cargo.lock index 95c5ae4dc..a8ea1d4a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3960,11 +3960,14 @@ dependencies = [ name = "sprout-dev-mcp" version = "0.1.0" dependencies = [ + "base64", "git-credential-nostr", "git-sign-nostr", "ignore", + "image", "nix", "nostr", + "reqwest 0.13.3", "rmcp", "schemars", "serde", diff --git a/crates/sprout-dev-mcp/Cargo.toml b/crates/sprout-dev-mcp/Cargo.toml index 162d228a9..574ede553 100644 --- a/crates/sprout-dev-mcp/Cargo.toml +++ b/crates/sprout-dev-mcp/Cargo.toml @@ -26,6 +26,11 @@ tempfile = "3" ignore = "0.4.25" tracing = { workspace = true } tracing-subscriber = { workspace = true } +# view_image tool: HTTP fetch (workspace reqwest is already used by sprout-cli; +# adding it here is essentially free), base64 encoding, and decode/resize. +reqwest = { workspace = true } +base64 = "0.22" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/sprout-dev-mcp/src/main.rs b/crates/sprout-dev-mcp/src/main.rs index 617ca279d..b163f5a25 100644 --- a/crates/sprout-dev-mcp/src/main.rs +++ b/crates/sprout-dev-mcp/src/main.rs @@ -9,12 +9,14 @@ use rmcp::{ use std::path::Path; use std::sync::Arc; +mod paths; mod rg; mod shell; mod shim; mod str_replace; mod todo; mod tree; +mod view_image; #[derive(Clone)] struct DevMcp { @@ -45,6 +47,17 @@ impl DevMcp { shell::run(&self.state, p, context.ct).await } + #[tool( + name = "view_image", + description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it." + )] + async fn view_image( + &self, + Parameters(p): Parameters, + ) -> Result { + view_image::run(&self.state, p).await + } + #[tool( name = "str_replace", description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk." diff --git a/crates/sprout-dev-mcp/src/paths.rs b/crates/sprout-dev-mcp/src/paths.rs new file mode 100644 index 000000000..3fb605097 --- /dev/null +++ b/crates/sprout-dev-mcp/src/paths.rs @@ -0,0 +1,64 @@ +//! Path resolution shared across dev-mcp tools. +//! +//! `resolve_within` canonicalises a user-supplied path against a workspace +//! root and rejects any result that escapes the root (e.g. via `..`, absolute +//! paths, or symlinks). All tools that touch the filesystem must funnel +//! through this helper so the escape policy stays consistent. + +use std::path::{Path, PathBuf}; + +/// Resolve `path` (absolute or relative) against `root` and require the +/// canonicalised result to live under the canonicalised `root`. Returns an +/// error string suitable for `ErrorData::invalid_params` on rejection. +pub(crate) fn resolve_within(root: &Path, path: &str) -> Result { + let raw = Path::new(path); + let candidate: PathBuf = if raw.is_absolute() { + raw.to_path_buf() + } else { + root.join(raw) + }; + + let root_canon = std::fs::canonicalize(root) + .map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?; + + let resolved = std::fs::canonicalize(&candidate) + .map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?; + + if !resolved.starts_with(&root_canon) { + return Err(format!( + "path escapes workspace: {} not within {}", + resolved.display(), + root_canon.display() + )); + } + Ok(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn resolve_within_rejects_escape() { + let dir = tempdir().expect("tempdir"); + let inside = dir.path().join("file.txt"); + fs::write(&inside, b"x").expect("write"); + // Symlink targeting outside the dir should be rejected. + #[cfg(unix)] + { + let outside = std::env::temp_dir().join("sprout-mcp-paths-escape-target"); + let _ = fs::remove_file(&outside); + fs::write(&outside, b"y").expect("write outside"); + let link = dir.path().join("link.txt"); + std::os::unix::fs::symlink(&outside, &link).expect("symlink"); + let err = resolve_within(dir.path(), "link.txt").unwrap_err(); + assert!(err.contains("escapes workspace"), "got: {err}"); + let _ = fs::remove_file(&outside); + } + // Resolves a normal path inside. + let p = resolve_within(dir.path(), "file.txt").expect("resolve"); + assert!(p.ends_with("file.txt")); + } +} diff --git a/crates/sprout-dev-mcp/src/str_replace.rs b/crates/sprout-dev-mcp/src/str_replace.rs index 0a788bd0e..0251c97dc 100644 --- a/crates/sprout-dev-mcp/src/str_replace.rs +++ b/crates/sprout-dev-mcp/src/str_replace.rs @@ -154,29 +154,7 @@ pub fn run(state: &SharedState, p: StrReplaceParams) -> Result Result { - let raw = Path::new(path); - let candidate: PathBuf = if raw.is_absolute() { - raw.to_path_buf() - } else { - root.join(raw) - }; - - let root_canon = std::fs::canonicalize(root) - .map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?; - - let resolved = std::fs::canonicalize(&candidate) - .map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?; - - if !resolved.starts_with(&root_canon) { - return Err(format!( - "path escapes workspace: {} not within {}", - resolved.display(), - root_canon.display() - )); - } - Ok(resolved) -} +pub(crate) use crate::paths::resolve_within; pub(crate) fn count_occurrences_capped(text: &str, pattern: &str) -> usize { if pattern.is_empty() { @@ -301,28 +279,6 @@ mod tests { assert_eq!(count_occurrences_capped("abc", ""), 0); } - #[test] - fn resolve_within_rejects_escape() { - let dir = tempdir().expect("tempdir"); - let inside = dir.path().join("file.txt"); - fs::write(&inside, b"x").expect("write"); - // Symlink targeting outside the dir should be rejected. - #[cfg(unix)] - { - let outside = std::env::temp_dir().join("sprout-mcp-escape-target"); - let _ = fs::remove_file(&outside); - fs::write(&outside, b"y").expect("write outside"); - let link = dir.path().join("link.txt"); - std::os::unix::fs::symlink(&outside, &link).expect("symlink"); - let err = resolve_within(dir.path(), "link.txt").unwrap_err(); - assert!(err.contains("escapes workspace"), "got: {err}"); - let _ = fs::remove_file(&outside); - } - // Resolves a normal path inside. - let p = resolve_within(dir.path(), "file.txt").expect("resolve"); - assert!(p.ends_with("file.txt")); - } - fn make_state(cwd: &std::path::Path) -> SharedState { let shim = crate::shim::Shim::install().expect("shim install"); SharedState::new(cwd.to_path_buf(), shim).expect("state new") diff --git a/crates/sprout-dev-mcp/src/view_image.rs b/crates/sprout-dev-mcp/src/view_image.rs new file mode 100644 index 000000000..5221ca942 --- /dev/null +++ b/crates/sprout-dev-mcp/src/view_image.rs @@ -0,0 +1,934 @@ +//! `view_image` MCP tool — load an image from a path, http(s) URL, or +//! `data:` URL and return it as an MCP `image` content block that any +//! multimodal-capable host (Anthropic, OpenAI-compatible, etc.) can forward +//! to its model. +//! +//! Design goals: tiny surface, no protocol-specific branching, and a +//! "reasonable resolution" that fits comfortably inside both Anthropic's +//! recommended ≤1568px / ≤5 MiB image budget and OpenAI's high-detail tile +//! size sweet spot. The MCP host translates `Content::image(data, mime)` +//! into the right provider-native shape on our behalf (see Goose's +//! `providers::utils::convert_image` for a reference implementation). + +use crate::paths::resolve_within; +use crate::shell::SharedState; +use base64::Engine; +use image::{ + codecs::{jpeg::JpegEncoder, png::PngEncoder}, + DynamicImage, ExtendedColorType, ImageEncoder, ImageReader, Limits, +}; +use rmcp::{ + model::{CallToolResult, Content}, + ErrorData, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use std::io::Cursor; +use std::path::PathBuf; +use std::time::Duration; + +/// Hard cap on bytes we will read from disk / URL / data: URL. +pub(crate) const MAX_SOURCE_BYTES: usize = 20 * 1024 * 1024; +/// Hard cap on the raw (pre-base64) bytes we emit. base64 expands by 4/3, so +/// a 3 MiB raw payload becomes ~4 MiB on the wire — comfortably below +/// Anthropic's 5 MiB-per-image limit. +pub(crate) const MAX_FINAL_RAW_BYTES: usize = 3 * 1024 * 1024; +/// Default longest-edge cap. Matches Anthropic's published recommendation +/// (≤1568px) and lands well inside OpenAI's high-detail tile budget. +pub(crate) const DEFAULT_MAX_DIM: u32 = 1568; +pub(crate) const MIN_MAX_DIM: u32 = 64; +pub(crate) const MAX_MAX_DIM: u32 = 2048; +/// Hard cap on decoded pixel count. A ≤20 MiB compressed source can decode +/// to hundreds of megabytes; we reject anything above this budget *before* +/// touching the decoder. 64 megapixels is generous (e.g. 8000×8000) yet +/// keeps worst-case allocation well under a gigabyte. +pub(crate) const MAX_PIXELS: u64 = 64 * 1024 * 1024; +/// Defence-in-depth for the `image` decoder: bound any single allocation it +/// performs to 256 MiB (the default is 512 MiB and skews high for a dev MCP). +pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; +/// Connect + read timeout for URL fetches. +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); + +/// Build the decoder allocation cap. Centralised so the resize path uses the +/// same value tests can reason about. +fn decode_limits() -> Limits { + let mut l = Limits::default(); + l.max_alloc = Some(MAX_DECODER_ALLOC); + l +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ViewImageParams { + /// Image source: an absolute or workspace-relative file path, + /// an `http://` / `https://` URL, or a `data:image/;base64,...` URL. + pub source: String, + /// Optional longest-edge cap in pixels. Clamped to [64, 2048]. + /// Defaults to 1568, which fits Anthropic's recommended budget and + /// OpenAI's high-detail tile size. + #[serde(default)] + pub max_dim: Option, + /// Workspace root for relative path resolution. Ignored for URL sources. + /// Defaults to the server's cwd. + #[serde(default)] + pub workdir: Option, +} + +/// What `view_image` returns: the (mime, raw bytes) we will base64-encode +/// into an MCP image content block, plus a short human-readable summary. +#[derive(Debug)] +struct PreparedImage { + mime: &'static str, + bytes: Vec, + summary: String, +} + +pub async fn run(state: &SharedState, p: ViewImageParams) -> Result { + let max_dim = p + .max_dim + .unwrap_or(DEFAULT_MAX_DIM) + .clamp(MIN_MAX_DIM, MAX_MAX_DIM); + + let (raw, source_label) = load_source(state, &p).await?; + let prepared = prepare(&raw, max_dim).map_err(invalid_params)?; + + let encoded = base64::engine::general_purpose::STANDARD.encode(&prepared.bytes); + let header = format!( + "{} ({} from {source_label})", + prepared.summary, prepared.mime + ); + + Ok(CallToolResult::success(vec![ + Content::text(header), + Content::image(encoded, prepared.mime.to_string()), + ])) +} + +fn invalid_params(msg: String) -> ErrorData { + ErrorData::invalid_params(msg, None) +} + +/// Fetch the source bytes from path / http(s) / data URL. +async fn load_source( + state: &SharedState, + p: &ViewImageParams, +) -> Result<(Vec, String), ErrorData> { + let src = p.source.trim(); + if src.starts_with("data:") { + let bytes = decode_data_url(src).map_err(invalid_params)?; + // `decode_data_url` enforces an encoded-length precheck so we never + // allocate past the source cap. Re-verify the decoded length for + // belt-and-braces. + if bytes.len() > MAX_SOURCE_BYTES { + return Err(invalid_params(format!( + "data: URL decoded to {} bytes (limit {} bytes)", + bytes.len(), + MAX_SOURCE_BYTES + ))); + } + Ok((bytes, "data:URL".to_string())) + } else if src.starts_with("http://") || src.starts_with("https://") { + let bytes = fetch_url(src).await?; + Ok((bytes, src.to_string())) + } else if src.contains("://") { + // Treat any other `scheme://...` form as an explicit reject so + // `ftp://...` doesn't accidentally become a filesystem path. + Err(invalid_params(format!( + "unsupported URL scheme in `source`: {src}", + ))) + } else { + let workspace_root = match p.workdir.as_deref() { + Some(w) => PathBuf::from(w), + None => state.cwd.clone(), + }; + let target = resolve_within(&workspace_root, src).map_err(invalid_params)?; + let meta = std::fs::metadata(&target).map_err(|e| { + ErrorData::internal_error(format!("cannot stat {}: {e}", target.display()), None) + })?; + if !meta.is_file() { + return Err(invalid_params(format!( + "not a regular file: {}", + target.display() + ))); + } + if meta.len() as usize > MAX_SOURCE_BYTES { + return Err(invalid_params(format!( + "file too large: {} is {} bytes (limit {} bytes)", + target.display(), + meta.len(), + MAX_SOURCE_BYTES + ))); + } + // Use `take(cap + 1)` so a file that grows between the metadata + // check and the read still cannot exceed our budget. The +1 + // distinguishes "exactly at cap" from "grew past cap". + let file = std::fs::File::open(&target).map_err(|e| { + ErrorData::internal_error(format!("cannot open {}: {e}", target.display()), None) + })?; + let mut bytes = Vec::with_capacity(meta.len() as usize); + use std::io::Read; + file.take(MAX_SOURCE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| { + ErrorData::internal_error(format!("cannot read {}: {e}", target.display()), None) + })?; + if bytes.len() > MAX_SOURCE_BYTES { + return Err(invalid_params(format!( + "file {} grew past {} byte cap during read", + target.display(), + MAX_SOURCE_BYTES + ))); + } + Ok((bytes, target.display().to_string())) + } +} + +/// Parse `data:image/[;base64],`. Only base64 payloads are +/// accepted — percent-encoded data URLs add surface area for no real benefit. +fn decode_data_url(src: &str) -> Result, String> { + let rest = src + .strip_prefix("data:") + .ok_or_else(|| "not a data: URL".to_string())?; + let (meta, payload) = rest + .split_once(',') + .ok_or_else(|| "malformed data: URL (no comma)".to_string())?; + // meta is "[;param=value]*[;base64]" + let mut parts = meta.split(';'); + let mime = parts.next().unwrap_or(""); + if !mime.starts_with("image/") { + return Err(format!("data: URL is not an image (got `{mime}`)")); + } + let is_base64 = parts.any(|p| p.eq_ignore_ascii_case("base64")); + if !is_base64 { + return Err( + "data: URL must be base64-encoded (non-base64 / percent-encoded forms are not supported)" + .to_string(), + ); + } + let payload = payload.trim(); + // Pre-check encoded length so we never allocate past the source cap. + // 4 base64 chars encode 3 raw bytes; ceil-divide MAX_SOURCE_BYTES. + let max_encoded = MAX_SOURCE_BYTES.div_ceil(3) * 4 + 4; // +4 absorbs padding rounding + if payload.len() > max_encoded { + return Err(format!( + "data: URL payload is {} base64 chars (limit ~{} = {} raw bytes)", + payload.len(), + max_encoded, + MAX_SOURCE_BYTES + )); + } + base64::engine::general_purpose::STANDARD + .decode(payload) + .map_err(|e| format!("data: URL base64 decode failed: {e}")) +} + +/// Fetch an http(s) URL with a streaming read and a hard byte cap. +/// Refuses up-front if `Content-Length` advertises more than the cap. +async fn fetch_url(url: &str) -> Result, ErrorData> { + let client = reqwest::Client::builder() + .connect_timeout(FETCH_TIMEOUT) + .timeout(FETCH_TIMEOUT) + .build() + .map_err(|e| ErrorData::internal_error(format!("http client init failed: {e}"), None))?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| ErrorData::internal_error(format!("fetch failed: {url} ({e})"), None))?; + if !resp.status().is_success() { + return Err(invalid_params(format!( + "fetch {url} returned HTTP {}", + resp.status() + ))); + } + if let Some(len) = resp.content_length() { + if len as usize > MAX_SOURCE_BYTES { + return Err(invalid_params(format!( + "remote image too large: Content-Length {} bytes (limit {})", + len, MAX_SOURCE_BYTES + ))); + } + } + let mut buf: Vec = Vec::new(); + let mut stream = resp; + loop { + let chunk = stream + .chunk() + .await + .map_err(|e| ErrorData::internal_error(format!("fetch read failed: {e}"), None))?; + match chunk { + Some(bytes) => { + if buf.len() + bytes.len() > MAX_SOURCE_BYTES { + return Err(invalid_params(format!( + "remote image exceeded {} byte cap mid-stream", + MAX_SOURCE_BYTES + ))); + } + buf.extend_from_slice(&bytes); + } + None => break, + } + } + Ok(buf) +} + +/// Sniff the image format from magic bytes alone (do not trust extensions +/// or `Content-Type`). Returns the canonical MIME type. +fn sniff_mime(bytes: &[u8]) -> Result<&'static str, String> { + // PNG: 89 50 4E 47 0D 0A 1A 0A + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Ok("image/png"); + } + // JPEG: FF D8 FF + if bytes.len() >= 3 && bytes[0..3] == [0xFF, 0xD8, 0xFF] { + return Ok("image/jpeg"); + } + // GIF87a / GIF89a + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Ok("image/gif"); + } + // WebP: "RIFF" .... "WEBP" + if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Ok("image/webp"); + } + Err("unsupported image format (recognised: png, jpeg, gif, webp)".to_string()) +} + +/// Detect animated GIF (≥2 image descriptors) or animated WebP (VP8X chunk +/// with ANIM bit set). We refuse animated input outright rather than +/// silently emit a first-frame still. +/// +/// **Important**: both branches must be allocation-free byte-level scans. +/// Using the `image` crate's `GifDecoder::into_frames()` here would let an +/// attacker-controlled logical-screen size trigger a multi-GB RGBA buffer +/// before our pixel-count cap fires. +fn is_animated(bytes: &[u8], mime: &str) -> bool { + match mime { + "image/gif" => gif_has_two_image_descriptors(bytes), + "image/webp" => { + // Animated WebP files always use the extended (VP8X) container. + // The animation bit is bit 1 of the flags byte at offset 20. + if bytes.len() < 21 { + return false; + } + if &bytes[12..16] != b"VP8X" { + return false; + } + (bytes[20] & 0x02) != 0 + } + _ => false, + } +} + +/// Scan a GIF byte stream and report whether it contains ≥2 image descriptors +/// (frames). Does not allocate decode buffers — walks the block structure +/// described in the GIF89a spec and bails on the second `0x2C` separator. +fn gif_has_two_image_descriptors(bytes: &[u8]) -> bool { + // 6-byte header ("GIF87a"/"GIF89a") + 7-byte logical screen descriptor. + if bytes.len() < 13 { + return false; + } + let packed = bytes[10]; + let has_gct = (packed & 0x80) != 0; + let gct_size = if has_gct { + 3 * (1u32 << ((packed & 0x07) + 1)) + } else { + 0 + }; + let mut i = 13usize + gct_size as usize; + let mut frames = 0u32; + while let Some(&b) = bytes.get(i) { + i += 1; + match b { + 0x3B => return frames >= 2, // trailer + 0x21 => { + // Extension introducer: