181e364d80
- 原生预览新增头部级 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 画出的假棋盘格。
1312 lines
50 KiB
Rust
1312 lines
50 KiB
Rust
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(¤t)
|
||
.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-32(colorType 6)应判为有 alpha");
|
||
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
|
||
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
|
||
assert!(
|
||
!color_type_alpha(2),
|
||
"PNG-24(colorType 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"
|
||
));
|
||
// 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 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());
|
||
}
|
||
}
|