Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs
T
suzmii 181e364d80 AGC 资源卡棋盘格底改按图像真实 alpha 判定
- 原生预览新增头部级 alpha 判据:`LocalProjectImagePreview.hasAlpha`(PNG colorType 4/6 或 tRNS、WebP VP8X/VP8L alpha 标志;JPEG 恒 false),只读签名与 chunk 头、不做像素解码。
- 前端预览 payload 增加可选 `hasAlpha`,并在 `materializeProjectResourceCardPreview` 的两条分支里透传。
- 资源卡根节点新增排障属性 `data-preview-has-alpha`:只有预览已加载且判据为真才写 `'true'`,其余一律不写(缺属性与不透明同档)。
- 棋盘格底选择器加 `[data-preview-has-alpha='true']` 条件;无 alpha 时退回卡片既有纯色底,不引入第二套底色。
- 新增 TS 用例(真渲染资源卡 + styles.css 声明级层叠求值)与 Rust 用例(PNG/WebP/JPEG 头部判据、tRNS、坏文件失败关闭、非解码证明、IPC 字段序列化契约)。
- 验收文档补记 `data-preview-has-alpha` 判据与「真假棋盘格」取证脚本,便于现场区分真透明底与 AI 画出的假棋盘格。
2026-09-14 17:13:43 +08:00

