Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs
T
menghao ffb4cb5d62 完善资源管理工作台与有界预览
将资源卡本体化并补齐分区缩放滚动与依赖聚类

增加全进程三槽预览调度范围取消与安全分块读取

保持布局 CAS 预览缓存回收和依赖图权威合同

补齐资源管理前端 Tauri AppSurface 回归并同步文档
2026-08-11 18:07:14 +08:00

830 lines
29 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) data_url: String,
}
pub(crate) struct AgentRuntimeInspectionImage {
pub(crate) relative_path: String,
pub(crate) sha256: String,
pub(crate) byte_len: u64,
pub(crate) media_type: &'static str,
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)
)
}
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)?;
cancellation.check()?;
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
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(),
)
}
fn read_agent_runtime_inspection_image_with_cancellation(
path: &Path,
relative_path: String,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> 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 = format!("{:x}", Sha256::digest(&bytes));
Ok(AgentRuntimeInspectionImage {
relative_path,
sha256,
byte_len: bytes.len() as u64,
media_type,
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,
}
}
fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if !bytes.starts_with(&[0xff, 0xd8]) {
return None;
}
let mut index = 2usize;
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 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)?,
]));
return Some((width, height));
}
index += segment_len;
}
None
}
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)]
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))]
fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
#[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")
}
#[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,"));
}
#[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());
}
}