ffb4cb5d62
将资源卡本体化并补齐分区缩放滚动与依赖聚类 增加全进程三槽预览调度范围取消与安全分块读取 保持布局 CAS 预览缓存回收和依赖图权威合同 补齐资源管理前端 Tauri AppSurface 回归并同步文档
830 lines
29 KiB
Rust
830 lines
29 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) 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(¤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(),
|
||
)
|
||
}
|
||
|
||
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());
|
||
}
|
||
}
|