1312 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
use base64::Engine as _;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
use std::path::Path;
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_IMAGES: usize = 2;
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES: u64 = 12 * 1024 * 1024;
const PROJECT_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 8_192;
const PROJECT_IMAGE_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
const PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectImagePreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) pixel_width: u32,
pub(crate) pixel_height: u32,
/// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。
///
/// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」,
/// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图
/// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。
pub(crate) has_alpha: bool,
pub(crate) data_url: String,
}
pub(crate) struct AgentRuntimeInspectionImage {
pub(crate) relative_path: String,
/// 只有 Agent 视觉检查(`image.inspect`)需要内容摘要:它要把摘要写进动作审计并按摘要
/// 复核「检查过的就是当前这张图」。资源卡预览只需要字节、媒体类型和像素尺寸,摘要没有
/// 任何消费者,因此预览路径不计算它。取摘要必须走 [`Self::sha256_digest`],缺摘要即失败关闭。
pub(crate) sha256: Option<String>,
pub(crate) byte_len: u64,
pub(crate) media_type: &'static str,
pixel_width: u32,
pixel_height: u32,
bytes: Vec<u8>,
}
impl AgentRuntimeInspectionImage {
pub(crate) fn data_url(&self) -> String {
format!(
"data:{};base64,{}",
self.media_type,
base64::engine::general_purpose::STANDARD.encode(&self.bytes)
)
}
pub(crate) fn sha256_digest(&self) -> Result<&str, String> {
self.sha256.as_deref().ok_or_else(|| {
format!(
"image.inspect 缺少内容摘要:{};摘要只在视觉检查读取路径上计算",
self.relative_path
)
})
}
fn data_url_with_cancellation(
&self,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<String, String> {
cancellation.check()?;
Ok(self.data_url())
}
}
#[cfg(test)]
pub(crate) fn load_local_project_image_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectImagePreview, String> {
load_local_project_image_preview_with_cancellation(
root,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_image_preview_with_cancellation(
root: &Path,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectImagePreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
if !normalized.starts_with("assets/") && !normalized.starts_with("game/") {
return Err("图片预览只允许读取 assets/ 或 game/ 下的项目图片".to_string());
}
reject_sensitive_project_file_read(&normalized)?;
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
cancellation.check()?;
let image = read_agent_runtime_inspection_image_with_cancellation(
&absolute,
normalized,
cancellation,
false,
)?;
cancellation.check()?;
// 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`),
// 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」
// 的不透明图都不会因此变慢。
let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type);
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
pixel_width: image.pixel_width,
pixel_height: image.pixel_height,
has_alpha,
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
pub(crate) fn load_agent_runtime_inspection_images(
root: &Path,
agent_id: &str,
run_id: &str,
paths: &[String],
) -> Result<Vec<AgentRuntimeInspectionImage>, String> {
if paths.is_empty() || paths.len() > AGENT_RUNTIME_IMAGE_INSPECT_MAX_IMAGES {
return Err(format!(
"image.inspect 的 paths 必须包含 1-{} 张图片",
AGENT_RUNTIME_IMAGE_INSPECT_MAX_IMAGES
));
}
let expected_agent = runtime_path_component(agent_id, "agent");
let expected_run = runtime_path_component(run_id, "run");
let mut unique_paths = BTreeSet::new();
let mut images = Vec::with_capacity(paths.len());
let mut total_bytes = 0_u64;
for path in paths {
let normalized = resolve_agent_runtime_inspection_path(
root,
&expected_agent,
&expected_run,
path.trim(),
)?;
if !unique_paths.insert(normalized.clone()) {
return Err(format!("image.inspect 不能重复读取同一图片:{normalized}"));
}
validate_agent_runtime_inspection_path(&normalized, &expected_agent, &expected_run)?;
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
let image = read_agent_runtime_inspection_image(&absolute, normalized)?;
total_bytes = total_bytes
.checked_add(image.byte_len)
.ok_or_else(|| "image.inspect 图片总大小溢出".to_string())?;
if total_bytes > AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES {
return Err(format!(
"image.inspect 图片总大小不能超过 {} MiB",
AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES / 1024 / 1024
));
}
images.push(image);
}
Ok(images)
}
fn resolve_agent_runtime_inspection_path(
root: &Path,
expected_agent: &str,
expected_run: &str,
requested_path: &str,
) -> Result<String, String> {
let normalized = normalize_relative_path(requested_path)?;
if !matches!(normalized.as_str(), "desktop.png" | "mobile.png") {
return Ok(normalized);
}
let validation_root =
format!(".agent/runtime/browser-validations/{expected_agent}/{expected_run}");
let validation_root_path = resolve_local_project_path(root, &validation_root)?;
validate_agent_runtime_inspection_ancestors(
root,
&validation_root_path.join("revision-placeholder"),
)?;
let entries = fs::read_dir(&validation_root_path)
.map_err(|error| format!("读取当前 Agent/run 浏览器截图目录失败:{error}"))?;
let mut latest_revision = None;
for entry in entries {
let entry = entry.map_err(|error| format!("读取浏览器截图目录项失败:{error}"))?;
let file_type = entry
.file_type()
.map_err(|error| format!("读取浏览器截图目录项类型失败:{error}"))?;
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let Some(revision) = entry
.file_name()
.to_str()
.filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
.and_then(|value| value.parse::<u64>().ok())
else {
continue;
};
if entry.path().join(&normalized).is_file() {
latest_revision =
Some(latest_revision.map_or(revision, |current: u64| current.max(revision)));
}
}
let revision = latest_revision
.ok_or_else(|| format!("当前 Agent/run 尚无可用的 {normalized} 浏览器截图"))?;
Ok(format!("{validation_root}/{revision}/{normalized}"))
}
pub(crate) fn redact_agent_runtime_image_data_urls(value: &str) -> String {
const PREFIX: &str = "data:image/";
let mut output = String::with_capacity(value.len());
let mut remaining = value;
while let Some(index) = remaining.find(PREFIX) {
output.push_str(&remaining[..index]);
output.push_str("<image-data-omitted>");
let tail = &remaining[index + PREFIX.len()..];
let end = tail
.find(|character: char| {
character.is_ascii_whitespace() || matches!(character, '"' | '\'' | ')' | ']' | '}')
})
.unwrap_or(tail.len());
remaining = &tail[end..];
}
output.push_str(remaining);
output
}
fn validate_agent_runtime_inspection_path(
normalized: &str,
expected_agent: &str,
expected_run: &str,
) -> Result<(), String> {
if normalized.starts_with("game/") || normalized.starts_with("assets/") {
reject_sensitive_project_file_read(normalized)?;
return Ok(());
}
let parts = normalized.split('/').collect::<Vec<_>>();
let is_current_runtime_screenshot = parts.len() == 7
&& parts[0] == ".agent"
&& parts[1] == "runtime"
&& parts[2] == "browser-validations"
&& parts[3] == expected_agent
&& parts[4] == expected_run
&& !parts[5].is_empty()
&& parts[5].chars().all(|character| character.is_ascii_digit())
&& matches!(parts[6], "desktop.png" | "mobile.png");
if !is_current_runtime_screenshot {
return Err(
"image.inspect 只允许 game/、assets/ 或当前 Agent/run 的桌面与移动浏览器截图"
.to_string(),
);
}
Ok(())
}
pub(crate) fn validate_agent_runtime_inspection_ancestors(
root: &Path,
path: &Path,
) -> Result<(), String> {
let relative = path
.strip_prefix(root)
.map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?;
let mut current = root.to_path_buf();
for component in relative
.components()
.take(relative.components().count().saturating_sub(1))
{
current.push(component.as_os_str());
let metadata = fs::symlink_metadata(&current)
.map_err(|error| format!("读取 image.inspect 图片父目录失败:{error}"))?;
if metadata.file_type().is_symlink()
|| metadata_is_windows_reparse_point(&metadata)
|| !metadata.is_dir()
{
return Err(
"image.inspect 图片父目录必须是普通目录且不能是符号链接或 reparse point"
.to_string(),
);
}
}
Ok(())
}
fn read_agent_runtime_inspection_image(
path: &Path,
relative_path: String,
) -> Result<AgentRuntimeInspectionImage, String> {
read_agent_runtime_inspection_image_with_cancellation(
path,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
true,
)
}
/// 单次受控读取:门禁、分块读取、漂移与替换复核、签名 / 尺寸校验对两条调用方完全一致,
/// 只有内容摘要按 `include_sha256` 分叉。
///
/// `include_sha256 = true` 是 Agent 视觉检查路径(`image.inspect`):摘要要写进动作审计,并在
/// `project_gates` 里按摘要复核「检查过的就是当前这张图」。资源卡预览路径传 `false`,因为它的
/// `LocalProjectImagePreview` 从来不消费摘要,只为它多算一遍全长 SHA-256 是纯开销。
fn read_agent_runtime_inspection_image_with_cancellation(
path: &Path,
relative_path: String,
cancellation: &ProjectResourcePreviewScopeCancellation,
include_sha256: bool,
) -> Result<AgentRuntimeInspectionImage, String> {
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
cancellation.check()?;
if initial_metadata.len() == 0 {
return Err(format!("image.inspect 图片不能为空:{relative_path}"));
}
if initial_metadata.len() > AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES {
return Err(format!(
"image.inspect 单张图片不能超过 {} MiB{relative_path}",
AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES / 1024 / 1024
));
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
let mut chunk = [0_u8; PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES];
loop {
cancellation.check()?;
let remaining =
(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1).saturating_sub(bytes.len() as u64);
if remaining == 0 {
break;
}
let read_len = usize::try_from(remaining)
.unwrap_or(usize::MAX)
.min(chunk.len());
let count = file
.read(&mut chunk[..read_len])
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
if count == 0 {
break;
}
bytes.extend_from_slice(&chunk[..count]);
}
cancellation.check()?;
if bytes.len() as u64 > AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES {
return Err(format!(
"image.inspect 单张图片不能超过 {} MiB{relative_path}",
AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES / 1024 / 1024
));
}
let final_metadata = file
.metadata()
.map_err(|error| format!("复核 image.inspect 图片失败:{relative_path}: {error}"))?;
if initial_metadata.len() != bytes.len() as u64
|| final_metadata.len() != bytes.len() as u64
|| !same_open_file_snapshot(&initial_metadata, &final_metadata)
{
return Err(format!(
"image.inspect 图片读取期间发生漂移:{relative_path}"
));
}
cancellation.check()?;
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!(
"image.inspect 图片路径读取期间发生替换:{relative_path}"
));
}
cancellation.check()?;
let media_type = detect_agent_runtime_image_media_type(&bytes)
.ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG 或 WEBP{relative_path}"))?;
cancellation.check()?;
let (width, height) = detect_raster_image_dimensions(&bytes, media_type)
.ok_or_else(|| format!("image.inspect 图片结构无效:{relative_path}"))?;
let pixels = u64::from(width)
.checked_mul(u64::from(height))
.ok_or_else(|| format!("image.inspect 图片尺寸溢出:{relative_path}"))?;
if width == 0
|| height == 0
|| width > PROJECT_IMAGE_PREVIEW_MAX_DIMENSION
|| height > PROJECT_IMAGE_PREVIEW_MAX_DIMENSION
|| pixels > PROJECT_IMAGE_PREVIEW_MAX_PIXELS
{
return Err(format!(
"image.inspect 图片尺寸过大:{width}x{height},最大边长 {PROJECT_IMAGE_PREVIEW_MAX_DIMENSION},最大像素 {PROJECT_IMAGE_PREVIEW_MAX_PIXELS}{relative_path}"
));
}
cancellation.check()?;
let sha256 = include_sha256.then(|| format!("{:x}", Sha256::digest(&bytes)));
Ok(AgentRuntimeInspectionImage {
relative_path,
sha256,
byte_len: bytes.len() as u64,
media_type,
pixel_width: width,
pixel_height: height,
bytes,
})
}
fn detect_agent_runtime_image_media_type(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
Some("image/jpeg")
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> {
match media_type {
"image/png" if bytes.len() >= 24 && &bytes[12..16] == b"IHDR" => Some((
u32::from_be_bytes(bytes[16..20].try_into().ok()?),
u32::from_be_bytes(bytes[20..24].try_into().ok()?),
)),
"image/jpeg" => detect_jpeg_dimensions(bytes),
"image/webp" => detect_webp_dimensions(bytes),
_ => None,
}
}
/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志
/// PNG 还会按 chunk 头跳过数据体找 `tRNS`)。
///
/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取,
/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB),
/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。
///
/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格):
/// - PNG:颜色类型 4(灰度 + alpha/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道,
/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`
/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位;
/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`);
/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。
fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool {
match media_type {
"image/png" => detect_png_has_alpha(bytes),
"image/webp" => detect_webp_has_alpha(bytes),
_ => false,
}
}
fn detect_png_has_alpha(bytes: &[u8]) -> bool {
// 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。
if bytes.len() < 26 || &bytes[12..16] != b"IHDR" {
return false;
}
if matches!(bytes[25], 4 | 6) {
return true;
}
png_has_transparency_chunk(bytes)
}
/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。
fn png_has_transparency_chunk(bytes: &[u8]) -> bool {
let mut offset = 8usize;
loop {
let Some(header_end) = offset.checked_add(8) else {
return false;
};
if header_end > bytes.len() {
return false;
}
let chunk_type = &bytes[offset + 4..header_end];
// `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。
if chunk_type == b"tRNS" {
return true;
}
if chunk_type == b"IDAT" || chunk_type == b"IEND" {
return false;
}
let chunk_len =
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize;
let Some(next) = header_end
.checked_add(chunk_len)
.and_then(|value| value.checked_add(4))
else {
return false;
};
if next <= offset || next > bytes.len() {
return false;
}
offset = next;
}
}
fn detect_webp_has_alpha(bytes: &[u8]) -> bool {
if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return false;
}
match &bytes[12..16] {
// `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。
b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0),
// `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。
b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0,
_ => false,
}
}
#[derive(Clone, Copy)]
enum TiffByteOrder {
LittleEndian,
BigEndian,
}
fn read_tiff_u16(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option<u16> {
let end = offset.checked_add(2)?;
let value: [u8; 2] = bytes.get(offset..end)?.try_into().ok()?;
Some(match byte_order {
TiffByteOrder::LittleEndian => u16::from_le_bytes(value),
TiffByteOrder::BigEndian => u16::from_be_bytes(value),
})
}
fn read_tiff_u32(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option<u32> {
let end = offset.checked_add(4)?;
let value: [u8; 4] = bytes.get(offset..end)?.try_into().ok()?;
Some(match byte_order {
TiffByteOrder::LittleEndian => u32::from_le_bytes(value),
TiffByteOrder::BigEndian => u32::from_be_bytes(value),
})
}
fn detect_jpeg_exif_orientation(app1_payload: &[u8]) -> Option<u16> {
let tiff = app1_payload.strip_prefix(b"Exif\0\0")?;
let byte_order = match tiff.get(..2)? {
b"II" => TiffByteOrder::LittleEndian,
b"MM" => TiffByteOrder::BigEndian,
_ => return None,
};
if read_tiff_u16(tiff, 2, byte_order)? != 42 {
return None;
}
let ifd_offset = usize::try_from(read_tiff_u32(tiff, 4, byte_order)?).ok()?;
let entry_count = usize::from(read_tiff_u16(tiff, ifd_offset, byte_order)?);
let entries_start = ifd_offset.checked_add(2)?;
let entries_end = entries_start.checked_add(entry_count.checked_mul(12)?)?;
if entries_end > tiff.len() {
return None;
}
for entry_index in 0..entry_count {
let entry_offset = entries_start.checked_add(entry_index.checked_mul(12)?)?;
if read_tiff_u16(tiff, entry_offset, byte_order)? != 0x0112 {
continue;
}
if read_tiff_u16(tiff, entry_offset.checked_add(2)?, byte_order)? != 3
|| read_tiff_u32(tiff, entry_offset.checked_add(4)?, byte_order)? != 1
{
return None;
}
let orientation = read_tiff_u16(tiff, entry_offset.checked_add(8)?, byte_order)?;
return (1..=8).contains(&orientation).then_some(orientation);
}
None
}
fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if !bytes.starts_with(&[0xff, 0xd8]) {
return None;
}
let mut index = 2usize;
let mut exif_orientation = None;
let mut dimensions = None;
while index + 3 < bytes.len() {
if bytes[index] != 0xff {
index += 1;
continue;
}
while index < bytes.len() && bytes[index] == 0xff {
index += 1;
}
let marker = *bytes.get(index)?;
index += 1;
if marker == 0xd9 || marker == 0xda {
break;
}
if marker == 0x01 || (0xd0..=0xd8).contains(&marker) {
continue;
}
let segment_len = usize::from(u16::from_be_bytes([
*bytes.get(index)?,
*bytes.get(index + 1)?,
]));
if segment_len < 2 || index.checked_add(segment_len)? > bytes.len() {
return None;
}
if marker == 0xe1 && exif_orientation.is_none() {
let payload_start = index.checked_add(2)?;
let payload_end = index.checked_add(segment_len)?;
exif_orientation = detect_jpeg_exif_orientation(bytes.get(payload_start..payload_end)?);
}
if dimensions.is_none()
&& matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
)
&& segment_len >= 7
{
let height = u32::from(u16::from_be_bytes([
*bytes.get(index + 3)?,
*bytes.get(index + 4)?,
]));
let width = u32::from(u16::from_be_bytes([
*bytes.get(index + 5)?,
*bytes.get(index + 6)?,
]));
dimensions = Some((width, height));
}
index += segment_len;
}
dimensions.map(|(width, height)| {
if matches!(exif_orientation, Some(5..=8)) {
(height, width)
} else {
(width, height)
}
})
}
fn detect_webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 30 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return None;
}
match &bytes[12..16] {
b"VP8X" if bytes.len() >= 30 => {
let width = 1
+ u32::from(bytes[24])
+ (u32::from(bytes[25]) << 8)
+ (u32::from(bytes[26]) << 16);
let height = 1
+ u32::from(bytes[27])
+ (u32::from(bytes[28]) << 8)
+ (u32::from(bytes[29]) << 16);
Some((width, height))
}
b"VP8 " if bytes.len() >= 30 && bytes[23..26] == [0x9d, 0x01, 0x2a] => Some((
u32::from(u16::from_le_bytes([bytes[26], bytes[27]]) & 0x3fff),
u32::from(u16::from_le_bytes([bytes[28], bytes[29]]) & 0x3fff),
)),
b"VP8L" if bytes.len() >= 25 && bytes[20] == 0x2f => Some((
1 + u32::from(bytes[21]) + ((u32::from(bytes[22]) & 0x3f) << 8),
1 + (u32::from(bytes[22]) >> 6)
+ (u32::from(bytes[23]) << 2)
+ ((u32::from(bytes[24]) & 0x0f) << 10),
)),
_ => None,
}
}
fn runtime_path_component(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric()
|| character == '-'
|| character == '_'
|| character == '.'
{
character
} else {
'-'
}
})
.collect::<String>();
let normalized = normalized.trim_matches('-');
if normalized.is_empty() {
fallback.to_string()
} else {
normalized.chars().take(160).collect()
}
}
#[cfg(windows)]
pub(crate) fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
pub(crate) fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
/// 已打开句柄的跨平台文件身份:Windows 用 `(volume serial, file index)`Unix 用 `(dev, ino)`。
/// 资源卡预览的 manifest 缓存用它判断「路径指向的还是同一个文件」。
pub(crate) fn open_file_identity_key(file: &fs::File) -> Result<(u64, u64), String> {
#[cfg(windows)]
{
let (volume_serial_number, file_index) = windows_file_identity(file)?;
Ok((u64::from(volume_serial_number), file_index))
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let metadata = file
.metadata()
.map_err(|error| format!("读取文件身份元数据失败:{error}"))?;
Ok((metadata.dev(), metadata.ino()))
}
#[cfg(not(any(unix, windows)))]
{
let _ = file;
Err("当前平台不支持文件身份判定".to_string())
}
}
#[cfg(unix)]
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
left.dev() == right.dev()
&& left.ino() == right.ino()
&& left.nlink() == right.nlink()
&& left.len() == right.len()
&& left.mtime() == right.mtime()
&& left.mtime_nsec() == right.mtime_nsec()
&& left.ctime() == right.ctime()
&& left.ctime_nsec() == right.ctime_nsec()
}
#[cfg(not(unix))]
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
left.len() == right.len() && left.modified().ok() == right.modified().ok()
}
#[cfg(unix)]
pub(crate) fn same_open_file_identity(
_left_file: &fs::File,
left: &fs::Metadata,
_right_file: &fs::File,
right: &fs::Metadata,
) -> Result<bool, String> {
use std::os::unix::fs::MetadataExt;
Ok(left.dev() == right.dev() && left.ino() == right.ino())
}
#[cfg(windows)]
pub(crate) fn same_open_file_identity(
left_file: &fs::File,
_left: &fs::Metadata,
right_file: &fs::File,
_right: &fs::Metadata,
) -> Result<bool, String> {
Ok(windows_file_identity(left_file)? == windows_file_identity(right_file)?)
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn same_open_file_identity(
_left_file: &fs::File,
left: &fs::Metadata,
_right_file: &fs::File,
right: &fs::Metadata,
) -> Result<bool, String> {
Ok(left.len() == right.len() && left.modified().ok() == right.modified().ok())
}
#[cfg(windows)]
fn windows_file_identity(file: &fs::File) -> Result<(u32, u64), String> {
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle;
#[repr(C)]
struct FileTime {
low_date_time: u32,
high_date_time: u32,
}
#[repr(C)]
struct ByHandleFileInformation {
file_attributes: u32,
creation_time: FileTime,
last_access_time: FileTime,
last_write_time: FileTime,
volume_serial_number: u32,
file_size_high: u32,
file_size_low: u32,
number_of_links: u32,
file_index_high: u32,
file_index_low: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetFileInformationByHandle(
file: *mut c_void,
information: *mut ByHandleFileInformation,
) -> i32;
}
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
let mut information = unsafe { std::mem::zeroed::<ByHandleFileInformation>() };
// SAFETY: file owns a live handle and information is a valid output pointer.
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 {
return Err(format!(
"读取 image.inspect Windows 文件身份失败:{}",
std::io::Error::last_os_error()
));
}
Ok((
information.volume_serial_number,
(u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn png_bytes() -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
.expect("valid 1x1 png")
}
/// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。
fn png_header(color_type: u8) -> Vec<u8> {
png_header_with_size(color_type, 1, 1)
}
fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(8);
ihdr.push(color_type);
ihdr.extend_from_slice(&[0, 0, 0]);
push_png_chunk(&mut bytes, b"IHDR", &ihdr);
bytes
}
/// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。
/// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明
/// 判据没有解码像素。
fn push_png_chunk(bytes: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
bytes.extend_from_slice(
&u32::try_from(data.len())
.expect("chunk length")
.to_be_bytes(),
);
bytes.extend_from_slice(kind);
bytes.extend_from_slice(data);
bytes.extend_from_slice(&[0, 0, 0, 0]);
}
/// 扩展格式 WebP`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。
fn webp_vp8x(flags: u8) -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8X");
bytes.extend_from_slice(&10_u32.to_le_bytes());
bytes.push(flags);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[0, 0, 0]);
bytes
}
/// 无损 WebP`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。
fn webp_vp8l(has_alpha: bool) -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8L");
bytes.extend_from_slice(&5_u32.to_le_bytes());
bytes.push(0x2f);
bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]);
bytes
}
/// 简单有损 WebP`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走
/// `VP8X` 扩展格式(+ `ALPH` chunk)。
fn webp_vp8_simple() -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8 ");
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes
}
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
if let Some(payload) = app1_payload {
let segment_len = u16::try_from(payload.len() + 2).expect("APP1 length");
bytes.extend_from_slice(&[0xff, 0xe1]);
bytes.extend_from_slice(&segment_len.to_be_bytes());
bytes.extend_from_slice(payload);
}
bytes.extend_from_slice(&[0xff, 0xc0, 0, 7, 8]);
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&[0xff, 0xd9]);
bytes
}
fn exif_orientation_payload(orientation: u16, little_endian: bool) -> Vec<u8> {
let mut payload = b"Exif\0\0".to_vec();
if little_endian {
payload.extend_from_slice(b"II");
payload.extend_from_slice(&42_u16.to_le_bytes());
payload.extend_from_slice(&8_u32.to_le_bytes());
payload.extend_from_slice(&1_u16.to_le_bytes());
payload.extend_from_slice(&0x0112_u16.to_le_bytes());
payload.extend_from_slice(&3_u16.to_le_bytes());
payload.extend_from_slice(&1_u32.to_le_bytes());
payload.extend_from_slice(&orientation.to_le_bytes());
} else {
payload.extend_from_slice(b"MM");
payload.extend_from_slice(&42_u16.to_be_bytes());
payload.extend_from_slice(&8_u32.to_be_bytes());
payload.extend_from_slice(&1_u16.to_be_bytes());
payload.extend_from_slice(&0x0112_u16.to_be_bytes());
payload.extend_from_slice(&3_u16.to_be_bytes());
payload.extend_from_slice(&1_u32.to_be_bytes());
payload.extend_from_slice(&orientation.to_be_bytes());
}
payload.extend_from_slice(&[0, 0]);
payload
}
#[test]
fn jpeg_dimensions_apply_little_and_big_endian_exif_rotation() {
for (orientation, little_endian) in [(6, true), (8, false)] {
let exif = exif_orientation_payload(orientation, little_endian);
assert_eq!(
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&exif))),
Some((20, 40))
);
}
}
#[test]
fn jpeg_dimensions_keep_raw_size_for_normal_or_malformed_exif() {
let normal = exif_orientation_payload(1, true);
assert_eq!(
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&normal))),
Some((40, 20))
);
let malformed = b"Exif\0\0II\x2a\0\xff\xff\xff\x7f";
assert_eq!(
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(malformed))),
Some((40, 20))
);
}
#[test]
fn image_inspect_accepts_magic_bytes_without_trusting_extension() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/reference.bin"), png_bytes()).expect("image");
let images = load_agent_runtime_inspection_images(
root.path(),
"code-prototype",
"visual-run",
&["assets/ui/reference.bin".to_string()],
)
.expect("load magic image");
assert_eq!(images[0].media_type, "image/png");
assert!(images[0].data_url().starts_with("data:image/png;base64,"));
}
#[test]
fn local_project_image_preview_returns_renderable_data_url() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
.expect("load project preview");
assert_eq!(preview.path, "assets/ui/prototype.png");
assert_eq!(preview.media_type, "image/png");
assert_eq!(preview.byte_len, png_bytes().len() as u64);
assert!(preview.data_url.starts_with("data:image/png;base64,"));
// 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」——
// 资源卡据此才铺棋盘格底。
assert!(preview.has_alpha);
}
#[test]
fn png_alpha_follows_color_type_and_transparency_chunk() {
let color_type_alpha = |color_type: u8| {
let mut bytes = png_header(color_type);
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut bytes, b"IEND", &[]);
detect_raster_image_has_alpha(&bytes, "image/png")
};
// 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。
assert!(color_type_alpha(4), "colorType 4 应判为有 alpha");
assert!(color_type_alpha(6), "PNG-32colorType 6)应判为有 alpha");
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
assert!(
!color_type_alpha(2),
"PNG-24colorType 2)不应判为有 alpha"
);
assert!(
!color_type_alpha(3),
"colorType 3 无 tRNS 时不应判为有 alpha"
);
// 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。
assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha");
// 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。
for color_type in [0_u8, 2, 3] {
let mut bytes = png_header(color_type);
push_png_chunk(&mut bytes, b"tRNS", &[0]);
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut bytes, b"IEND", &[]);
assert!(
detect_raster_image_has_alpha(&bytes, "image/png"),
"colorType {color_type} + tRNS 也是真透明 PNG"
);
}
// tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。
let mut late_trns = png_header(3);
push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut late_trns, b"tRNS", &[0]);
push_png_chunk(&mut late_trns, b"IEND", &[]);
assert!(!detect_raster_image_has_alpha(&late_trns, "image/png"));
}
#[test]
fn jpeg_and_webp_alpha_follow_container_flags() {
// JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。
assert!(!detect_raster_image_has_alpha(
&jpeg_bytes(40, 20, None),
"image/jpeg"
));
// 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。
assert!(detect_raster_image_has_alpha(
&webp_vp8x(0x10),
"image/webp"
));
assert!(!detect_raster_image_has_alpha(
&webp_vp8x(0x00),
"image/webp"
));
// 只有 ICC0x20/ EXIF0x08)等其它标志时不是 alpha。
assert!(!detect_raster_image_has_alpha(
&webp_vp8x(0x28),
"image/webp"
));
// 无损 VP8L 的 alpha_is_used 位。
assert!(detect_raster_image_has_alpha(
&webp_vp8l(true),
"image/webp"
));
assert!(!detect_raster_image_has_alpha(
&webp_vp8l(false),
"image/webp"
));
// 简单有损格式不带 alpha 通道。
assert!(!detect_raster_image_has_alpha(
&webp_vp8_simple(),
"image/webp"
));
// 头部被截断时失败关闭为「不透明」,且不得 panic。
let truncated_webp = webp_vp8x(0x10);
assert!(!detect_raster_image_has_alpha(
&truncated_webp[..18],
"image/webp"
));
let truncated_png = png_header(6);
assert!(!detect_raster_image_has_alpha(
&truncated_png[..20],
"image/png"
));
}
#[test]
fn alpha_judgement_never_decodes_pixels() {
// 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法
// deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功,
// 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
let mut bytes = png_header_with_size(6, 4_096, 4_096);
push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]);
push_png_chunk(&mut bytes, b"IEND", &[]);
fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png")
.expect("header-only preview");
assert_eq!(preview.pixel_width, 4_096);
assert_eq!(preview.byte_len, bytes.len() as u64);
assert!(preview.has_alpha);
}
#[test]
fn image_preview_serializes_alpha_flag_for_the_shell() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
.expect("load project preview");
// 前端按 camelCase 读 `hasAlpha``ProjectResourceCardPreviewTransportPayload`);
// 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。
let serialized = serde_json::to_value(&preview).expect("serialize preview");
assert_eq!(serialized["hasAlpha"], serde_json::json!(true));
}
#[test]
fn cancelled_image_preview_does_not_enter_base64_encoding() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let image = read_agent_runtime_inspection_image(
&root.path().join("assets/ui/prototype.png"),
"assets/ui/prototype.png".to_string(),
)
.expect("read project image");
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
cancellation.cancel();
assert_eq!(
image.data_url_with_cancellation(&cancellation),
Err(
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
.to_string()
)
);
}
#[test]
fn local_project_image_preview_rejects_non_project_asset_paths() {
let root = tempfile::tempdir().expect("temp root");
let error = load_local_project_image_preview(root.path(), "memory/project.png")
.err()
.expect("memory image rejected");
assert!(error.contains("assets/ 或 game/"));
}
#[test]
fn image_inspect_rejects_runtime_evidence_from_another_run() {
let root = tempfile::tempdir().expect("temp root");
let error = load_agent_runtime_inspection_images(
root.path(),
"code-prototype",
"visual-run",
&[
".agent/runtime/browser-validations/code-prototype/other-run/0/desktop.png"
.to_string(),
],
)
.err()
.expect("cross-run evidence rejected");
assert!(error.contains("当前 Agent/run"));
}
#[test]
fn image_inspect_resolves_fixed_viewport_aliases_to_latest_current_run_evidence() {
let root = tempfile::tempdir().expect("temp root");
let evidence_root = root
.path()
.join(".agent/runtime/browser-validations/project-supervisor/visual-run");
for revision in [2_u64, 7] {
let revision_root = evidence_root.join(revision.to_string());
fs::create_dir_all(&revision_root).expect("revision evidence dir");
fs::write(revision_root.join("desktop.png"), png_bytes()).expect("desktop image");
fs::write(revision_root.join("mobile.png"), png_bytes()).expect("mobile image");
}
let images = load_agent_runtime_inspection_images(
root.path(),
"project-supervisor",
"visual-run",
&["desktop.png".to_string(), "mobile.png".to_string()],
)
.expect("resolve current run viewport aliases");
assert_eq!(
images
.iter()
.map(|image| image.relative_path.as_str())
.collect::<Vec<_>>(),
vec![
".agent/runtime/browser-validations/project-supervisor/visual-run/7/desktop.png",
".agent/runtime/browser-validations/project-supervisor/visual-run/7/mobile.png",
]
);
}
#[test]
fn image_inspect_viewport_aliases_do_not_fall_back_to_another_run() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(
root.path()
.join(".agent/runtime/browser-validations/project-supervisor/visual-run"),
)
.expect("current run evidence dir");
let other_run = root
.path()
.join(".agent/runtime/browser-validations/project-supervisor/other-run/9");
fs::create_dir_all(&other_run).expect("other run evidence dir");
fs::write(other_run.join("desktop.png"), png_bytes()).expect("other run image");
let error = load_agent_runtime_inspection_images(
root.path(),
"project-supervisor",
"visual-run",
&["desktop.png".to_string()],
)
.err()
.expect("alias must not resolve across runs");
assert!(error.contains("当前 Agent/run"));
}
#[test]
fn image_inspect_rejects_fake_images_and_oversized_files() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets")).expect("asset dir");
fs::write(root.path().join("assets/fake.png"), b"not-an-image").expect("fake image");
let oversized =
fs::File::create(root.path().join("assets/oversized.png")).expect("oversized image");
oversized
.set_len(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1)
.expect("set oversized length");
let fake_error = load_agent_runtime_inspection_images(
root.path(),
"code-prototype",
"visual-run",
&["assets/fake.png".to_string()],
)
.err()
.expect("fake image rejected");
assert!(fake_error.contains("只支持 PNG、JPEG 或 WEBP"));
let oversized_error = load_agent_runtime_inspection_images(
root.path(),
"code-prototype",
"visual-run",
&["assets/oversized.png".to_string()],
)
.err()
.expect("oversized image rejected");
assert!(oversized_error.contains("单张图片不能超过"));
}
#[cfg(unix)]
#[test]
fn image_inspect_rejects_symlink_and_hardlink_images() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("temp root");
let outside = tempfile::tempdir().expect("outside");
fs::create_dir_all(root.path().join("assets")).expect("asset dir");
let target = outside.path().join("target.png");
fs::write(&target, png_bytes()).expect("target");
symlink(&target, root.path().join("assets/link.png")).expect("symlink");
fs::hard_link(&target, root.path().join("assets/hard.png")).expect("hardlink");
for path in ["assets/link.png", "assets/hard.png"] {
assert!(load_agent_runtime_inspection_images(
root.path(),
"code-prototype",
"visual-run",
&[path.to_string()],
)
.is_err());
}
fs::create_dir_all(outside.path().join("linked-parent")).expect("outside parent");
fs::write(outside.path().join("linked-parent/image.png"), png_bytes())
.expect("parent image");
symlink(
outside.path().join("linked-parent"),
root.path().join("assets/linked-parent"),
)
.expect("parent symlink");
assert!(load_agent_runtime_inspection_images(
root.path(),
"code-prototype",
"visual-run",
&["assets/linked-parent/image.png".to_string()],
)
.is_err());
}
}