From 4bb9f77f20b088fbcfd32a87817cb3372274c662 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 07:09:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=9B=BE=E9=9B=86=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E6=A8=A1=E5=9D=97=E6=8A=95=E5=BD=B1=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分 Windows 祖先固定与事务叶子句柄权限,避免重复捕获共享冲突 为 Unix 恢复前态增加非阻塞稳定双读、元数据与叶子身份复核 保持缺失合同父目录无副作用并补齐 CAS 与残留清理回归 按 Oxc symbol span 投影 import alias、namespace、shorthand 与 bridge 同步图集事务和 Tetris ESM 权威技术约束 --- .../src/agent/generation/canvas_generation.rs | 1327 +++++++++++++++-- .../runtime_protocol/autonomous_completion.rs | 432 ++++-- .../autonomous_completion_contract_tests.rs | 162 +- .../shared-memory/decision-log.md | 4 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 5 files changed, 1650 insertions(+), 279 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 32f16148b..038df501a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1625,9 +1625,10 @@ fn recover_interrupted_platform_art_replacement_at( fn cleanup_interrupted_platform_art_contract_files_at(root: &Path) -> Result<(), String> { for output_path in STRICT_PLATFORM_ART_CONTRACT_PATHS { let target = resolve_local_project_path(root, output_path)?; - let parent = target - .parent() - .ok_or_else(|| "平台素材缺少父目录".to_string())?; + let Some(parent) = TrustedPlatformArtRecoveryParent::open_optional(root, &target, false)? + else { + continue; + }; let file_name = target .file_name() .and_then(|value| value.to_str()) @@ -1637,14 +1638,7 @@ fn cleanup_interrupted_platform_art_contract_files_at(root: &Path) -> Result<(), format!(".{file_name}.replacement."), format!(".{file_name}.installing."), ]; - let entries = match fs::read_dir(parent) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => return Err(format!("扫描平台素材中断文件失败:{error}")), - }; - for entry in entries { - let entry = entry.map_err(|error| format!("读取平台素材中断目录项失败:{error}"))?; - let name = entry.file_name(); + for name in parent.list_names()? { let Some(name) = name.to_str() else { continue; }; @@ -1654,13 +1648,19 @@ fn cleanup_interrupted_platform_art_contract_files_at(root: &Path) -> Result<(), }) { continue; } - let file_type = entry - .file_type() - .map_err(|error| format!("读取平台素材中断文件类型失败:{error}"))?; - if !file_type.is_file() { + let file = parent + .open_file(std::ffi::OsStr::new(name)) + .map_err(|error| format!("锚定打开平台素材中断文件失败:{error}"))?; + if !file + .metadata() + .map_err(|error| format!("读取平台素材中断文件类型失败:{error}"))? + .is_file() + { return Err("平台素材中断残留不是普通文件,已保留事务等待对账".to_string()); } - fs::remove_file(entry.path()) + drop(file); + parent + .remove(std::ffi::OsStr::new(name)) .map_err(|error| format!("回收平台素材中断文件失败:{error}"))?; } } @@ -1760,6 +1760,7 @@ fn sync_platform_art_directory(path: &Path, label: &str) -> Result<(), String> { Ok(()) } +#[cfg(any(test, not(unix)))] fn write_durable_platform_art_transaction_file( path: &Path, bytes: &[u8], @@ -1781,6 +1782,7 @@ fn write_durable_platform_art_transaction_file( .map_err(|error| format!("持久化{label}失败:{}: {error}", path.display())) } +#[cfg(test)] fn write_atomic_platform_art_transaction_marker( transaction_directory: &Path, marker_name: &str, @@ -1869,6 +1871,7 @@ fn platform_art_transaction_metadata_unchanged( } } +#[cfg(any(test, not(unix)))] fn open_platform_art_transaction_file_for_read(path: &Path) -> std::io::Result { let mut options = fs::OpenOptions::new(); options.read(true); @@ -1893,7 +1896,40 @@ fn open_platform_art_transaction_file_for_read(path: &Path) -> std::io::Result std::io::Result { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlatformArtWindowsDirectoryHandleRole { + RecoveryAncestorPin, + TransactionDirectory, +} + +#[cfg(any(test, windows))] +fn platform_art_windows_directory_open_policy( + role: PlatformArtWindowsDirectoryHandleRole, +) -> (u32, u32) { + const DELETE_ACCESS: u32 = 0x0001_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003; + + match role { + // A read-only handle that omits FILE_SHARE_DELETE still pins the + // directory against rename/delete, but repeated ancestor walks can + // coexist because none of those handles requests DELETE access. + PlatformArtWindowsDirectoryHandleRole::RecoveryAncestorPin => { + (GENERIC_READ, FILE_SHARE_READ_WRITE) + } + // The trusted transaction-directory handle may need DELETE access for + // directory retirement. It is only acquired for the transaction leaf, + // never for every ancestor in a recovery walk. + PlatformArtWindowsDirectoryHandleRole::TransactionDirectory => { + (GENERIC_READ | DELETE_ACCESS, FILE_SHARE_READ_WRITE) + } + } +} + +fn open_platform_art_directory_with_role( + path: &Path, + #[cfg_attr(not(windows), allow(unused_variables))] role: PlatformArtWindowsDirectoryHandleRole, +) -> std::io::Result { let mut options = fs::OpenOptions::new(); options.read(true); #[cfg(unix)] @@ -1906,21 +1942,33 @@ fn open_platform_art_transaction_directory_for_read(path: &Path) -> std::io::Res { use std::os::windows::fs::OpenOptionsExt; - const DELETE_ACCESS: u32 = 0x0001_0000; - const GENERIC_READ: u32 = 0x8000_0000; - const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003; const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let (access_mode, share_mode) = platform_art_windows_directory_open_policy(role); options - .access_mode(GENERIC_READ | DELETE_ACCESS) + .access_mode(access_mode) // Deliberately omit FILE_SHARE_DELETE. While this handle is alive the // directory cannot be renamed, replaced, or deleted by another process. - .share_mode(FILE_SHARE_READ_WRITE) + .share_mode(share_mode) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); } options.open(path) } +fn open_platform_art_transaction_directory_for_read(path: &Path) -> std::io::Result { + open_platform_art_directory_with_role( + path, + PlatformArtWindowsDirectoryHandleRole::TransactionDirectory, + ) +} + +fn open_platform_art_recovery_ancestor_for_pin(path: &Path) -> std::io::Result { + open_platform_art_directory_with_role( + path, + PlatformArtWindowsDirectoryHandleRole::RecoveryAncestorPin, + ) +} + fn open_platform_art_transaction_directory_for_identity(path: &Path) -> std::io::Result { #[cfg(windows)] { @@ -1961,7 +2009,7 @@ fn open_platform_art_transaction_child_at( libc::openat( directory.as_raw_fd(), name.as_ptr(), - libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, ) }; if descriptor < 0 { @@ -2072,9 +2120,118 @@ struct TrustedPlatformArtTransactionDirectory { parent_handle: fs::File, #[cfg(unix)] directory_name: std::ffi::OsString, + #[cfg(windows)] + ancestor_handles: Vec, } impl TrustedPlatformArtTransactionDirectory { + fn open_anchored(root: &Path, path: &Path) -> Result { + let parent = TrustedPlatformArtRecoveryParent::open(root, path, false)?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + let name = std::ffi::CString::new(parent.leaf.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + let descriptor = unsafe { + libc::openat( + parent.handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(format!( + "锚定打开平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + let handle = unsafe { fs::File::from_raw_fd(descriptor) }; + let metadata = handle + .metadata() + .map_err(|error| format!("读取锚定平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) { + return Err("锚定平台图集事务路径不是可信目录".to_string()); + } + return Ok(Self { + path: path.to_path_buf(), + handle, + metadata, + parent_handle: parent.handle, + directory_name: parent.leaf, + }); + } + #[cfg(not(unix))] + { + let mut trusted = Self::open(path)?; + #[cfg(windows)] + { + trusted.ancestor_handles = parent.ancestors; + } + Ok(trusted) + } + } + + fn create_anchored(root: &Path, path: &Path) -> Result { + let parent = TrustedPlatformArtRecoveryParent::open(root, path, true)?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let name = std::ffi::CString::new(parent.leaf.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + if unsafe { libc::mkdirat(parent.handle.as_raw_fd(), name.as_ptr(), 0o700) } != 0 { + return Err(format!( + "锚定创建平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + let descriptor = unsafe { + libc::openat( + parent.handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(format!( + "锚定打开新平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + let handle = unsafe { fs::File::from_raw_fd(descriptor) }; + let metadata = handle + .metadata() + .map_err(|error| format!("读取新平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) { + return Err("新平台图集事务目录不是可信目录".to_string()); + } + parent + .handle + .sync_all() + .map_err(|error| format!("同步平台图集事务父目录失败:{error}"))?; + return Ok(Self { + path: path.to_path_buf(), + handle, + metadata, + parent_handle: parent.handle, + directory_name: parent.leaf, + }); + } + #[cfg(not(unix))] + { + fs::create_dir(path).map_err(|error| format!("创建平台图集事务目录失败:{error}"))?; + let mut trusted = Self::open(path)?; + #[cfg(windows)] + { + trusted.ancestor_handles = parent.ancestors; + } + Ok(trusted) + } + } + + #[cfg(any(test, not(unix)))] fn open(path: &Path) -> Result { let path_metadata = fs::symlink_metadata(path) .map_err(|error| format!("读取平台图集事务目录失败:{error}"))?; @@ -2148,6 +2305,8 @@ impl TrustedPlatformArtTransactionDirectory { parent_handle, #[cfg(unix)] directory_name, + #[cfg(windows)] + ancestor_handles: Vec::new(), }; trusted.verify()?; Ok(trusted) @@ -2221,6 +2380,103 @@ impl TrustedPlatformArtTransactionDirectory { } } + fn write_child_new( + &self, + name: &std::ffi::OsStr, + bytes: &[u8], + label: &str, + ) -> Result<(), String> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let name = std::ffi::CString::new(name.as_bytes()) + .map_err(|_| format!("{label}文件名包含 NUL"))?; + let descriptor = unsafe { + libc::openat( + self.handle.as_raw_fd(), + name.as_ptr(), + libc::O_WRONLY + | libc::O_CLOEXEC + | libc::O_CREAT + | libc::O_EXCL + | libc::O_NOFOLLOW, + 0o600, + ) + }; + if descriptor < 0 { + return Err(format!( + "锚定创建{label}失败:{}", + std::io::Error::last_os_error() + )); + } + let mut file = unsafe { fs::File::from_raw_fd(descriptor) }; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("持久化{label}失败:{error}"))?; + return self + .handle + .sync_all() + .map_err(|error| format!("同步{label}事务目录失败:{error}")); + } + #[cfg(not(unix))] + write_durable_platform_art_transaction_file(&self.path.join(name), bytes, label) + } + + fn publish_marker(&self, marker_name: &str, bytes: &[u8], label: &str) -> Result<(), String> { + let temporary = std::ffi::OsString::from(format!( + ".{marker_name}.tmp.{}.{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + self.write_child_new(&temporary, bytes, label)?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::AsRawFd; + let from = std::ffi::CString::new(temporary.as_bytes()) + .map_err(|_| format!("{label}临时文件名包含 NUL"))?; + let to = std::ffi::CString::new(marker_name.as_bytes()) + .map_err(|_| format!("{label}文件名包含 NUL"))?; + if unsafe { + libc::linkat( + self.handle.as_raw_fd(), + from.as_ptr(), + self.handle.as_raw_fd(), + to.as_ptr(), + 0, + ) + } != 0 + { + let error = std::io::Error::last_os_error(); + let _ = remove_platform_art_transaction_child_at(&self.handle, &temporary); + return Err(format!("原子发布{label}失败:{error}")); + } + remove_platform_art_transaction_child_at(&self.handle, &temporary) + .map_err(|error| format!("清理{label}临时文件失败:{error}"))?; + return self + .handle + .sync_all() + .map_err(|error| format!("同步{label}事务目录失败:{error}")); + } + #[cfg(not(unix))] + { + let temporary_path = self.path.join(&temporary); + let marker_path = self.path.join(marker_name); + if let Err(error) = fs::hard_link(&temporary_path, &marker_path) { + let _ = fs::remove_file(&temporary_path); + return Err(format!("原子发布{label}失败:{error}")); + } + fs::remove_file(&temporary_path) + .map_err(|error| format!("清理{label}临时文件失败:{error}")) + } + } + fn child_names(&self) -> Result, String> { self.verify()?; #[cfg(unix)] @@ -2360,6 +2616,7 @@ fn read_platform_art_transaction_file_once( Ok(bytes) } +#[cfg(any(test, not(unix)))] fn read_bounded_platform_art_transaction_file_with_hook( path: &Path, max_bytes: u64, @@ -2459,6 +2716,7 @@ where Ok(second) } +#[cfg(any(test, not(unix)))] fn read_bounded_platform_art_transaction_file( path: &Path, max_bytes: u64, @@ -2612,18 +2870,6 @@ fn remove_trusted_platform_art_transaction_directory( Ok(()) } -fn remove_strict_platform_art_transaction_directory( - transaction_directory: &Path, -) -> Result<(), String> { - match fs::symlink_metadata(transaction_directory) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Ok(_) => {} - Err(error) => return Err(format!("读取平台图集事务目录失败:{error}")), - } - let trusted = TrustedPlatformArtTransactionDirectory::open(transaction_directory)?; - remove_trusted_platform_art_transaction_directory(trusted) -} - fn strict_platform_art_transaction_marker_exists( transaction_directory: &TrustedPlatformArtTransactionDirectory, path: &Path, @@ -2728,6 +2974,485 @@ fn preflight_platform_art_recovery_target(root: &Path, path: &Path) -> Result<() Ok(()) } +#[cfg(unix)] +struct TrustedPlatformArtRecoveryParent { + handle: fs::File, + leaf: std::ffi::OsString, + root: PathBuf, + canonical: PathBuf, + metadata: fs::Metadata, +} + +#[cfg(unix)] +impl TrustedPlatformArtRecoveryParent { + fn open(root: &Path, canonical: &Path, create_parent: bool) -> Result { + Self::open_optional(root, canonical, create_parent)? + .ok_or_else(|| format!("平台图集事务恢复目标父目录不存在:{}", canonical.display())) + } + + fn open_optional( + root: &Path, + canonical: &Path, + create_parent: bool, + ) -> Result, String> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let relative = canonical.strip_prefix(root).map_err(|_| { + format!( + "平台图集事务恢复目标越出项目根目录:{}", + canonical.display() + ) + })?; + let leaf = relative + .file_name() + .ok_or_else(|| "平台图集事务恢复目标缺少叶子文件名".to_string())? + .to_os_string(); + let parent = relative + .parent() + .ok_or_else(|| "平台图集事务恢复目标缺少父目录".to_string())?; + if relative.as_os_str().is_empty() + || relative + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err("平台图集事务恢复目标不是规范项目相对路径".to_string()); + } + let mut handle = open_platform_art_recovery_ancestor_for_pin(root) + .map_err(|error| format!("锚定平台图集项目根目录失败:{error}"))?; + for component in parent.components() { + let std::path::Component::Normal(component) = component else { + return Err("平台图集事务恢复父路径不是规范相对路径".to_string()); + }; + let name = std::ffi::CString::new(component.as_bytes()) + .map_err(|_| "平台图集事务恢复父路径包含 NUL".to_string())?; + let mut descriptor = unsafe { + libc::openat( + handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 + && create_parent + && std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound + { + if unsafe { libc::mkdirat(handle.as_raw_fd(), name.as_ptr(), 0o755) } != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(format!("锚定创建平台图集恢复父目录失败:{error}")); + } + } + descriptor = unsafe { + libc::openat( + handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + } + if descriptor < 0 { + if !create_parent + && std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound + { + return Ok(None); + } + return Err(format!( + "锚定打开平台图集恢复父目录失败:{}", + std::io::Error::last_os_error() + )); + } + handle = unsafe { fs::File::from_raw_fd(descriptor) }; + } + let metadata = handle + .metadata() + .map_err(|error| format!("读取锚定平台图集恢复父目录元数据失败:{error}"))?; + Ok(Some(Self { + handle, + leaf, + root: root.to_path_buf(), + canonical: canonical.to_path_buf(), + metadata, + })) + } + + fn c_name(name: &std::ffi::OsStr) -> Result { + use std::os::unix::ffi::OsStrExt; + std::ffi::CString::new(name.as_bytes()) + .map_err(|_| "平台图集事务恢复文件名包含 NUL".to_string()) + } + + fn read_state( + &self, + name: &std::ffi::OsStr, + max_bytes: u64, + label: &str, + ) -> Result { + self.read_state_with_hook(name, max_bytes, label, || Ok(())) + } + + fn read_state_with_hook( + &self, + name: &std::ffi::OsStr, + max_bytes: u64, + label: &str, + after_first_read: F, + ) -> Result + where + F: FnOnce() -> Result<(), String>, + { + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let c_name = Self::c_name(name)?; + let descriptor = unsafe { + libc::openat( + self.handle.as_raw_fd(), + c_name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + return match self.open_file(name) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(PlatformArtRecoveryFileState::Missing) + } + Ok(_) => Err(format!("{label}在缺失状态检查期间出现,已拒绝继续")), + Err(error) => Err(format!("复核{label}缺失状态失败:{error}")), + }; + } + return Err(format!("锚定打开{label}失败:{error}")); + } + let mut file = unsafe { fs::File::from_raw_fd(descriptor) }; + let opened_metadata_before = file + .metadata() + .map_err(|error| format!("读取{label}锚定元数据失败:{error}"))?; + if !platform_art_transaction_metadata_is_trusted(&opened_metadata_before, max_bytes) { + return Err(format!("{label}不是有界可信普通文件")); + } + let initial_current = self + .open_file(name) + .map_err(|error| format!("读取前复核{label}当前叶子失败:{error}"))?; + let initial_current_metadata = initial_current + .metadata() + .map_err(|error| format!("读取前复核{label}当前叶子元数据失败:{error}"))?; + if !platform_art_transaction_metadata_is_trusted(&initial_current_metadata, max_bytes) + || !platform_art_transaction_open_files_match( + &file, + &opened_metadata_before, + &initial_current, + &initial_current_metadata, + )? + { + return Err(format!("{label}在读取前发生变化,已拒绝继续")); + } + + let path = self.canonical.with_file_name(name); + let first = read_platform_art_transaction_file_once(&mut file, max_bytes, label, &path)?; + after_first_read()?; + let second = read_platform_art_transaction_file_once(&mut file, max_bytes, label, &path)?; + let opened_metadata_after = file + .metadata() + .map_err(|error| format!("读取{label}结束锚定元数据失败:{error}"))?; + let current = self + .open_file(name) + .map_err(|error| format!("结束复核{label}当前叶子失败:{error}"))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取结束复核{label}当前叶子元数据失败:{error}"))?; + if !platform_art_transaction_metadata_is_trusted(&opened_metadata_after, max_bytes) + || !platform_art_transaction_metadata_is_trusted(¤t_metadata, max_bytes) + || !platform_art_transaction_metadata_unchanged( + &opened_metadata_before, + &opened_metadata_after, + ) + || !platform_art_transaction_metadata_unchanged( + &opened_metadata_after, + ¤t_metadata, + ) + || !platform_art_transaction_open_files_match( + &file, + &opened_metadata_after, + ¤t, + ¤t_metadata, + )? + || first != second + || u64::try_from(second.len()).unwrap_or(u64::MAX) != opened_metadata_after.len() + { + return Err(format!("{label}在读取期间发生变化,已拒绝继续")); + } + Ok(PlatformArtRecoveryFileState::Present(second)) + } + + fn open_file(&self, name: &std::ffi::OsStr) -> std::io::Result { + use std::os::unix::io::{AsRawFd, FromRawFd}; + let name = Self::c_name(name) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + let descriptor = unsafe { + libc::openat( + self.handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(unsafe { fs::File::from_raw_fd(descriptor) }) + } + + fn list_names(&self) -> Result, String> { + use std::os::unix::ffi::OsStringExt; + use std::os::unix::io::AsRawFd; + + let duplicate = unsafe { libc::dup(self.handle.as_raw_fd()) }; + if duplicate < 0 { + return Err(format!( + "复制平台图集恢复目录句柄失败:{}", + std::io::Error::last_os_error() + )); + } + let directory = unsafe { libc::fdopendir(duplicate) }; + if directory.is_null() { + unsafe { libc::close(duplicate) }; + return Err(format!( + "打开平台图集恢复目录流失败:{}", + std::io::Error::last_os_error() + )); + } + let mut names = Vec::new(); + loop { + let entry = unsafe { libc::readdir(directory) }; + if entry.is_null() { + break; + } + let name = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name != b"." && name != b".." { + names.push(std::ffi::OsString::from_vec(name.to_vec())); + } + } + unsafe { libc::closedir(directory) }; + Ok(names) + } + + fn verify_path(&self) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + let current = Self::open(&self.root, &self.canonical, false)?; + if self.metadata.dev() != current.metadata.dev() + || self.metadata.ino() != current.metadata.ino() + { + return Err("平台图集恢复父目录在锚定期间发生变化".to_string()); + } + Ok(()) + } + + fn write_new(&self, name: &std::ffi::OsStr, bytes: &[u8], label: &str) -> Result<(), String> { + use std::io::Write; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let name = Self::c_name(name)?; + let descriptor = unsafe { + libc::openat( + self.handle.as_raw_fd(), + name.as_ptr(), + libc::O_WRONLY | libc::O_CLOEXEC | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW, + 0o600, + ) + }; + if descriptor < 0 { + return Err(format!( + "锚定创建{label}失败:{}", + std::io::Error::last_os_error() + )); + } + let mut file = unsafe { fs::File::from_raw_fd(descriptor) }; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("持久化{label}失败:{error}"))?; + self.handle + .sync_all() + .map_err(|error| format!("同步{label}父目录失败:{error}")) + } + + fn hard_link(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> Result<(), String> { + use std::os::unix::io::AsRawFd; + let from = Self::c_name(from)?; + let to = Self::c_name(to)?; + if unsafe { + libc::linkat( + self.handle.as_raw_fd(), + from.as_ptr(), + self.handle.as_raw_fd(), + to.as_ptr(), + 0, + ) + } != 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + self.handle.sync_all().map_err(|error| error.to_string()) + } + + fn remove(&self, name: &std::ffi::OsStr) -> Result<(), String> { + remove_platform_art_transaction_child_at(&self.handle, name) + .and_then(|_| self.handle.sync_all()) + .map_err(|error| error.to_string()) + } + + fn move_no_replace(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> Result<(), String> { + self.hard_link(from, to)?; + if let Err(error) = self.remove(from) { + let _ = self.remove(to); + return Err(error); + } + Ok(()) + } +} + +#[cfg(not(unix))] +struct TrustedPlatformArtRecoveryParent { + canonical: PathBuf, + leaf: std::ffi::OsString, + ancestors: Vec, +} + +#[cfg(not(unix))] +impl TrustedPlatformArtRecoveryParent { + fn open(root: &Path, canonical: &Path, create_parent: bool) -> Result { + Self::open_optional(root, canonical, create_parent)? + .ok_or_else(|| format!("平台图集事务恢复目标父目录不存在:{}", canonical.display())) + } + + fn open_optional( + root: &Path, + canonical: &Path, + create_parent: bool, + ) -> Result, String> { + preflight_platform_art_recovery_target(root, canonical)?; + let relative = canonical + .strip_prefix(root) + .map_err(|_| "平台图集事务恢复目标越出项目根目录".to_string())?; + let leaf = relative + .file_name() + .ok_or_else(|| "平台图集事务恢复目标缺少叶子文件名".to_string())? + .to_os_string(); + let mut ancestors = Vec::new(); + let mut current = root.to_path_buf(); + let root_handle = open_platform_art_recovery_ancestor_for_pin(¤t) + .map_err(|error| format!("锚定平台图集项目根目录失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted( + &root_handle + .metadata() + .map_err(|error| format!("读取平台图集项目根目录元数据失败:{error}"))?, + ) { + return Err("平台图集项目根目录不是可信目录".to_string()); + } + ancestors.push(root_handle); + for component in relative.parent().into_iter().flat_map(Path::components) { + let std::path::Component::Normal(component) = component else { + return Err("平台图集事务恢复父路径不是规范相对路径".to_string()); + }; + current.push(component); + if create_parent && !current.exists() { + fs::create_dir(¤t) + .map_err(|error| format!("创建平台图集恢复父目录失败:{error}"))?; + } + let handle = match open_platform_art_recovery_ancestor_for_pin(¤t) { + Ok(handle) => handle, + Err(error) if !create_parent && error.kind() == std::io::ErrorKind::NotFound => { + return Ok(None); + } + Err(error) => { + return Err(format!("锚定平台图集恢复父目录失败:{error}")); + } + }; + if !platform_art_transaction_directory_metadata_is_trusted( + &handle + .metadata() + .map_err(|error| format!("读取锚定平台图集恢复父目录元数据失败:{error}"))?, + ) { + return Err("平台图集恢复父目录不是可信目录".to_string()); + } + ancestors.push(handle); + } + Ok(Some(Self { + canonical: canonical.to_path_buf(), + leaf, + ancestors, + })) + } + + fn path_for(&self, name: &std::ffi::OsStr) -> PathBuf { + self.canonical.with_file_name(name) + } + + fn read_state( + &self, + name: &std::ffi::OsStr, + max_bytes: u64, + label: &str, + ) -> Result { + read_platform_art_recovery_file_state(&self.path_for(name), max_bytes, label) + } + + fn open_file(&self, name: &std::ffi::OsStr) -> std::io::Result { + open_platform_art_transaction_file_for_read(&self.path_for(name)) + } + + fn list_names(&self) -> Result, String> { + let parent = self + .canonical + .parent() + .ok_or_else(|| "平台图集恢复目标缺少父目录".to_string())?; + fs::read_dir(parent) + .map_err(|error| format!("扫描平台图集恢复目录失败:{error}"))? + .map(|entry| { + entry + .map(|entry| entry.file_name()) + .map_err(|error| format!("读取平台图集恢复目录项失败:{error}")) + }) + .collect() + } + + fn verify_path(&self) -> Result<(), String> { + Ok(()) + } + + fn write_new(&self, name: &std::ffi::OsStr, bytes: &[u8], label: &str) -> Result<(), String> { + write_durable_platform_art_transaction_file(&self.path_for(name), bytes, label) + } + + fn hard_link(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> Result<(), String> { + fs::hard_link(self.path_for(from), self.path_for(to)).map_err(|error| error.to_string()) + } + + fn remove(&self, name: &std::ffi::OsStr) -> Result<(), String> { + fs::remove_file(self.path_for(name)).map_err(|error| error.to_string()) + } + + fn move_no_replace(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> Result<(), String> { + self.hard_link(from, to)?; + if let Err(error) = self.remove(from) { + let _ = self.remove(to); + return Err(error); + } + Ok(()) + } +} + +fn read_platform_art_recovery_file_state_anchored( + root: &Path, + path: &Path, + max_bytes: u64, + label: &str, +) -> Result { + let Some(parent) = TrustedPlatformArtRecoveryParent::open_optional(root, path, false)? else { + return Ok(PlatformArtRecoveryFileState::Missing); + }; + parent.read_state(&parent.leaf, max_bytes, label) +} + fn sync_strict_platform_art_contract_state_at( root: &Path, require_complete: bool, @@ -2799,6 +3524,7 @@ struct AppliedPlatformArtRecovery { installed: PlatformArtRecoveryFileState, } +#[cfg(any(test, not(unix)))] fn read_platform_art_recovery_file_state( path: &Path, max_bytes: u64, @@ -2854,12 +3580,15 @@ fn install_platform_art_recovery_state_cas_with_hook( where F: FnOnce() -> Result<(), String>, { - preflight_platform_art_recovery_target(root, canonical)?; - let observed = read_platform_art_recovery_file_state( - canonical, - STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, - "平台图集事务 CAS 目标", - )?; + let existing_parent = TrustedPlatformArtRecoveryParent::open_optional(root, canonical, false)?; + let observed = match existing_parent.as_ref() { + Some(parent) => parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 目标", + )?, + None => PlatformArtRecoveryFileState::Missing, + }; if &observed != expected { return Err(format!( "平台图集事务 CAS 目标已被并发修改,已拒绝覆盖:{}", @@ -2869,57 +3598,59 @@ where if expected == desired { return Ok(()); } + let parent = match existing_parent { + Some(parent) => parent, + None => TrustedPlatformArtRecoveryParent::open(root, canonical, true)?, + }; let file_name = canonical .file_name() .and_then(|value| value.to_str()) .unwrap_or("manifest.json"); - if let Some(parent) = canonical.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建平台图集事务恢复目录失败:{}: {error}", - parent.display() - ) - })?; - } - preflight_platform_art_recovery_target(root, canonical)?; - // Prepare desired bytes under a private create-new leaf first. Linking this // durable inode into the canonical name is atomic and never overwrites a // concurrently-created target, so write/sync failures cannot leave a partial // canonical contract file. let installing = canonical.with_file_name(format!(".{file_name}.installing.{suffix}")); + let installing_name = installing + .file_name() + .ok_or_else(|| "平台图集事务 CAS 安装暂存缺少叶子文件名".to_string())?; let prepared_install = match desired { PlatformArtRecoveryFileState::Present(bytes) => { - if fs::symlink_metadata(&installing).is_ok() { + if !matches!( + parent.read_state( + installing_name, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 安装暂存" + )?, + PlatformArtRecoveryFileState::Missing + ) { return Err(format!( "平台图集事务 CAS 安装暂存路径已存在,已拒绝覆盖:{}", installing.display() )); } - if let Err(error) = write_durable_platform_art_transaction_file( - &installing, - bytes, - "平台图集事务 CAS 安装暂存", - ) { - let _ = fs::remove_file(&installing); + if let Err(error) = + parent.write_new(installing_name, bytes, "平台图集事务 CAS 安装暂存") + { + let _ = parent.remove(installing_name); return Err(error); } - Some(installing.as_path()) + Some(installing_name) } PlatformArtRecoveryFileState::Missing => None, }; if matches!(expected, PlatformArtRecoveryFileState::Missing) { - match read_platform_art_recovery_file_state( - canonical, + match parent.read_state( + &parent.leaf, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, "平台图集事务 CAS 缺失目标", )? { PlatformArtRecoveryFileState::Missing => {} PlatformArtRecoveryFileState::Present(_) => { if prepared_install.is_some() { - let _ = fs::remove_file(&installing); + let _ = parent.remove(installing_name); } return Err(format!( "平台图集事务 CAS 缺失目标已被并发创建,已拒绝覆盖:{}", @@ -2930,43 +3661,58 @@ where let Some(prepared_install) = prepared_install else { return Ok(()); }; - if let Err(error) = fs::hard_link(prepared_install, canonical) { - let _ = fs::remove_file(&installing); + if let Err(error) = parent.hard_link(prepared_install, &parent.leaf) { + let _ = parent.remove(installing_name); return Err(format!("原子安装平台图集事务 CAS 缺失目标失败:{error}")); } // From this point canonical contains desired even if temporary cleanup // fails; the caller observes that state and includes this item in rollback. after_canonical_install()?; - fs::remove_file(&installing) + parent + .remove(installing_name) .map_err(|error| format!("回收平台图集事务 CAS 安装暂存失败:{error}"))?; return Ok(()); } let backup = canonical.with_file_name(format!(".{file_name}.previous.{suffix}")); - if fs::symlink_metadata(&backup).is_ok() { + let backup_name = backup + .file_name() + .ok_or_else(|| "平台图集事务 CAS 备份缺少叶子文件名".to_string())?; + if !matches!( + parent.read_state( + backup_name, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 备份" + )?, + PlatformArtRecoveryFileState::Missing + ) { if prepared_install.is_some() { - let _ = fs::remove_file(&installing); + let _ = parent.remove(installing_name); } return Err(format!( "平台图集事务 CAS 备份路径已存在,已拒绝覆盖:{}", backup.display() )); } - if let Err(error) = fs::rename(canonical, &backup) { + if let Err(error) = parent.move_no_replace(&parent.leaf, backup_name) { if prepared_install.is_some() { - let _ = fs::remove_file(&installing); + let _ = parent.remove(installing_name); } return Err(format!("平台图集事务 CAS 锁定既有目标失败:{error}")); } - let moved = read_platform_art_recovery_file_state( - &backup, + let moved = parent.read_state( + backup_name, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, "平台图集事务 CAS 已移动目标", ); if moved.as_ref() != Ok(expected) { - let restore_result = match fs::symlink_metadata(canonical) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::rename(&backup, canonical) + let restore_result = match parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 恢复目标", + ) { + Ok(PlatformArtRecoveryFileState::Missing) => { + parent.move_no_replace(backup_name, &parent.leaf) } _ => { return Err(format!( @@ -2981,7 +3727,7 @@ where )); } if prepared_install.is_some() { - let _ = fs::remove_file(&installing); + let _ = parent.remove(installing_name); } return Err(moved .err() @@ -2990,16 +3736,21 @@ where let install_result = match desired { PlatformArtRecoveryFileState::Missing => Ok(()), - PlatformArtRecoveryFileState::Present(_) => fs::hard_link( - prepared_install.expect("present desired state has prepared install"), - canonical, - ) - .map_err(|error| format!("原子安装平台图集事务 CAS 结果失败:{error}")), + PlatformArtRecoveryFileState::Present(_) => parent + .hard_link( + prepared_install.expect("present desired state has prepared install"), + &parent.leaf, + ) + .map_err(|error| format!("原子安装平台图集事务 CAS 结果失败:{error}")), }; if let Err(error) = install_result { - let restore_result = match fs::symlink_metadata(canonical) { - Err(current_error) if current_error.kind() == std::io::ErrorKind::NotFound => { - fs::rename(&backup, canonical) + let restore_result = match parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 安装失败恢复目标", + ) { + Ok(PlatformArtRecoveryFileState::Missing) => { + parent.move_no_replace(backup_name, &parent.leaf) } _ => { return Err(format!( @@ -3009,7 +3760,7 @@ where } }; let cleanup_result = if prepared_install.is_some() { - fs::remove_file(&installing) + parent.remove(installing_name) } else { Ok(()) }; @@ -3032,10 +3783,12 @@ where after_canonical_install()?; if prepared_install.is_some() { // canonical is already desired if this cleanup reports an error. - fs::remove_file(&installing) + parent + .remove(installing_name) .map_err(|error| format!("回收平台图集事务 CAS 安装暂存失败:{error}"))?; } - fs::remove_file(&backup) + parent + .remove(backup_name) .map_err(|error| format!("回收平台图集事务 CAS 原目标备份失败:{error}"))?; Ok(()) } @@ -3085,13 +3838,15 @@ fn platform_art_recovery_error_after_rollback( } fn include_platform_art_recovery_current_item_after_install_error( + root: &Path, canonical: &Path, previous: &PlatformArtRecoveryFileState, desired: &PlatformArtRecoveryFileState, applied: &mut Vec, error: String, ) -> String { - match read_platform_art_recovery_file_state( + match read_platform_art_recovery_file_state_anchored( + root, canonical, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, "平台图集事务 CAS 错误后安装状态", @@ -3127,7 +3882,7 @@ where F: FnMut(usize, &Path) -> Result<(), String>, { let trusted_transaction_directory = - TrustedPlatformArtTransactionDirectory::open(transaction_directory)?; + TrustedPlatformArtTransactionDirectory::open_anchored(root, transaction_directory)?; let prepared_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED); let committed_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_COMMITTED); if strict_platform_art_transaction_marker_exists( @@ -3263,7 +4018,8 @@ where } let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES.saturating_sub(total_rollback_bytes); - let previous = match read_platform_art_recovery_file_state( + let previous = match read_platform_art_recovery_file_state_anchored( + root, &canonical, remaining, "平台图集事务恢复前合同", @@ -3301,6 +4057,7 @@ where &recovery_suffix, ) { let error = include_platform_art_recovery_current_item_after_install_error( + root, &canonical, &previous, &desired, @@ -3319,7 +4076,8 @@ where previous, installed: desired.clone(), }); - match read_platform_art_recovery_file_state( + match read_platform_art_recovery_file_state_anchored( + root, &canonical, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, "平台图集事务本轮安装结果", @@ -3346,6 +4104,35 @@ where } } } + for applied_entry in &applied { + match read_platform_art_recovery_file_state_anchored( + root, + &applied_entry.canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务清理前整组安装结果", + ) { + Ok(observed) if observed == applied_entry.installed => {} + Ok(_) => { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + format!( + "平台图集事务清理前整组 CAS 已漂移,已保留事务等待对账:{}", + applied_entry.canonical.display() + ), + )); + } + Err(error) => { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + error, + )); + } + } + } cleanup_interrupted_platform_art_contract_files_at(root)?; sync_strict_platform_art_contract_state_at(root, false)?; trusted_transaction_directory.verify()?; @@ -3396,56 +4183,59 @@ struct PlatformArtSliceContractRollback { struct TrustedPlatformArtContractCaptureFile { path: PathBuf, + parent: TrustedPlatformArtRecoveryParent, file: fs::File, metadata: fs::Metadata, bytes: Vec, } enum TrustedPlatformArtContractCaptureSource { - Missing(PathBuf), + Missing { root: PathBuf, path: PathBuf }, Present(TrustedPlatformArtContractCaptureFile), } fn open_platform_art_contract_capture_source( + root: &Path, path: &Path, max_bytes: u64, ) -> Result { - let path_metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, + let Some(parent) = TrustedPlatformArtRecoveryParent::open_optional(root, path, false)? else { + return Ok(TrustedPlatformArtContractCaptureSource::Missing { + root: root.to_path_buf(), + path: path.to_path_buf(), + }); + }; + let file = match parent.open_file(&parent.leaf) { + Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(TrustedPlatformArtContractCaptureSource::Missing( - path.to_path_buf(), - )); + return Ok(TrustedPlatformArtContractCaptureSource::Missing { + root: root.to_path_buf(), + path: path.to_path_buf(), + }); } Err(error) => { return Err(format!( - "读取既有平台图集切片合同元数据失败:{}: {error}", + "安全锚定既有平台图集切片合同快照来源失败:{}: {error}", path.display() )); } }; - if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { - return Err(format!( - "既有平台图集切片合同快照来源不是可信普通文件:{}", - path.display() - )); - } - if path_metadata.len() > max_bytes { - return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); - } - let file = open_platform_art_transaction_file_for_read(path).map_err(|error| { - format!( - "安全打开既有平台图集切片合同快照来源失败:{}: {error}", - path.display() - ) - })?; let metadata = file.metadata().map_err(|error| { format!( "读取已打开平台图集切片合同元数据失败:{}: {error}", path.display() ) })?; - let current = open_platform_art_transaction_file_for_read(path).map_err(|error| { + if !metadata.is_file() { + return Err(format!( + "既有平台图集切片合同快照来源不是可信普通文件:{}", + path.display() + )); + } + if metadata.len() > max_bytes { + return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); + } + let current = parent.open_file(&parent.leaf).map_err(|error| { format!( "复核打开既有平台图集切片合同快照来源失败:{}: {error}", path.display() @@ -3457,6 +4247,7 @@ fn open_platform_art_contract_capture_source( path.display() ) })?; + parent.verify_path()?; if !platform_art_transaction_metadata_is_trusted(&metadata, max_bytes) || !platform_art_transaction_metadata_is_trusted(¤t_metadata, max_bytes) || !platform_art_transaction_open_files_match( @@ -3465,7 +4256,6 @@ fn open_platform_art_contract_capture_source( ¤t, ¤t_metadata, )? - || !platform_art_transaction_metadata_unchanged(&path_metadata, ¤t_metadata) { return Err(format!( "既有平台图集切片合同快照来源在打开期间发生变化,已拒绝提交:{}", @@ -3475,6 +4265,7 @@ fn open_platform_art_contract_capture_source( Ok(TrustedPlatformArtContractCaptureSource::Present( TrustedPlatformArtContractCaptureFile { path: path.to_path_buf(), + parent, file, metadata, bytes: Vec::new(), @@ -3487,8 +4278,14 @@ fn verify_platform_art_contract_capture_sources( ) -> Result<(), String> { for source in sources { match source { - TrustedPlatformArtContractCaptureSource::Missing(path) => { - match fs::symlink_metadata(&*path) { + TrustedPlatformArtContractCaptureSource::Missing { root, path } => { + let Some(parent) = + TrustedPlatformArtRecoveryParent::open_optional(root, path, false)? + else { + continue; + }; + parent.verify_path()?; + match parent.open_file(&parent.leaf) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Ok(_) => { return Err(format!( @@ -3505,6 +4302,7 @@ fn verify_platform_art_contract_capture_sources( } } TrustedPlatformArtContractCaptureSource::Present(captured) => { + captured.parent.verify_path()?; let verify_bytes = read_platform_art_transaction_file_once( &mut captured.file, u64::try_from(captured.bytes.len()).unwrap_or(u64::MAX), @@ -3517,14 +4315,16 @@ fn verify_platform_art_contract_capture_sources( captured.path.display() ) })?; - let current = open_platform_art_transaction_file_for_read(&captured.path).map_err( - |error| { - format!( - "一致性复核打开当前平台图集合同失败:{}: {error}", - captured.path.display() - ) - }, - )?; + let current = + captured + .parent + .open_file(&captured.parent.leaf) + .map_err(|error| { + format!( + "一致性复核打开当前平台图集合同失败:{}: {error}", + captured.path.display() + ) + })?; let current_metadata = current.metadata().map_err(|error| { format!( "读取当前平台图集合同元数据失败:{}: {error}", @@ -3577,18 +4377,14 @@ impl PlatformArtSliceContractRollback { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(format!("检查平台图集事务目录失败:{error}")), } - let parent = transaction_directory - .parent() - .ok_or_else(|| "平台图集事务目录缺少父目录".to_string())?; - fs::create_dir_all(parent) - .map_err(|error| format!("创建平台图集事务父目录失败:{error}"))?; - fs::create_dir(&transaction_directory) - .map_err(|error| format!("创建平台图集事务目录失败:{error}"))?; + let trusted_transaction_directory = + TrustedPlatformArtTransactionDirectory::create_anchored(root, &transaction_directory)?; let capture_result = (|| { let mut sources = Vec::with_capacity(STRICT_PLATFORM_ART_CONTRACT_PATHS.len()); for local_path in STRICT_PLATFORM_ART_CONTRACT_PATHS { let canonical = resolve_local_project_path(root, local_path)?; sources.push(open_platform_art_contract_capture_source( + root, &canonical, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, )?); @@ -3659,8 +4455,8 @@ impl PlatformArtSliceContractRollback { { if let TrustedPlatformArtContractCaptureSource::Present(captured) = source { let snapshot = format!("{index:02}.snapshot"); - write_durable_platform_art_transaction_file( - &transaction_directory.join(&snapshot), + trusted_transaction_directory.write_child_new( + std::ffi::OsStr::new(&snapshot), &captured.bytes, "平台图集事务快照", )?; @@ -3683,32 +4479,40 @@ impl PlatformArtSliceContractRollback { "entries": entries, })) .map_err(|error| format!("序列化平台图集事务 journal 失败:{error}"))?; - write_durable_platform_art_transaction_file( - &transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL), + trusted_transaction_directory.write_child_new( + std::ffi::OsStr::new(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL), &journal, "平台图集事务 journal", )?; - sync_platform_art_directory(&transaction_directory, "平台图集事务")?; + trusted_transaction_directory + .handle + .sync_all() + .map_err(|error| format!("同步平台图集事务目录失败:{error}"))?; // Keep every existing source handle alive through journal durability, // then collectively prove the same captured state again immediately // before publishing the prepared marker. verify_platform_art_contract_capture_sources(&mut sources)?; - write_atomic_platform_art_transaction_marker( - &transaction_directory, + trusted_transaction_directory.publish_marker( STRICT_PLATFORM_ART_TRANSACTION_PREPARED, b"prepared\n", "平台图集事务 prepared marker", )?; - sync_platform_art_directory(parent, "平台图集事务父")?; + #[cfg(unix)] + trusted_transaction_directory + .parent_handle + .sync_all() + .map_err(|error| format!("同步平台图集事务父目录失败:{error}"))?; Ok::<(), String>(()) })(); if let Err(error) = capture_result { let cleanup_error = - remove_strict_platform_art_transaction_directory(&transaction_directory).err(); + remove_trusted_platform_art_transaction_directory(trusted_transaction_directory) + .err(); return Err(cleanup_error .map(|cleanup_error| format!("{error};{cleanup_error}")) .unwrap_or(error)); } + drop(trusted_transaction_directory); Ok(Self { root: root.to_path_buf(), transaction_directory, @@ -3718,8 +4522,17 @@ impl PlatformArtSliceContractRollback { fn commit(&mut self) -> Result, String> { sync_strict_platform_art_contract_state_at(&self.root, true)?; - write_atomic_platform_art_transaction_marker( - &self.transaction_directory, + let trusted_transaction_directory = + TrustedPlatformArtTransactionDirectory::open_anchored( + &self.root, + &self.transaction_directory, + ) + .map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同待提交,但锚定事务目录失败:{error}" + ) + })?; + trusted_transaction_directory.publish_marker( STRICT_PLATFORM_ART_TRANSACTION_COMMITTED, b"committed\n", "平台图集事务 committed marker", @@ -3728,14 +4541,6 @@ impl PlatformArtSliceContractRollback { let prepared_path = self .transaction_directory .join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED); - let trusted_transaction_directory = TrustedPlatformArtTransactionDirectory::open( - &self.transaction_directory, - ) - .map_err(|error| { - format!( - "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但锚定事务目录失败:{error}" - ) - })?; match trusted_transaction_directory .open_child_for_read(&prepared_path, "平台图集事务 prepared marker") { @@ -6555,6 +7360,227 @@ mod canvas_generation_tests { ); } + #[cfg(unix)] + #[test] + fn platform_art_recovery_parent_handle_survives_pathname_ancestor_replacement() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("create anchored recovery parent fixture"); + let root = temporary.path().join("project"); + let assets = root.join("assets"); + let displaced = root.join("assets-displaced"); + let outside = temporary.path().join("outside"); + fs::create_dir_all(&assets).expect("create canonical assets"); + fs::create_dir_all(&outside).expect("create outside directory"); + let canonical = assets.join("manifest.art.json"); + let parent = TrustedPlatformArtRecoveryParent::open(&root, &canonical, true) + .expect("anchor canonical parent from project root"); + + fs::rename(&assets, &displaced).expect("displace anchored assets directory"); + symlink(&outside, &assets).expect("replace assets pathname with outside symlink"); + parent + .write_new( + std::ffi::OsStr::new("installing"), + b"anchored-contract", + "测试锚定安装暂存", + ) + .expect("write through anchored parent"); + parent + .hard_link(std::ffi::OsStr::new("installing"), &parent.leaf) + .expect("install through anchored parent"); + + assert_eq!( + fs::read(displaced.join("manifest.art.json")).expect("read anchored install"), + b"anchored-contract" + ); + assert!(!outside.join("manifest.art.json").exists()); + } + + #[test] + fn windows_directory_handle_roles_keep_ancestor_pins_repeatable() { + const DELETE_ACCESS: u32 = 0x0001_0000; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + + let (ancestor_access, ancestor_share) = platform_art_windows_directory_open_policy( + PlatformArtWindowsDirectoryHandleRole::RecoveryAncestorPin, + ); + let (transaction_access, transaction_share) = platform_art_windows_directory_open_policy( + PlatformArtWindowsDirectoryHandleRole::TransactionDirectory, + ); + + assert_eq!(ancestor_access & DELETE_ACCESS, 0); + assert_eq!(ancestor_share & FILE_SHARE_DELETE, 0); + assert_ne!(transaction_access & DELETE_ACCESS, 0); + assert_eq!(transaction_share & FILE_SHARE_DELETE, 0); + } + + #[cfg(unix)] + #[test] + fn platform_art_recovery_read_rejects_in_place_rewrite_between_stable_reads() { + let temporary = tempfile::tempdir().expect("create recovery stable-read fixture"); + let root = temporary.path(); + fs::create_dir_all(root.join("assets")).expect("create assets"); + let canonical = root.join("assets/manifest.art.json"); + fs::write(&canonical, b"old-data").expect("write original state"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &canonical, false) + .expect("anchor recovery parent"); + + let error = parent + .read_state_with_hook(&parent.leaf, 64, "测试恢复稳定读取", || { + fs::write(&canonical, b"new-data") + .map_err(|error| format!("rewrite recovery target: {error}")) + }) + .expect_err("in-place rewrite between stable reads must fail closed"); + + assert!( + error.contains("读取期间发生变化"), + "unexpected error: {error}" + ); + } + + #[cfg(unix)] + #[test] + fn platform_art_recovery_read_rejects_same_content_leaf_inode_replacement() { + let temporary = tempfile::tempdir().expect("create recovery leaf-identity fixture"); + let root = temporary.path(); + fs::create_dir_all(root.join("assets")).expect("create assets"); + let canonical = root.join("assets/manifest.art.json"); + let replacement = root.join("assets/replacement.json"); + fs::write(&canonical, b"same-data").expect("write original state"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &canonical, false) + .expect("anchor recovery parent"); + + let error = parent + .read_state_with_hook(&parent.leaf, 64, "测试恢复叶子身份", || { + fs::write(&replacement, b"same-data") + .map_err(|error| format!("write replacement target: {error}"))?; + fs::rename(&replacement, &canonical) + .map_err(|error| format!("replace recovery target: {error}")) + }) + .expect_err("same-content inode replacement must fail closed"); + + assert!( + error.contains("读取期间发生变化"), + "unexpected error: {error}" + ); + } + + #[cfg(unix)] + #[test] + fn platform_art_recovery_read_rejects_fifo_without_blocking() { + use std::os::unix::ffi::OsStrExt; + + let temporary = tempfile::tempdir().expect("create recovery FIFO fixture"); + let root = temporary.path(); + fs::create_dir_all(root.join("assets")).expect("create assets"); + let canonical = root.join("assets/manifest.art.json"); + let fifo_name = + std::ffi::CString::new(canonical.as_os_str().as_bytes()).expect("encode FIFO path"); + assert_eq!(unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, 0); + let parent = TrustedPlatformArtRecoveryParent::open(root, &canonical, false) + .expect("anchor recovery parent"); + + let error = parent + .read_state(&parent.leaf, 64, "测试恢复 FIFO") + .expect_err("FIFO recovery target must fail closed without waiting for a writer"); + + assert!(error.contains("可信普通文件"), "unexpected error: {error}"); + } + + #[cfg(unix)] + #[test] + fn platform_art_recovery_move_is_no_replace_when_backup_appears() { + let temporary = tempfile::tempdir().expect("create no-replace recovery fixture"); + let root = temporary.path(); + fs::create_dir_all(root.join("assets")).expect("create assets"); + let canonical = root.join("assets/manifest.art.json"); + fs::write(&canonical, b"canonical").expect("write canonical"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &canonical, true) + .expect("anchor recovery parent"); + let backup = std::ffi::OsStr::new("backup"); + fs::write(root.join("assets/backup"), b"concurrent-backup") + .expect("create concurrent backup"); + + parent + .move_no_replace(&parent.leaf, backup) + .expect_err("no-replace move must reject an existing backup"); + assert_eq!(fs::read(&canonical).expect("read canonical"), b"canonical"); + assert_eq!( + fs::read(root.join("assets/backup")).expect("read preserved backup"), + b"concurrent-backup" + ); + } + + #[cfg(unix)] + #[test] + fn trusted_transaction_directory_rejects_fifo_without_blocking() { + use std::os::unix::ffi::OsStrExt; + + let temporary = tempfile::tempdir().expect("create FIFO transaction fixture"); + let transaction_directory = temporary.path().join("transaction"); + fs::create_dir(&transaction_directory).expect("create transaction directory"); + let fifo = transaction_directory.join("journal.json"); + let fifo_name = + std::ffi::CString::new(fifo.as_os_str().as_bytes()).expect("encode FIFO path"); + assert_eq!(unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, 0); + let trusted = TrustedPlatformArtTransactionDirectory::open(&transaction_directory) + .expect("anchor transaction directory"); + + let error = read_bounded_platform_art_transaction_file_in_directory( + &trusted, + &fifo, + 64, + "测试 FIFO journal", + ) + .expect_err("FIFO transaction child must fail closed without waiting for a writer"); + assert!(error.contains("可信普通文件"), "unexpected error: {error}"); + } + + #[test] + fn durable_recovery_rechecks_the_entire_installed_set_before_cleanup() { + let temporary = tempfile::tempdir().expect("create final CAS recovery fixture"); + let root = temporary.path(); + init_local_game_project_at(root, "final-cas-recovery", "整组 CAS 复核测试") + .expect("init project"); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + fs::create_dir_all(path.parent().expect("contract parent")) + .expect("create contract parent"); + fs::write(&path, format!("old-contract-{index}")).expect("write original contract"); + } + let transaction = PlatformArtSliceContractRollback::capture(root, "final-cas") + .expect("capture recovery transaction"); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + fs::write(root.join(local_path), format!("partial-contract-{index}")) + .expect("write partial contract"); + } + let transaction_directory = transaction.transaction_directory.clone(); + std::mem::forget(transaction); + let first = root.join(STRICT_PLATFORM_ART_CONTRACT_PATHS[0]); + + let error = restore_strict_platform_art_transaction_at_with_hook( + root, + &transaction_directory, + |index, _| { + if index == 1 { + fs::write(&first, b"external-after-first-install") + .map_err(|error| format!("mutate first installed target: {error}"))?; + } + Ok(()) + }, + ) + .expect_err("final whole-set CAS must catch an earlier installed target drifting"); + assert!( + error.contains("整组 CAS") || error.contains("反向回滚"), + "unexpected error: {error}" + ); + assert!(transaction_directory.exists()); + assert_eq!( + fs::read(first).expect("read preserved external target"), + b"external-after-first-install" + ); + } + #[cfg(unix)] #[test] fn trusted_transaction_directory_opens_children_relative_to_anchored_handle() { @@ -6673,6 +7699,7 @@ mod canvas_generation_tests { let mut applied = Vec::new(); let error = include_platform_art_recovery_current_item_after_install_error( + root, &canonical, &previous, &desired, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 5d568a937..4cc55a173 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -20,8 +20,9 @@ use oxc_ast::ast::{ use oxc_ast_visit::Visit as VisitJavascript; use oxc_parser::Parser as JavascriptParser; use oxc_semantic::{ - ScopeFlags as JavascriptScopeFlags, Scoping as JavascriptScoping, - SemanticBuilder as JavascriptSemanticBuilder, SymbolId as JavascriptSymbolId, + ReferenceId as JavascriptReferenceId, ScopeFlags as JavascriptScopeFlags, + Scoping as JavascriptScoping, SemanticBuilder as JavascriptSemanticBuilder, + SymbolId as JavascriptSymbolId, }; use oxc_span::{GetSpan as JavascriptGetSpan, SourceType as JavascriptSourceType}; @@ -3483,13 +3484,20 @@ enum JavascriptExportTarget { Reexport { source: String, imported: String }, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct JavascriptImportReferenceSpan { + range: std::ops::Range, + shorthand: bool, +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct JavascriptModuleAnalysis { static_sources: Vec, import_declaration_ranges: Vec>, imports: Vec<(String, BTreeMap)>, used_import_locals: BTreeSet, - namespace_import_members: BTreeMap>, + import_reference_spans: BTreeMap>, + namespace_import_members: BTreeMap>>>, exports: BTreeMap, link_exports: BTreeMap, synthetic_declarations: BTreeMap>, @@ -3509,6 +3517,8 @@ fn javascript_module_export_name(name: &JavascriptModuleExportName<'_>) -> Strin struct JavascriptModuleAnalysisCollector { analysis: JavascriptModuleAnalysis, import_symbols: Vec<(String, JavascriptSymbolId)>, + import_origins: BTreeMap, + pending_export_references: Vec<(String, JavascriptReferenceId)>, } impl JavascriptModuleAnalysisCollector { @@ -3568,6 +3578,13 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { ); if let Some(symbol_id) = specifier.local.symbol_id.get() { self.import_symbols.push((local, symbol_id)); + self.import_origins.insert( + symbol_id, + ( + source.clone(), + javascript_module_export_name(&specifier.imported), + ), + ); } } JavascriptImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { @@ -3575,6 +3592,8 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { bindings.insert("default".to_string(), local.clone()); if let Some(symbol_id) = specifier.local.symbol_id.get() { self.import_symbols.push((local, symbol_id)); + self.import_origins + .insert(symbol_id, (source.clone(), "default".to_string())); } } JavascriptImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { @@ -3582,6 +3601,8 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { bindings.insert("*".to_string(), local.clone()); if let Some(symbol_id) = specifier.local.symbol_id.get() { self.import_symbols.push((local.clone(), symbol_id)); + self.import_origins + .insert(symbol_id, (source.clone(), "*".to_string())); } } } @@ -3609,6 +3630,12 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { exported.to_ascii_lowercase(), JavascriptExportTarget::Local(local.to_ascii_lowercase()), ); + if let JavascriptModuleExportName::IdentifierReference(local) = &specifier.local { + if let Some(reference_id) = local.reference_id.get() { + self.pending_export_references + .push((exported, reference_id)); + } + } } } @@ -3707,11 +3734,16 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { struct JavascriptNamespaceUsageCollector<'a> { scoping: &'a JavascriptScoping, namespaces: BTreeMap, - members: BTreeMap>, + members: BTreeMap>>>, } impl JavascriptNamespaceUsageCollector<'_> { - fn record(&mut self, object: &JavascriptExpression<'_>, property: Option<&str>) { + fn record( + &mut self, + object: &JavascriptExpression<'_>, + property: Option<&str>, + span: oxc_span::Span, + ) { let (JavascriptExpression::Identifier(identifier), Some(property)) = (object, property) else { return; @@ -3726,14 +3758,20 @@ impl JavascriptNamespaceUsageCollector<'_> { self.members .entry(namespace.clone()) .or_default() - .insert(property.to_string()); + .entry(property.to_string()) + .or_default() + .push(span.start as usize..span.end as usize); } } } impl<'a> VisitJavascript<'a> for JavascriptNamespaceUsageCollector<'_> { fn visit_static_member_expression(&mut self, member: &JavascriptStaticMemberExpression<'a>) { - self.record(&member.object, Some(member.property.name.as_str())); + self.record( + &member.object, + Some(member.property.name.as_str()), + member.span, + ); oxc_ast_visit::walk::walk_static_member_expression(self, member); } @@ -3744,11 +3782,80 @@ impl<'a> VisitJavascript<'a> for JavascriptNamespaceUsageCollector<'_> { self.record( &member.object, member.static_property_name().map(|name| name.as_str()), + member.span, ); oxc_ast_visit::walk::walk_computed_member_expression(self, member); } } +struct JavascriptShorthandReferenceCollector<'a> { + scoping: &'a JavascriptScoping, + symbol_id: JavascriptSymbolId, + ranges: BTreeSet<(usize, usize)>, +} + +struct JavascriptSymbolReferenceCollector<'a> { + scoping: &'a JavascriptScoping, + symbol_id: JavascriptSymbolId, + ranges: BTreeSet<(usize, usize)>, +} + +struct JavascriptImportReferenceCollector<'a> { + scoping: &'a JavascriptScoping, + imports: BTreeMap, + shorthand_ranges: BTreeSet<(usize, usize)>, + spans: BTreeMap>, +} + +impl<'a> VisitJavascript<'a> for JavascriptImportReferenceCollector<'_> { + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + let range = identifier.span.start as usize..identifier.span.end as usize; + if let Some(local) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .and_then(|symbol_id| self.imports.get(&symbol_id)) + { + self.spans + .entry(local.clone()) + .or_default() + .push(JavascriptImportReferenceSpan { + shorthand: self.shorthand_ranges.contains(&(range.start, range.end)), + range, + }); + } + oxc_ast_visit::walk::walk_identifier_reference(self, identifier); + } +} + +impl<'a> VisitJavascript<'a> for JavascriptSymbolReferenceCollector<'_> { + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if identifier.reference_id.get().is_some_and(|reference_id| { + self.scoping.get_reference(reference_id).symbol_id() == Some(self.symbol_id) + }) { + self.ranges + .insert((identifier.span.start as usize, identifier.span.end as usize)); + } + oxc_ast_visit::walk::walk_identifier_reference(self, identifier); + } +} + +impl<'a> VisitJavascript<'a> for JavascriptShorthandReferenceCollector<'_> { + fn visit_object_property(&mut self, property: &JavascriptObjectProperty<'a>) { + if property.shorthand { + if let JavascriptExpression::Identifier(identifier) = &property.value { + if identifier.reference_id.get().is_some_and(|reference_id| { + self.scoping.get_reference(reference_id).symbol_id() == Some(self.symbol_id) + }) { + self.ranges + .insert((identifier.span.start as usize, identifier.span.end as usize)); + } + } + } + oxc_ast_visit::walk::walk_object_property(self, property); + } +} + fn javascript_module_analysis(content: &str, is_module: bool) -> Option { let allocator = JavascriptAllocator::default(); let parsed = @@ -3773,6 +3880,51 @@ fn javascript_module_analysis(content: &str, is_module: bool) -> Option std::collections::BTreeMa declarations } -fn javascript_module_binding_projection(content: &str, imported: &BTreeSet) -> String { +pub(in crate::agent) fn javascript_module_binding_projection( + content: &str, + imported: &BTreeSet, +) -> String { let Some(analysis) = javascript_module_analysis(content, true) else { return String::new(); }; @@ -3905,122 +4060,122 @@ fn javascript_module_binding_projection(content: &str, imported: &BTreeSet String { - let mut output = String::with_capacity(content.len()); - let mut cursor = 0usize; - while let Some(offset) = content[cursor..].find(from) { - let start = cursor + offset; - let end = start + from.len(); - let has_boundary = (start == 0 - || !is_ascii_word_byte(content.as_bytes()[start - 1]) - && content.as_bytes()[start - 1] != b'$') - && content - .as_bytes() - .get(end) - .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$'); - output.push_str(&content[cursor..start]); - if has_boundary && !position_is_inside_javascript_string(content, start) { - output.push_str(to); - } else { - output.push_str(from); +pub(in crate::agent) fn apply_javascript_span_replacements( + content: &mut String, + replacements: &mut Vec<(std::ops::Range, String)>, +) -> bool { + replacements.sort_by(|(left, _), (right, _)| right.start.cmp(&left.start)); + let mut ceiling = content.len(); + for (range, replacement) in replacements.drain(..) { + if range.start > range.end + || range.end > ceiling + || !content.is_char_boundary(range.start) + || !content.is_char_boundary(range.end) + { + return false; } - cursor = end; + content.replace_range(range.clone(), &replacement); + ceiling = range.start; } - output.push_str(&content[cursor..]); - output + true } -fn replace_javascript_namespace_member( - content: &str, - namespace: &str, - member: &str, - replacement: &str, -) -> String { - let bytes = content.as_bytes(); - let mut output = String::with_capacity(content.len()); - let mut cursor = 0usize; - while let Some(offset) = content[cursor..].find(namespace) { - let start = cursor + offset; - let namespace_end = start + namespace.len(); - let has_namespace_boundary = (start == 0 - || !is_ascii_word_byte(bytes[start - 1]) && bytes[start - 1] != b'$') - && bytes - .get(namespace_end) - .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$'); - let mut access = namespace_end; - while bytes.get(access).is_some_and(u8::is_ascii_whitespace) { - access += 1; - } - let member_range = if bytes.get(access) == Some(&b'.') { - let mut member_start = access + 1; - while bytes.get(member_start).is_some_and(u8::is_ascii_whitespace) { - member_start += 1; - } - Some((member_start, member_start.saturating_add(member.len()))) - } else { - if bytes.get(access..access.saturating_add(2)) == Some(b"?.") { - access += 2; - } - if bytes.get(access) != Some(&b'[') { - None - } else { - let mut quote_at = access + 1; - while bytes.get(quote_at).is_some_and(u8::is_ascii_whitespace) { - quote_at += 1; - } - let quote = bytes.get(quote_at).copied(); - if !matches!(quote, Some(b'\'' | b'"')) { - None - } else { - let member_start = quote_at + 1; - let member_end = member_start.saturating_add(member.len()); - let mut bracket_at = member_end + 1; - while bytes.get(bracket_at).is_some_and(u8::is_ascii_whitespace) { - bracket_at += 1; - } - (bytes.get(member_end).copied() == quote - && bytes.get(bracket_at) == Some(&b']')) - .then_some((member_start, bracket_at + 1)) - } - } - }; - let Some((member_start, replacement_end)) = member_range else { - output.push_str(&content[cursor..namespace_end]); - cursor = namespace_end; - continue; - }; - let member_end = member_start.saturating_add(member.len()); - let has_member_boundary = content - .get(member_start..member_end) - .is_some_and(|value| value == member) - && bytes - .get(member_end) - .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$'); - output.push_str(&content[cursor..start]); - if has_namespace_boundary - && has_member_boundary - && !position_is_inside_javascript_string(content, start) - { - output.push_str(replacement); - cursor = replacement_end; - } else { - output.push_str(namespace); - cursor = namespace_end; - } +pub(in crate::agent) fn rename_javascript_root_binding( + content: &mut String, + from: &str, + to: &str, +) -> bool { + if from == to { + return true; } - output.push_str(&content[cursor..]); - output + let allocator = JavascriptAllocator::default(); + let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(true)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return false; + } + let semantic = JavascriptSemanticBuilder::new_compiler().build(&parsed.program); + if !semantic.diagnostics.is_empty() { + return false; + } + let Some(symbol_id) = semantic.semantic.scoping().get_root_binding(from.into()) else { + return false; + }; + let mut shorthand_references = JavascriptShorthandReferenceCollector { + scoping: semantic.semantic.scoping(), + symbol_id, + ranges: BTreeSet::new(), + }; + shorthand_references.visit_program(&parsed.program); + let mut symbol_references = JavascriptSymbolReferenceCollector { + scoping: semantic.semantic.scoping(), + symbol_id, + ranges: BTreeSet::new(), + }; + symbol_references.visit_program(&parsed.program); + let mut replacements = vec![( + { + let span = semantic.semantic.scoping().symbol_span(symbol_id); + span.start as usize..span.end as usize + }, + to.to_string(), + )]; + replacements.extend( + semantic + .semantic + .scoping() + .symbol_redeclarations(symbol_id) + .iter() + .map(|redeclaration| { + ( + redeclaration.span.start as usize..redeclaration.span.end as usize, + to.to_string(), + ) + }), + ); + replacements.extend(symbol_references.ranges.into_iter().map(|(start, end)| { + let range = start..end; + let replacement = if shorthand_references + .ranges + .contains(&(range.start, range.end)) + { + format!("{from}: {to}") + } else { + to.to_string() + }; + (range, replacement) + })); + replacements.sort_by(|(left, _), (right, _)| { + left.start + .cmp(&right.start) + .then_with(|| left.end.cmp(&right.end)) + }); + replacements.dedup_by(|(left, _), (right, _)| left == right); + apply_javascript_span_replacements(content, &mut replacements) } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -4034,6 +4189,11 @@ impl ExternalGameplayJavascript { self.classic_global.contains(marker) || self.module_units.iter().any(|unit| unit.contains(marker)) } + + #[cfg(test)] + pub(in crate::agent) fn module_units(&self) -> &[String] { + &self.module_units + } } fn normalize_javascript_module_analysis_sources( @@ -4301,6 +4461,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } let mut unit = String::from_utf8(unit_bytes) .expect("masking parsed JavaScript imports preserves UTF-8"); + let mut unit_replacements = Vec::<(std::ops::Range, String)>::new(); let mut added_projection = false; for (dependency, bindings) in dependencies { if bindings.is_empty() { @@ -4316,7 +4477,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .cloned() .unwrap_or_default() .into_iter() - .map(|member| (member.clone(), member, Some(local.clone()))) + .map(|(member, _)| (member.clone(), member, Some(local.clone()))) .collect::>() } else if importer_analysis.used_import_locals.contains(&local) { vec![(exported, local, None)] @@ -4340,6 +4501,7 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .cloned() .unwrap_or_default() { + let (member, _) = member; if let Some((member_origin, member_export)) = javascript_module_export_origin( &origin, @@ -4382,17 +4544,36 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( JavascriptExportTarget::Reexport { .. } => None, }) .unwrap_or(exported.as_str()); - if origin_local != local { - projection = - replace_javascript_identifier(&projection, origin_local, &local); - } if let Some(namespace) = namespace { - unit = replace_javascript_namespace_member( - &unit, - &namespace, - &exported.to_ascii_lowercase(), - &local, - ); + if let Some(ranges) = importer_analysis + .namespace_import_members + .get(&namespace) + .and_then(|members| members.get(&exported)) + { + unit_replacements.extend( + ranges + .iter() + .cloned() + .map(|range| (range, origin_local.to_string())), + ); + } + } else if origin_local == "__agc_default_export__" { + if !rename_javascript_root_binding(&mut projection, origin_local, &local) { + return Err(format!( + "自主构建模块匿名 default 投影绑定无法按符号重命名:{origin}::{exported} -> {local}" + )); + } + } else if origin_local != local { + if let Some(spans) = importer_analysis.import_reference_spans.get(&local) { + unit_replacements.extend(spans.iter().map(|span| { + let replacement = if span.shorthand { + format!("{local}: {origin_local}") + } else { + origin_local.to_string() + }; + (span.range.clone(), replacement) + })); + } } } if !projection.is_empty() { @@ -4403,6 +4584,9 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } } if added_projection { + if !apply_javascript_span_replacements(&mut unit, &mut unit_replacements) { + return Err(format!("自主构建模块投影引用范围冲突:{importer}")); + } output.module_units.push(unit); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 7a7139ccb..eed40af04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -2383,6 +2383,48 @@ import './gameplay/tetris.mjs';"#, "explicitly imported exported gameplay bindings may form one semantic chain", ); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "import {{ board, current, spin as turnPiece, clearLines as clearRows }} from './scope-a.mjs';\nconst importerMetadata={{ rotatePiece: 'property-only' }};\nconst aliasView={{ turnPiece }};\nfunction shadow(turnPiece) {{ return turnPiece(); }}\nturnPiece();\n{}", + gameplay[split_at..] + .replace("rotatePiece", "turnPiece") + .replace("clearLines();", "clearRows();") + ), + ) + .expect("write aliased consumer with property and parameter shadows"); + let aliased_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read symbol-aware aliased module projection"); + let aliased_unit = aliased_modules + .module_units() + .iter() + .find(|unit| { + unit.contains("function rotatepiece()") + && unit.contains("const importermetadata={ rotatepiece: 'property-only' }") + }) + .expect("aliased importer must be joined with its renamed origin projection"); + assert!( + aliased_unit.contains("function shadow(turnpiece) { return turnpiece(); }"), + "a local parameter shadow in the importer must not be confused with the import symbol", + ); + assert!( + aliased_unit.contains("const importermetadata={ rotatepiece: 'property-only' }"), + "an importer object property must not be rewritten as an imported binding", + ); + assert!( + aliased_unit.contains("const aliasview={ turnpiece: rotatepiece };"), + "an imported shorthand value must be projected without changing its object key", + ); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &aliased_modules, + ), + None, + "an import alias must project only the dependency's root binding references", + ); + fs::write( root.join("game/shadowed-import.mjs"), format!( @@ -2461,7 +2503,7 @@ import './gameplay/tetris.mjs';"#, .replace("let current=", "export let current=") .replace("function rotatePiece()", "export function rotatePiece()") .replace("function clearLines()", "export function clearLines()"); - fs::write(root.join("game/scope-a.mjs"), namespace_scope_a) + fs::write(root.join("game/scope-a.mjs"), &namespace_scope_a) .expect("write namespace-exported gameplay bindings"); let namespace_consumer = gameplay[split_at..] .replace("board", "gameplay.board") @@ -2510,6 +2552,76 @@ import './gameplay/tetris.mjs';"#, "a namespace re-export must preserve statically referenced member bindings", ); + fs::write( + root.join("game/import-export-bridge.mjs"), + "import { board as importedBoard, current as importedCurrent, rotatePiece as importedRotate, clearLines as importedClear } from './scope-a.mjs'; export { importedBoard as board, importedCurrent as current, importedRotate as rotatePiece, importedClear as clearLines };", + ) + .expect("write import-then-export bridge"); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "import {{ board, current, rotatePiece, clearLines }} from './import-export-bridge.mjs';\nrotatePiece();\n{}", + &gameplay[split_at..] + ), + ) + .expect("write import-then-export bridge consumer"); + let bridge_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read modules joined through an import-then-export bridge"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &bridge_modules, + ), + None, + "a local import-then-export bridge must resolve to the dependency's binding origin", + ); + + fs::write(root.join("game/scope-a.mjs"), namespace_scope_a) + .expect("restore namespace-exported gameplay bindings"); + fs::write( + root.join("game/scope-b.mjs"), + format!( + "import * as gameplay from './scope-a.mjs';\ngameplay.rotatePiece();\nconst namespaceView = {{ rotatePiece: gameplay.rotatePiece }};\nconst namespaceMetadata = {{ rotatePiece: 'property-only' }};\nfunction shadow(gameplay) {{ gameplay.rotatePiece(); }}\n{namespace_consumer}" + ), + ) + .expect("write shadowed namespace consumer"); + let shadowed_namespace_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read namespace consumer with a shadowed parameter"); + let projected_namespace_unit = shadowed_namespace_modules + .module_units() + .iter() + .find(|unit| { + unit.contains("function shadow(gameplay)") && unit.contains("function rotatepiece()") + }) + .expect("namespace consumer must be joined with the referenced export projection"); + assert!( + projected_namespace_unit.contains("function shadow(gameplay) { gameplay.rotatepiece(); }"), + "a shadowed namespace parameter must retain its member access in the projected unit", + ); + assert!( + !projected_namespace_unit.contains("function shadow(gameplay) { rotatepiece(); }"), + "projection must not rewrite a member access resolved to a shadowing parameter", + ); + assert!( + projected_namespace_unit + .contains("const namespaceview = { rotatepiece: rotatepiece };"), + "namespace projection must replace the complete imported member value while preserving its object key", + ); + assert!( + projected_namespace_unit + .contains("const namespacemetadata = { rotatepiece: 'property-only' };"), + "an unrelated importer object property must remain unchanged", + ); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &shadowed_namespace_modules, + ), + None, + ); + fs::write( root.join("game/invalid-link.mjs"), "import { missingGameplay } from './scope-a.mjs'; missingGameplay();", @@ -2612,6 +2724,54 @@ import './gameplay/tetris.mjs';"#, ); } +#[test] +fn javascript_module_projection_uses_symbols_for_dependencies_and_renames() { + let projection = javascript_module_binding_projection( + "const board = Array.from({ length: 20 }, () => Array(10).fill(0));\nfunction clearLines() { board.splice(0, 1); }\nexport function rotatePiece() { const metadata = { board: 'property-only' }; function shadow(clearLines) { return clearLines(); } return metadata; }", + &BTreeSet::from(["rotatepiece".to_string()]), + ); + assert!(projection.contains("function rotatePiece()")); + assert!( + !projection.contains("const board ="), + "an object property name must not pull an unrelated top-level binding into projection", + ); + assert!( + !projection.contains("function clearLines()"), + "a shadowed parameter reference must not pull the same-named root binding into projection", + ); + + let mut renamed = "const rotatePieceMetadata = { rotatePiece: 'property-only' };\nfunction shadow(rotatePiece) { return rotatePiece(); }\nexport function rotatePiece() { const explicit = { rotatePiece: 1 }; const shorthand = { rotatePiece }; shadow(() => 0); return [rotatePieceMetadata.rotatePiece, explicit.rotatePiece, shorthand]; }".to_string(); + assert!(rename_javascript_root_binding( + &mut renamed, + "rotatePiece", + "turnPiece", + )); + assert!(renamed.contains("export function turnPiece()")); + assert!(renamed.contains("function shadow(rotatePiece) { return rotatePiece(); }")); + assert!(renamed.contains("{ rotatePiece: 1 }")); + assert!(renamed.contains("rotatePieceMetadata.rotatePiece")); + assert!( + renamed.contains("const shorthand = { rotatePiece: turnPiece };"), + "renaming a shorthand binding reference must preserve its original object key", + ); + + let mut disjoint = "alpha beta gamma".to_string(); + let mut disjoint_replacements = vec![(0..5, "a".to_string()), (11..16, "g".to_string())]; + assert!(apply_javascript_span_replacements( + &mut disjoint, + &mut disjoint_replacements, + )); + assert_eq!(disjoint, "a beta g"); + + let mut overlapping = "alpha beta".to_string(); + let mut overlapping_replacements = + vec![(0..5, "a".to_string()), (0..10, "conflict".to_string())]; + assert!( + !apply_javascript_span_replacements(&mut overlapping, &mut overlapping_replacements), + "overlapping AST spans must fail closed", + ); +} + #[test] fn game_chat_pure_continue_does_not_inherit_across_sessions() { let (_temporary, root, original_state, original_contract) = diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 49118f18a..c6a857383 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5976,7 +5976,7 @@ ## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口 -- 图集事务:九文件旧合同在写 `prepared` 前必须全部持有可信源句柄并整体复读;Unix 事务控制文件统一通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW` 操作,Windows 目录句柄拒绝 delete sharing 并用文件身份复核。恢复目标从 canonical 项目根逐组件拒绝 symlink / reparse point,CAS 安装后的任何清理错误都按实际 canonical 状态把当前项纳入逆序回滚,不能留下新旧混合合同。 -- JavaScript / ESM:Tetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、re-export 与缺失导出链接不再由字符串扫描猜测;HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。 +- 图集事务:九文件旧合同在写 `prepared` 前必须全部持有可信源句柄并整体复读;Unix 事务控制文件统一通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW + O_NONBLOCK` 操作,FIFO 等非普通文件必须在读取前失败关闭,恢复前态也必须在同一叶子句柄上稳定双读并复核前后元数据与当前 inode。Windows 祖先 pin 只请求读访问并拒绝 delete sharing,可重复持有;只有事务叶子句柄请求删除访问。事务捕获与恢复 CAS 从 canonical 项目根句柄逐组件打开或创建父目录,staging、no-replace link/move 与 unlink 均相对固定父目录句柄执行;清理事务证据前再次复核整组安装结果。安装后的任何清理错误都按实际 canonical 状态把当前项纳入逆序回滚,不能留下新旧混合合同。 +- JavaScript / ESM:Tetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、import 后再 export 的 bridge、re-export 与缺失导出链接不再由字符串扫描猜测;跨模块 alias 保留 origin 根绑定名称,只按解析到 import symbol 的 reference span 改写 importer,object shorthand 展开后保留原键;namespace 只改写绑定到 import symbol 的完整 member span,同文本属性和局部遮蔽均不得连带改写。HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。 - 浏览器因果:状态证据仍只冻结 trusted input listener 及其点击派生微任务内的变化;完整手势身份改由宿主在成功完成 Chromium 元素鼠标输入后调用隔离世界 finish。更早注册的 `window` capture listener 即使调用 `stopImmediatePropagation()` 也不能阻断探针自身的完成身份,页面脚本不能伪造 host finish,RAF / timer 继续不计入动作结果。 - 验证边界:Linux 定向回归覆盖目录相对读写与清理、祖先 symlink、CAS 安装后错误、九文件混合快照、Tetris AST 反例和七项真实 Chrome generic 试玩。Windows cfg 代码必须继续在真实 Windows CI / 发布构建验证;本地缺少 MinGW C compiler 时,安装了 Rust target 也不能把交叉 `cargo check` 失败误报为源码失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6d88c4dcf..2bb4d84b5 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -827,9 +827,9 @@ game-project/ - 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。 - game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 - `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 -- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须先全部打开可信源句柄,完成有界双读,并在 journal 持久化后、发布 `prepared` marker 前再次整体复读与身份校验;任一文件在九文件捕获窗口变化都失败关闭,不能形成跨版本混合快照。事务控制文件在 Unix 通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW` 读取和清理;Windows 以拒绝 delete sharing 的目录句柄阻止 rename / replacement,并以句柄文件身份复核子文件,不能退回 pathname 前后检查。恢复目标必须从 canonical 项目根开始逐组件检查全部祖先,任何中间 symlink / reparse point 都拒绝。CAS 安装先把目标字节持久化到同目录私有 staging,再以不覆盖的原子 hard-link / rename 切换;即使 canonical 已安装后清理 backup/staging 才报错,也必须把当前项纳入同轮逆序回滚。Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复继续在同一项目写锁内完整验证全部 journal 条目和快照、形成内存计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理。回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 +- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须先全部打开可信源句柄,完成有界双读,并在 journal 持久化后、发布 `prepared` marker 前再次整体复读与身份校验;任一文件在九文件捕获窗口变化都失败关闭,不能形成跨版本混合快照。事务控制文件在 Unix 通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW + O_NONBLOCK` 读取和清理,FIFO 等非普通文件不得阻塞恢复;恢复前态在同一叶子句柄稳定双读并复核元数据、内容和当前 inode。Windows 祖先目录 pin 只请求读访问并拒绝 delete sharing,允许九文件捕获重复持有同一 root;只有事务叶子句柄请求删除访问。事务目录创建、合同源捕获与恢复 CAS 必须从 canonical 项目根句柄逐组件打开或创建父目录,staging、hard-link、no-replace move 与 unlink 全部相对固定父目录句柄执行,不能退回 pathname 预检后操作。CAS 安装先把目标字节持久化到同目录私有 staging,再以不覆盖切换安装;即使 canonical 已安装后清理 backup/staging 才报错,也必须把当前项纳入同轮逆序回滚。Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复继续在同一项目写锁内完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,逐项安装完成后、清理事务证据前还必须再次复核整组 installed 状态,再按 transaction id 整组回滚或幂等清理。回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 - 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。 -- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并在 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据;filter / splice 消行仍必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 与传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 +- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并在 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / import 后再 export 的 bridge / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。跨模块 alias 投影保留 origin 根绑定,只改写解析到 import symbol 的 importer reference span;object shorthand 展开为显式键值以保留原键,namespace 只改写完整 member span。同文本对象属性和局部遮蔽不得连带改写,投影依赖也只由 semantic 未解析根引用递归纳入。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据;filter / splice 消行仍必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 与传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 - 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。