4ecab19429
- project_write_lock_does_not_project_permission_denial_as_contention 不再用 expect_err 断言“只读目录必须挡住取锁”:CI 容器以 root 运行,0o500 不生效,取锁会正常成功;此时跳过端到端前提 - 新增平台无关用例 project_write_lock_classifies_by_whether_the_target_exists:目标存在才是争用、目标不存在却创建失败是权限拒绝、NotFound 归其它 - 让权限分类判据在不依赖 ACL 环境的条件下也有回归护栏,避免只靠会被 root 绕过的端到端用例
1347 lines
53 KiB
Rust
1347 lines
53 KiB
Rust
use super::*;
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
|
||
static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 =
|
||
std::sync::atomic::AtomicU64::new(1);
|
||
const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600;
|
||
/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者
|
||
/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它
|
||
/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。
|
||
const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
|
||
/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。
|
||
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
||
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
||
|
||
#[derive(Debug)]
|
||
pub(crate) struct ProjectWriteLock {
|
||
path: PathBuf,
|
||
content: String,
|
||
/// In the free-form autonomous lane a single Runtime process may have
|
||
/// several specialist actions in flight at once. A file lock is still
|
||
/// useful across processes, but making same-process contenders fail turns
|
||
/// ordinary parallel work into a dead run (and can deadlock nested tool
|
||
/// calls). Such a contender receives an in-process/advisory guard instead
|
||
/// of deleting the real holder's lock on drop.
|
||
bypassed_same_process: bool,
|
||
}
|
||
|
||
impl ProjectWriteLock {
|
||
pub(crate) fn guards_project_root(&self, root: &Path) -> Result<bool, String> {
|
||
let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?;
|
||
if self.bypassed_same_process {
|
||
// The relaxed guard deliberately has no ownership of the durable
|
||
// `.agent/project.lock` file. It still binds the observation to
|
||
// the validated project root so callers cannot use a guard from a
|
||
// different project.
|
||
return Ok(self.path == expected_path);
|
||
}
|
||
Ok(self.path == expected_path
|
||
&& fs::read_to_string(&self.path).is_ok_and(|content| content == self.content))
|
||
}
|
||
}
|
||
|
||
impl Drop for ProjectWriteLock {
|
||
fn drop(&mut self) {
|
||
if self.bypassed_same_process {
|
||
return;
|
||
}
|
||
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
||
let _ = fs::remove_file(&self.path);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn project_write_lock_process_is_alive(process_id: u64) -> Option<bool> {
|
||
// Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机
|
||
// 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。
|
||
let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else {
|
||
return Some(false);
|
||
};
|
||
let result = unsafe { libc::kill(process_id, 0) };
|
||
if result == 0 {
|
||
return Some(true);
|
||
}
|
||
match std::io::Error::last_os_error().raw_os_error() {
|
||
Some(libc::ESRCH) => Some(false),
|
||
Some(libc::EPERM) => Some(true),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn project_write_lock_process_is_alive(process_id: u64) -> Option<bool> {
|
||
use std::ffi::c_void;
|
||
|
||
#[link(name = "kernel32")]
|
||
unsafe extern "system" {
|
||
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
|
||
fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32;
|
||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||
}
|
||
|
||
// Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何
|
||
// 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。
|
||
let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else {
|
||
return Some(false);
|
||
};
|
||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||
const STILL_ACTIVE: u32 = 259;
|
||
// SAFETY: OpenProcess returns an owned kernel handle or null; it is
|
||
// closed below. We only request the query permission needed here.
|
||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
|
||
if process.is_null() {
|
||
// ERROR_INVALID_PARAMETER means the process no longer exists. For
|
||
// access-denied/other failures we cannot prove liveness, so keep the
|
||
// conservative unknown result and let the normal bounded wait decide.
|
||
return match std::io::Error::last_os_error().raw_os_error() {
|
||
Some(87) => Some(false),
|
||
_ => None,
|
||
};
|
||
}
|
||
let mut exit_code = 0_u32;
|
||
// SAFETY: `exit_code` is a writable scalar and `process` is a live handle.
|
||
let result = unsafe { GetExitCodeProcess(process, &mut exit_code) };
|
||
// SAFETY: `process` is an owned handle returned by OpenProcess.
|
||
unsafe { CloseHandle(process) };
|
||
if result == 0 {
|
||
return None;
|
||
}
|
||
Some(exit_code == STILL_ACTIVE)
|
||
}
|
||
|
||
#[cfg(not(any(unix, windows)))]
|
||
fn project_write_lock_process_is_alive(_process_id: u64) -> Option<bool> {
|
||
None
|
||
}
|
||
|
||
/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有
|
||
/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时
|
||
/// 保持原有的保守回收策略。
|
||
#[cfg(windows)]
|
||
pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option<u64> {
|
||
use std::ffi::c_void;
|
||
|
||
#[repr(C)]
|
||
struct FileTime {
|
||
low_date_time: u32,
|
||
high_date_time: u32,
|
||
}
|
||
|
||
#[link(name = "kernel32")]
|
||
unsafe extern "system" {
|
||
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
|
||
fn GetProcessTimes(
|
||
process: *mut c_void,
|
||
creation_time: *mut FileTime,
|
||
exit_time: *mut FileTime,
|
||
kernel_time: *mut FileTime,
|
||
user_time: *mut FileTime,
|
||
) -> i32;
|
||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||
}
|
||
|
||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||
/// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。
|
||
const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000;
|
||
let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?;
|
||
// SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below.
|
||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
|
||
if process.is_null() {
|
||
return None;
|
||
}
|
||
// SAFETY: every FileTime is plain data filled by GetProcessTimes.
|
||
let mut creation = unsafe { std::mem::zeroed::<FileTime>() };
|
||
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
|
||
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
|
||
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
|
||
// SAFETY: `process` is a live handle and all four pointers are writable scalars.
|
||
let result =
|
||
unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) };
|
||
// SAFETY: `process` is an owned handle returned by OpenProcess.
|
||
unsafe { CloseHandle(process) };
|
||
if result == 0 {
|
||
return None;
|
||
}
|
||
let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time);
|
||
file_time
|
||
.checked_sub(FILETIME_UNIX_EPOCH_OFFSET)
|
||
.map(|unix_100ns| unix_100ns / 10_000_000)
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option<u64> {
|
||
let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?;
|
||
// SAFETY: sysconf has no memory safety preconditions and returns -1 on failure.
|
||
let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
|
||
if clock_ticks <= 0 {
|
||
return None;
|
||
}
|
||
let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?;
|
||
let start_ticks = stat
|
||
.rsplit_once(") ")?
|
||
.1
|
||
.split_whitespace()
|
||
.nth(19)?
|
||
.parse::<u64>()
|
||
.ok()?;
|
||
let boot_time = fs::read_to_string("/proc/stat")
|
||
.ok()?
|
||
.lines()
|
||
.find_map(|line| line.strip_prefix("btime "))?
|
||
.trim()
|
||
.parse::<u64>()
|
||
.ok()?;
|
||
Some(boot_time + start_ticks / clock_ticks as u64)
|
||
}
|
||
|
||
#[cfg(not(any(windows, target_os = "linux")))]
|
||
pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option<u64> {
|
||
None
|
||
}
|
||
|
||
/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照:
|
||
/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息
|
||
/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。
|
||
#[derive(Debug, Clone)]
|
||
pub(crate) struct ProjectWriteLockSnapshot {
|
||
content: Vec<u8>,
|
||
command_id: Option<String>,
|
||
pid: Option<u64>,
|
||
created_at: Option<u64>,
|
||
process_started_at: Option<u64>,
|
||
}
|
||
|
||
impl ProjectWriteLockSnapshot {
|
||
pub(crate) fn read(path: &Path) -> Option<Self> {
|
||
let content = fs::read(path).ok()?;
|
||
let payload = serde_json::from_slice::<serde_json::Value>(&content).ok();
|
||
let number = |key: &str| {
|
||
payload
|
||
.as_ref()
|
||
.and_then(|payload| payload.get(key))
|
||
.and_then(serde_json::Value::as_u64)
|
||
};
|
||
Some(Self {
|
||
command_id: payload
|
||
.as_ref()
|
||
.and_then(|payload| payload.get("commandId"))
|
||
.and_then(serde_json::Value::as_str)
|
||
.map(str::to_string),
|
||
pid: number("pid"),
|
||
created_at: number("createdAt"),
|
||
process_started_at: number("processStartedAt"),
|
||
content,
|
||
})
|
||
}
|
||
|
||
/// 持锁方身份的单行描述。Issue #318 的现场只有一句"别人在写",无法回答"到底是谁、
|
||
/// 是不是自己人",所以争用错误和等待日志都要带上这几个字段。
|
||
/// `ownerIsSelf` 用 `pid` 判定:`true` 是同进程另一条写通道,`false` 才是真外部进程。
|
||
pub(crate) fn describe_holder(&self) -> String {
|
||
format!(
|
||
"commandId={} pid={} createdAt={} ownerIsSelf={}",
|
||
self.command_id.as_deref().unwrap_or("unknown"),
|
||
self.pid
|
||
.map(|pid| pid.to_string())
|
||
.unwrap_or_else(|| "unknown".to_string()),
|
||
self.created_at
|
||
.map(|created_at| created_at.to_string())
|
||
.unwrap_or_else(|| "unknown".to_string()),
|
||
match self.pid {
|
||
Some(pid) if pid == u64::from(std::process::id()) => "true",
|
||
Some(_) => "false",
|
||
None => "unknown",
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool {
|
||
ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid)
|
||
== Some(u64::from(std::process::id()))
|
||
}
|
||
|
||
/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知”
|
||
/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成
|
||
/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。
|
||
fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option<u64> {
|
||
metadata
|
||
.modified()
|
||
.ok()
|
||
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||
.map(|duration| duration.as_secs())
|
||
}
|
||
|
||
/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能
|
||
/// 按“不回收”处理,不能退化成 0 或极大值。
|
||
fn project_write_lock_age_seconds(
|
||
snapshot: &ProjectWriteLockSnapshot,
|
||
modified_at: Option<u64>,
|
||
now: u64,
|
||
) -> Option<u64> {
|
||
if let Some(created_at) = snapshot.created_at {
|
||
return Some(now.saturating_sub(created_at));
|
||
}
|
||
modified_at.map(|modified_at| now.saturating_sub(modified_at))
|
||
}
|
||
|
||
/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程
|
||
/// 难以构造的分支(存活状态无法判定、mtime 不可读)。
|
||
pub(crate) fn project_write_lock_reclaim_decision(
|
||
snapshot: &ProjectWriteLockSnapshot,
|
||
modified_at: Option<u64>,
|
||
now: u64,
|
||
process_is_alive: impl Fn(u64) -> Option<bool>,
|
||
process_started_at: impl Fn(u64) -> Option<u64>,
|
||
) -> bool {
|
||
let Some(owner_pid) = snapshot.pid else {
|
||
// 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。
|
||
return project_write_lock_age_seconds(snapshot, modified_at, now)
|
||
.is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS);
|
||
};
|
||
match process_is_alive(owner_pid) {
|
||
Some(false) => true,
|
||
Some(true) => {
|
||
// PID 会被系统复用,必须确认当前同名进程就是当时的持有者。
|
||
match (snapshot.process_started_at, process_started_at(owner_pid)) {
|
||
// 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。
|
||
(Some(stored), Some(actual)) => stored != actual,
|
||
// 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用;
|
||
// 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。
|
||
(None, Some(actual)) => {
|
||
let Some(lock_created_at) = snapshot.created_at.or(modified_at) else {
|
||
return false;
|
||
};
|
||
actual
|
||
> lock_created_at
|
||
.saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS)
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
// 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。
|
||
None => project_write_lock_age_seconds(snapshot, modified_at, now)
|
||
.is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS),
|
||
}
|
||
}
|
||
|
||
/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。
|
||
fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option<ProjectWriteLockSnapshot> {
|
||
let metadata = fs::symlink_metadata(path).ok()?;
|
||
if metadata.file_type().is_symlink()
|
||
|| windows_metadata_is_reparse_point(&metadata)
|
||
|| !metadata.is_file()
|
||
|| metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES
|
||
{
|
||
return None;
|
||
}
|
||
let snapshot = ProjectWriteLockSnapshot::read(path)?;
|
||
project_write_lock_reclaim_decision(
|
||
&snapshot,
|
||
project_write_lock_file_modified_seconds(&metadata),
|
||
unix_timestamp(),
|
||
project_write_lock_process_is_alive,
|
||
project_write_lock_process_start_time_seconds,
|
||
)
|
||
.then_some(snapshot)
|
||
}
|
||
|
||
/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的
|
||
/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或
|
||
/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。
|
||
pub(crate) fn project_write_lock_reclaim(
|
||
path: &Path,
|
||
snapshot: &ProjectWriteLockSnapshot,
|
||
) -> Result<bool, String> {
|
||
match fs::read(path) {
|
||
Ok(content) if content == snapshot.content => {}
|
||
Ok(_) => return Ok(false),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||
Err(error) => {
|
||
return Err(format!("读取失效项目写锁失败:{}: {error}", path.display()));
|
||
}
|
||
}
|
||
match fs::remove_file(path) {
|
||
Ok(()) => Ok(true),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||
Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())),
|
||
}
|
||
}
|
||
|
||
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
|
||
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
|
||
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
|
||
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
|
||
|
||
/// `create_new` 失败到底意味着什么。三类的处置完全不同:争用可以等待,权限拒绝必须
|
||
/// 失败关闭,其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL
|
||
/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum ProjectWriteLockOpenFailure {
|
||
Contention,
|
||
Permission,
|
||
Other,
|
||
}
|
||
|
||
fn project_write_lock_classify_open_error(
|
||
error: &std::io::Error,
|
||
lock_path_exists: bool,
|
||
) -> ProjectWriteLockOpenFailure {
|
||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||
return ProjectWriteLockOpenFailure::Contention;
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
// Windows 会把已存在或处于 delete-pending 的 create_new 目标报成 ACCESS_DENIED
|
||
// 而不是 ALREADY_EXISTS。32 / 33 是 sharing violation 与 lock violation,只可能
|
||
// 在目标被占用时出现,恒定归争用。
|
||
if matches!(error.raw_os_error(), Some(32 | 33)) {
|
||
return ProjectWriteLockOpenFailure::Contention;
|
||
}
|
||
// ACCESS_DENIED(5) 有两种含义,只能靠"目标是否存在"区分:delete-pending 或存在
|
||
// 的目标是争用;目标并不存在却仍创建失败,是真正的权限 / ACL 拒绝。
|
||
if error.kind() == std::io::ErrorKind::PermissionDenied || error.raw_os_error() == Some(5) {
|
||
return if lock_path_exists {
|
||
ProjectWriteLockOpenFailure::Contention
|
||
} else {
|
||
ProjectWriteLockOpenFailure::Permission
|
||
};
|
||
}
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
if error.kind() == std::io::ErrorKind::PermissionDenied {
|
||
return ProjectWriteLockOpenFailure::Permission;
|
||
}
|
||
}
|
||
ProjectWriteLockOpenFailure::Other
|
||
}
|
||
|
||
/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份,
|
||
/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。
|
||
fn project_write_lock_contention_error(
|
||
path: &Path,
|
||
snapshot: Option<&ProjectWriteLockSnapshot>,
|
||
) -> String {
|
||
match snapshot {
|
||
Some(snapshot) => format!(
|
||
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})",
|
||
path.display(),
|
||
snapshot.describe_holder()
|
||
),
|
||
None => format!(
|
||
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)",
|
||
path.display()
|
||
),
|
||
}
|
||
}
|
||
|
||
fn project_write_lock_permission_error(path: &Path, error: &std::io::Error) -> String {
|
||
// 文案刻意不含争用前缀:`..._with_wait`、`provider_recovery.rs` 和前端都按前缀把
|
||
// 错误当成"等一下就好"的瞬时状态,权限拒绝必须失败关闭。
|
||
format!(
|
||
"项目写锁路径权限被拒绝,不是写锁争用(请检查项目目录与 .agent 目录的 ACL):{}: {error}",
|
||
path.display()
|
||
)
|
||
}
|
||
|
||
/// 等待预算耗尽时写进 App 日志的持锁方快照。
|
||
pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String {
|
||
let Ok(path) = resolve_project_write_lock_path(root) else {
|
||
return "持锁方身份不可解析".to_string();
|
||
};
|
||
match ProjectWriteLockSnapshot::read(&path) {
|
||
Some(snapshot) => snapshot.describe_holder(),
|
||
None => "持锁方身份不可读(锁文件可能处于删除挂起或尚未写完)".to_string(),
|
||
}
|
||
}
|
||
|
||
#[cfg(all(test, windows))]
|
||
#[test]
|
||
fn project_write_lock_treats_windows_target_races_as_contention() {
|
||
for code in [5, 32, 33] {
|
||
assert_eq!(
|
||
project_write_lock_classify_open_error(
|
||
&std::io::Error::from_raw_os_error(code),
|
||
true
|
||
),
|
||
ProjectWriteLockOpenFailure::Contention,
|
||
"Windows project lock error {code} with an existing target must enter the bounded contention wait"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn project_write_lock_reports_acl_denial_without_an_existing_target() {
|
||
// delete-pending 与真实 ACL 拒绝在 Windows 上同为 ACCESS_DENIED(5);目标不存在时
|
||
// 必须落到权限类,否则 ACL 问题会被投影成"别人在写"。
|
||
assert_eq!(
|
||
project_write_lock_classify_open_error(&std::io::Error::from_raw_os_error(5), false),
|
||
ProjectWriteLockOpenFailure::Permission
|
||
);
|
||
}
|
||
|
||
/// 分类判据本身与平台无关,两个平台都要盯住:目标存在才是争用,目标不存在却创建失败
|
||
/// 是权限拒绝。这条用例不依赖 ACL 环境,因此在 CI 容器以 root 运行时仍然有效。
|
||
#[test]
|
||
fn project_write_lock_classifies_by_whether_the_target_exists() {
|
||
assert_eq!(
|
||
project_write_lock_classify_open_error(
|
||
&std::io::Error::from(std::io::ErrorKind::AlreadyExists),
|
||
true
|
||
),
|
||
ProjectWriteLockOpenFailure::Contention
|
||
);
|
||
assert_eq!(
|
||
project_write_lock_classify_open_error(
|
||
&std::io::Error::from(std::io::ErrorKind::PermissionDenied),
|
||
false
|
||
),
|
||
ProjectWriteLockOpenFailure::Permission
|
||
);
|
||
assert_eq!(
|
||
project_write_lock_classify_open_error(
|
||
&std::io::Error::from(std::io::ErrorKind::NotFound),
|
||
false
|
||
),
|
||
ProjectWriteLockOpenFailure::Other
|
||
);
|
||
}
|
||
|
||
#[cfg(all(test, windows))]
|
||
#[test]
|
||
fn project_write_lock_hardens_space_containing_path_in_process() {
|
||
let parent = tempfile::tempdir().expect("create spaced lock parent");
|
||
let root = parent
|
||
.path()
|
||
.join("Genarrative GameAgent")
|
||
.join("gameagent-space");
|
||
fs::create_dir_all(&root).expect("create spaced project root");
|
||
let lock = acquire_project_write_lock(&root, "planning.v2.approval")
|
||
.expect("acquire project lock under a space-containing path");
|
||
let lock_path = root.join(".agent").join("project.lock");
|
||
assert!(lock_path.is_file(), "project lock must exist while held");
|
||
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)
|
||
.expect("new project lock must already satisfy the private DACL contract");
|
||
drop(lock);
|
||
assert!(
|
||
!lock_path.exists(),
|
||
"project lock must be removed when the guard is dropped"
|
||
);
|
||
}
|
||
|
||
fn resolve_project_write_lock_path(root: &Path) -> Result<PathBuf, String> {
|
||
let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?;
|
||
let (parent_relative, file_name) = normalized
|
||
.rsplit_once('/')
|
||
.ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?;
|
||
let parent = resolve_local_project_path(root, parent_relative)?;
|
||
// create_new is the authority for the final lock component. On Windows a
|
||
// delete-pending lock can make a metadata preflight fail with ACCESS_DENIED
|
||
// before the existing bounded contention wait has a chance to run.
|
||
Ok(parent.join(file_name))
|
||
}
|
||
|
||
pub(crate) fn acquire_project_write_lock(
|
||
root: &Path,
|
||
command_id: &str,
|
||
) -> Result<ProjectWriteLock, String> {
|
||
validate_project_root(root)?;
|
||
let mut path = resolve_project_write_lock_path(root)?;
|
||
if let Some(parent) = path.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "项目锁目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?;
|
||
}
|
||
// Re-check the parent after creation so skipping metadata only for the final
|
||
// create_new target cannot weaken the normal ancestor link/reparse checks.
|
||
path = resolve_project_write_lock_path(root)?;
|
||
let payload = serde_json::json!({
|
||
"commandId": command_id,
|
||
"pid": std::process::id(),
|
||
// 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。
|
||
"processStartedAt": project_write_lock_process_start_time_seconds(u64::from(
|
||
std::process::id()
|
||
)),
|
||
"createdAt": unix_timestamp(),
|
||
"nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||
});
|
||
let content = serde_json::to_string_pretty(&payload)
|
||
.map_err(|error| format!("生成项目写锁失败:{error}"))?;
|
||
let mut retried_after_reclaim = false;
|
||
loop {
|
||
let mut options = fs::OpenOptions::new();
|
||
options.create_new(true).write(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
match options.open(&path) {
|
||
Ok(mut file) => {
|
||
if let Err(error) = file.write_all(content.as_bytes()) {
|
||
drop(file);
|
||
let _ = fs::remove_file(&path);
|
||
return Err(format!("写入项目写锁失败:{}: {error}", path.display()));
|
||
}
|
||
if let Err(error) = file.sync_all() {
|
||
drop(file);
|
||
let _ = fs::remove_file(&path);
|
||
return Err(format!("落盘项目写锁失败:{}: {error}", path.display()));
|
||
}
|
||
drop(file);
|
||
if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁")
|
||
{
|
||
let _ = fs::remove_file(&path);
|
||
return Err(error);
|
||
}
|
||
let actual = match fs::read_to_string(&path) {
|
||
Ok(actual) => actual,
|
||
Err(error) => {
|
||
let _ = fs::remove_file(&path);
|
||
return Err(format!("读取项目写锁失败:{}: {error}", path.display()));
|
||
}
|
||
};
|
||
if actual != content {
|
||
let _ = fs::remove_file(&path);
|
||
return Err(format!("项目写锁内容校验失败:{}", path.display()));
|
||
}
|
||
return Ok(ProjectWriteLock {
|
||
path,
|
||
content: content.clone(),
|
||
bypassed_same_process: false,
|
||
});
|
||
}
|
||
Err(error) => {
|
||
let failure = project_write_lock_classify_open_error(&error, path.exists());
|
||
if failure == ProjectWriteLockOpenFailure::Permission {
|
||
// 权限类错误不会重试,所以在这里记录:它必须能在 App 日志里
|
||
// 和"别人正在写"区分开。
|
||
app_log!(
|
||
"project.write_lock.permission_denied commandId={command_id} path={} osError={:?}",
|
||
path.display(),
|
||
error.raw_os_error()
|
||
);
|
||
return Err(project_write_lock_permission_error(&path, &error));
|
||
}
|
||
if failure == ProjectWriteLockOpenFailure::Other {
|
||
return Err(format!("创建项目写锁失败:{}: {error}", path.display()));
|
||
}
|
||
if !retried_after_reclaim {
|
||
if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) {
|
||
if project_write_lock_reclaim(&path, &snapshot)? {
|
||
app_log!(
|
||
"project.write_lock.reclaim_stale commandId={command_id} path={} holder={}",
|
||
path.display(),
|
||
snapshot.describe_holder()
|
||
);
|
||
retried_after_reclaim = true;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
if crate::agent::autonomous_game_build_root_run_active_at(root)
|
||
&& project_write_lock_is_owned_by_current_process(&path)
|
||
{
|
||
// The autonomous game-build lane intentionally permits
|
||
// parallel specialist actions. If the durable lock belongs
|
||
// to this very process, contention is an in-process overlap,
|
||
// not another application editing the project. Return an
|
||
// advisory guard and leave the real lock untouched.
|
||
return Ok(ProjectWriteLock {
|
||
path,
|
||
content: String::new(),
|
||
bypassed_same_process: true,
|
||
});
|
||
}
|
||
// 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次,
|
||
// 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。
|
||
return Err(project_write_lock_contention_error(
|
||
&path,
|
||
ProjectWriteLockSnapshot::read(&path).as_ref(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn list_local_project_files_at(
|
||
root: &Path,
|
||
) -> Result<ListLocalProjectFilesResult, String> {
|
||
validate_project_root(root)?;
|
||
if !root.exists() {
|
||
return Ok(ListLocalProjectFilesResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
files: Vec::new(),
|
||
});
|
||
}
|
||
|
||
let mut files = Vec::new();
|
||
let mut dirs = vec![root.to_path_buf()];
|
||
while let Some(dir) = dirs.pop() {
|
||
for entry in fs::read_dir(&dir)
|
||
.map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))?
|
||
{
|
||
let entry =
|
||
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
|
||
let path = entry.path();
|
||
let metadata = fs::symlink_metadata(&path)
|
||
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
|
||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||
continue;
|
||
}
|
||
let file_type = metadata.file_type();
|
||
let relative_path = relative_project_path(root, &path)?;
|
||
if is_agent_runtime_private_control_path(&relative_path)
|
||
|| is_agent_checkpoint_control_path(&relative_path)
|
||
|| is_agent_workbench_control_path(&relative_path)
|
||
|| is_agent_planning_storage_path(&relative_path)
|
||
{
|
||
continue;
|
||
}
|
||
let modified_at = metadata
|
||
.modified()
|
||
.ok()
|
||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||
.unwrap_or(0);
|
||
if file_type.is_dir() {
|
||
files.push(LocalProjectFileEntry {
|
||
path: relative_path,
|
||
kind: "directory".to_string(),
|
||
size: 0,
|
||
modified_at,
|
||
});
|
||
dirs.push(path);
|
||
} else if file_type.is_file() {
|
||
let size = metadata.len();
|
||
files.push(LocalProjectFileEntry {
|
||
path: relative_path,
|
||
kind: "file".to_string(),
|
||
size,
|
||
modified_at,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||
|
||
Ok(ListLocalProjectFilesResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
files,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn read_local_project_file_at(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
) -> Result<LocalProjectFileResult, String> {
|
||
let normalized_path = normalize_relative_path(relative_path)?;
|
||
reject_agent_runtime_private_control_path(&normalized_path)?;
|
||
reject_sensitive_project_file_read(&normalized_path)?;
|
||
let path = resolve_local_project_path(root, &normalized_path)?;
|
||
prepare_game_creator_private_path_for_read(&path, false, "项目文件")?;
|
||
let metadata = fs::metadata(&path)
|
||
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
|
||
if !metadata.is_file() {
|
||
return Err("只能读取文件".to_string());
|
||
}
|
||
let content = fs::read_to_string(&path)
|
||
.map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?;
|
||
|
||
Ok(LocalProjectFileResult {
|
||
path: normalized_path,
|
||
absolute_path: path.to_string_lossy().into_owned(),
|
||
content,
|
||
})
|
||
}
|
||
|
||
fn is_agent_runtime_private_control_path(normalized_path: &str) -> bool {
|
||
let mut parts = normalized_path.split('/');
|
||
if !matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent")) {
|
||
return false;
|
||
}
|
||
matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("runtime"))
|
||
|| normalized_path.eq_ignore_ascii_case(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH)
|
||
}
|
||
|
||
fn is_agent_checkpoint_control_path(normalized_path: &str) -> bool {
|
||
let mut parts = normalized_path.split('/');
|
||
matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent"))
|
||
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("checkpoints"))
|
||
}
|
||
|
||
fn is_agent_workbench_control_path(normalized_path: &str) -> bool {
|
||
let mut parts = normalized_path.split('/');
|
||
matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent"))
|
||
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("workbench"))
|
||
}
|
||
|
||
pub(crate) fn reject_agent_runtime_private_control_path(
|
||
normalized_path: &str,
|
||
) -> Result<(), String> {
|
||
if is_agent_runtime_private_control_path(normalized_path) {
|
||
return Err("Agent Runtime 私有控制面不可通过通用文件工具访问".to_string());
|
||
}
|
||
if is_agent_checkpoint_control_path(normalized_path) {
|
||
return Err("Agent checkpoint 控制面不可通过通用文件工具访问".to_string());
|
||
}
|
||
if is_agent_workbench_control_path(normalized_path) {
|
||
return Err("Agent workbench 控制面不可通过通用文件工具访问".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `.agent/planning/**` is a Runtime-owned sidecar. It remains readable by
|
||
/// the narrow planning read tools, but generic project mutation helpers must
|
||
/// never be able to create, replace, patch, or delete it. Keeping this gate
|
||
/// separate from `reject_agent_runtime_private_control_path` is deliberate:
|
||
/// the planning Agent needs `file.read`/`file.list` observations while its
|
||
/// durable writer is still the only component allowed to mutate the sidecar.
|
||
pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool {
|
||
let normalized_path = normalized_path.to_ascii_lowercase();
|
||
normalized_path == ".agent/planning"
|
||
|| normalized_path.starts_with(".agent/planning/")
|
||
|| normalized_path == ".agent/planning-v2"
|
||
|| normalized_path.starts_with(".agent/planning-v2/")
|
||
}
|
||
|
||
pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool {
|
||
is_agent_planning_storage_path(normalized_path)
|
||
|| is_plan_fast_gdd_projection_path(normalized_path)
|
||
}
|
||
|
||
pub(crate) fn reject_agent_planning_storage_write_path(
|
||
normalized_path: &str,
|
||
) -> Result<(), String> {
|
||
if is_agent_planning_managed_write_path(normalized_path) {
|
||
return Err(
|
||
"`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝"
|
||
.to_string(),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `game/fast_gdd.md` is the human-readable planning projection. It lives
|
||
/// outside `.agent/planning`, but it is still Runtime-owned and must not be
|
||
/// mutated by generic file tools. Keep this predicate write-only so planning
|
||
/// observations can continue to read the projection.
|
||
pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool {
|
||
normalized_path.eq_ignore_ascii_case(PLAN_FAST_GDD_PATH)
|
||
}
|
||
|
||
pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> {
|
||
reject_agent_planning_storage_write_path(normalized_path)?;
|
||
if is_plan_fast_gdd_projection_path(normalized_path) {
|
||
return Err(
|
||
"`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入,通用文件写入被拒绝"
|
||
.to_string(),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> {
|
||
if matches!(
|
||
normalized_path.split('/').next(),
|
||
Some(part) if part.eq_ignore_ascii_case(".agent")
|
||
) {
|
||
return Err("Agent 控制面不可通过 file.delete 删除".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn reject_sensitive_project_file_read(normalized_path: &str) -> Result<(), String> {
|
||
for part in normalized_path.split('/') {
|
||
let lower = part.to_ascii_lowercase();
|
||
if lower == ".env"
|
||
|| lower.starts_with(".env.")
|
||
|| lower == GAME_CREATOR_CONFIG_FILE_NAME
|
||
|| lower == GAME_CREATOR_LOCAL_CONFIG_FILE_NAME
|
||
{
|
||
return Err("拒绝读取敏感配置文件".to_string());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn is_agent_trace_read_path(normalized_path: &str) -> bool {
|
||
normalized_path == ".agent/run.latest.json"
|
||
|| (normalized_path.starts_with(".agent/runs/") && normalized_path.ends_with(".json"))
|
||
}
|
||
|
||
pub(crate) fn write_local_project_file_at(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
content: &str,
|
||
) -> Result<LocalProjectFileMutationResult, String> {
|
||
let normalized_path = normalize_relative_path(relative_path)?;
|
||
reject_agent_runtime_private_control_path(&normalized_path)?;
|
||
reject_plan_projection_write_path(&normalized_path)?;
|
||
let path = resolve_local_project_path(root, &normalized_path)?;
|
||
if path.exists() && !path.is_file() {
|
||
return Err("只能写入文件".to_string());
|
||
}
|
||
crate::write_game_creator_private_file(&path, content.as_bytes(), "项目文件")?;
|
||
|
||
Ok(LocalProjectFileMutationResult {
|
||
path: normalized_path,
|
||
absolute_path: path.to_string_lossy().into_owned(),
|
||
deleted: false,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn delete_local_project_file_at(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
) -> Result<LocalProjectFileMutationResult, String> {
|
||
let normalized_path = normalize_relative_path(relative_path)?;
|
||
reject_agent_runtime_private_control_path(&normalized_path)?;
|
||
reject_plan_projection_write_path(&normalized_path)?;
|
||
reject_agent_control_path_delete(&normalized_path)?;
|
||
let path = resolve_local_project_path(root, &normalized_path)?;
|
||
if !path.exists() {
|
||
return Ok(LocalProjectFileMutationResult {
|
||
path: normalized_path,
|
||
absolute_path: path.to_string_lossy().into_owned(),
|
||
deleted: false,
|
||
});
|
||
}
|
||
if !path.is_file() {
|
||
return Err("只能删除文件".to_string());
|
||
}
|
||
prepare_game_creator_private_path_for_read(&path, false, "项目文件")?;
|
||
fs::remove_file(&path)
|
||
.map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?;
|
||
|
||
Ok(LocalProjectFileMutationResult {
|
||
path: normalized_path,
|
||
absolute_path: path.to_string_lossy().into_owned(),
|
||
deleted: true,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn build_local_project_index_at(root: &Path) -> Result<LocalProjectIndexResult, String> {
|
||
validate_project_root(root)?;
|
||
let files = collect_project_index_files(root)?;
|
||
let total_bytes = files.iter().map(|file| file.size).sum();
|
||
let result = LocalProjectIndexResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
index_path: root.join(PROJECT_INDEX_PATH).to_string_lossy().into_owned(),
|
||
file_count: files.len(),
|
||
total_bytes,
|
||
files,
|
||
};
|
||
let index_path = root.join(PROJECT_INDEX_PATH);
|
||
crate::write_game_creator_private_file(
|
||
&index_path,
|
||
format!(
|
||
"{}\n",
|
||
serde_json::to_string_pretty(&result)
|
||
.map_err(|error| format!("序列化项目索引失败:{error}"))?
|
||
)
|
||
.as_bytes(),
|
||
"项目索引",
|
||
)?;
|
||
append_agent_db_record(
|
||
root,
|
||
serde_json::json!({
|
||
"recordType": "project.index",
|
||
"fileCount": result.file_count,
|
||
"totalBytes": result.total_bytes,
|
||
"indexPath": PROJECT_INDEX_PATH,
|
||
}),
|
||
)?;
|
||
Ok(result)
|
||
}
|
||
|
||
pub(crate) fn collect_project_index_files(
|
||
root: &Path,
|
||
) -> Result<Vec<LocalProjectIndexedFile>, String> {
|
||
validate_project_root(root)?;
|
||
if !root.exists() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let mut files = Vec::new();
|
||
let mut dirs = vec![root.to_path_buf()];
|
||
while let Some(dir) = dirs.pop() {
|
||
for entry in fs::read_dir(&dir)
|
||
.map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))?
|
||
{
|
||
let entry =
|
||
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
|
||
let path = entry.path();
|
||
let metadata = fs::symlink_metadata(&path)
|
||
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
|
||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||
continue;
|
||
}
|
||
let file_type = metadata.file_type();
|
||
let relative_path = relative_project_path(root, &path)?;
|
||
if should_skip_project_index_path(&relative_path) {
|
||
continue;
|
||
}
|
||
if file_type.is_dir() {
|
||
dirs.push(path);
|
||
} else if file_type.is_file() {
|
||
let (mut file, metadata) =
|
||
open_project_private_regular_file(&path, "项目索引文件")?;
|
||
let mut bytes =
|
||
Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default());
|
||
file.read_to_end(&mut bytes)
|
||
.map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?;
|
||
let final_metadata = file
|
||
.metadata()
|
||
.map_err(|error| format!("复核项目文件失败:{}: {error}", path.display()))?;
|
||
if final_metadata.len() != bytes.len() as u64 {
|
||
return Err(format!("读取项目索引期间文件发生漂移:{}", path.display()));
|
||
}
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::MetadataExt;
|
||
if final_metadata.nlink() != 1 {
|
||
return Err(format!("项目索引文件不能是硬链接文件:{}", path.display()));
|
||
}
|
||
}
|
||
#[cfg(windows)]
|
||
validate_windows_regular_file_handle(&file, "项目索引文件")?;
|
||
files.push(LocalProjectIndexedFile {
|
||
path: relative_path,
|
||
size: bytes.len() as u64,
|
||
checksum: format!("fnv1a64:{:016x}", fnv1a64(&bytes)),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||
Ok(files)
|
||
}
|
||
|
||
pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool {
|
||
relative_path == PROJECT_WRITE_LOCK_PATH
|
||
|| relative_path == PROJECT_INDEX_PATH
|
||
|| relative_path.starts_with(".agent/checkpoints/")
|
||
|| relative_path.starts_with(".agent/runtime/")
|
||
|| should_skip_project_snapshot_path(relative_path)
|
||
}
|
||
|
||
pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
|
||
let components = relative_path
|
||
.split('/')
|
||
.filter(|component| !component.is_empty())
|
||
.map(str::to_ascii_lowercase)
|
||
.collect::<Vec<_>>();
|
||
if components.iter().any(|component| {
|
||
matches!(
|
||
component.as_str(),
|
||
".agent"
|
||
| ".git"
|
||
| ".hg"
|
||
| ".svn"
|
||
| ".ssh"
|
||
| ".aws"
|
||
| ".azure"
|
||
| ".gnupg"
|
||
| ".kube"
|
||
| ".docker"
|
||
| ".gcloud"
|
||
| ".terraform"
|
||
| ".password-store"
|
||
| ".secrets"
|
||
| "secrets"
|
||
| "credentials"
|
||
| "node_modules"
|
||
| "target"
|
||
| "dist"
|
||
| "build"
|
||
| ".next"
|
||
| "coverage"
|
||
| ".cache"
|
||
)
|
||
}) {
|
||
return true;
|
||
}
|
||
let Some(file_name) = components.last() else {
|
||
return true;
|
||
};
|
||
let sensitive_suffixes = [
|
||
".pem",
|
||
".key",
|
||
".p12",
|
||
".pfx",
|
||
".ppk",
|
||
".jks",
|
||
".keystore",
|
||
".kdbx",
|
||
".db",
|
||
".db-wal",
|
||
".db-shm",
|
||
".sqlite",
|
||
".sqlite-wal",
|
||
".sqlite-shm",
|
||
".sqlite3",
|
||
".sqlite3-wal",
|
||
".sqlite3-shm",
|
||
".sql",
|
||
".sql.gz",
|
||
".sql.bz2",
|
||
".sql.xz",
|
||
".dump",
|
||
".dump.gz",
|
||
".dmp",
|
||
".bak",
|
||
".mdb",
|
||
".accdb",
|
||
".rdb",
|
||
".bson",
|
||
".pgdump",
|
||
".tfstate",
|
||
".tfstate.backup",
|
||
];
|
||
let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"];
|
||
file_name == ".env"
|
||
|| file_name.starts_with(".env.")
|
||
|| file_name == ".envrc"
|
||
|| matches!(
|
||
file_name.as_str(),
|
||
".npmrc"
|
||
| ".pypirc"
|
||
| ".netrc"
|
||
| ".git-credentials"
|
||
| ".htpasswd"
|
||
| ".vault-token"
|
||
| ".bash_history"
|
||
| ".zsh_history"
|
||
| ".psql_history"
|
||
| ".mysql_history"
|
||
| "authorized_keys"
|
||
| "kubeconfig"
|
||
| "credentials"
|
||
| "credentials.json"
|
||
| "credentials.toml"
|
||
| "credentials.yaml"
|
||
| "credentials.yml"
|
||
| "auth.json"
|
||
| "auth.toml"
|
||
| "auth.yaml"
|
||
| "auth.yml"
|
||
| "secrets.json"
|
||
| "secrets.toml"
|
||
| "secrets.yaml"
|
||
| "secrets.yml"
|
||
| "client_secret.json"
|
||
| "client_secrets.json"
|
||
| "service-account.json"
|
||
| "service_account.json"
|
||
| "application_default_credentials.json"
|
||
| "cookies.txt"
|
||
| "cookies.json"
|
||
| "token"
|
||
| "token.txt"
|
||
| "token.json"
|
||
| "tokens.json"
|
||
| GAME_CREATOR_CONFIG_FILE_NAME
|
||
| GAME_CREATOR_LOCAL_CONFIG_FILE_NAME
|
||
)
|
||
|| file_name.starts_with("id_rsa")
|
||
|| file_name.starts_with("id_dsa")
|
||
|| file_name.starts_with("id_ecdsa")
|
||
|| file_name.starts_with("id_ed25519")
|
||
|| file_name.starts_with("id_xmss")
|
||
|| sensitive_suffixes
|
||
.iter()
|
||
.any(|suffix| file_name.ends_with(suffix))
|
||
|| ((file_name.contains("cookie") || file_name.contains("credential"))
|
||
&& structured_secret_suffixes
|
||
.iter()
|
||
.any(|suffix| file_name.ends_with(suffix)))
|
||
}
|
||
|
||
pub(crate) fn resolve_local_project_path(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
) -> Result<PathBuf, String> {
|
||
validate_project_root(root)?;
|
||
let normalized = normalize_relative_path(relative_path)?;
|
||
let mut path = root.to_path_buf();
|
||
let mut should_check_metadata = true;
|
||
let mut acl_repair_attempted = false;
|
||
for part in normalized.split('/') {
|
||
path.push(part);
|
||
if !should_check_metadata {
|
||
continue;
|
||
}
|
||
match fs::symlink_metadata(&path) {
|
||
Ok(metadata)
|
||
if metadata.file_type().is_symlink()
|
||
|| windows_metadata_is_reparse_point(&metadata) =>
|
||
{
|
||
return Err("项目文件路径不能包含符号链接或 Windows reparse point".to_string());
|
||
}
|
||
Ok(_) => {}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
should_check_metadata = false;
|
||
}
|
||
Err(error)
|
||
if !acl_repair_attempted
|
||
&& error.kind() == std::io::ErrorKind::PermissionDenied =>
|
||
{
|
||
acl_repair_attempted = true;
|
||
#[cfg(windows)]
|
||
if crate::prepare_game_creator_private_path_for_read(&path, true, "项目路径")
|
||
.is_ok()
|
||
{
|
||
continue;
|
||
}
|
||
return Err(format!("读取路径失败:{}: {error}", path.display()));
|
||
}
|
||
Err(error) => {
|
||
return Err(format!("读取路径失败:{}: {error}", path.display()));
|
||
}
|
||
}
|
||
}
|
||
Ok(path)
|
||
}
|
||
|
||
pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> {
|
||
if root.as_os_str().is_empty() {
|
||
return Err("项目目录不能为空".to_string());
|
||
}
|
||
if !root.is_absolute() {
|
||
return Err("项目目录必须是绝对路径".to_string());
|
||
}
|
||
if project_path_has_control_chars(root) {
|
||
return Err("项目目录不能包含控制字符".to_string());
|
||
}
|
||
// Every project operation enters through this validator. On Windows the
|
||
// root may be a historical directory whose owner is still the elevated
|
||
// installer account or whose DACL is inherited. Reuse the formal prepare
|
||
// entry here so all downstream reads/writes get the same one-shot UAC
|
||
// repair and post-repair verification, instead of failing later at the
|
||
// first individual sidecar read.
|
||
#[cfg(windows)]
|
||
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
|
||
match fs::symlink_metadata(root) {
|
||
Ok(metadata) => {
|
||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||
return Err("项目目录不能是符号链接或 Windows reparse point".to_string());
|
||
}
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!("读取项目目录失败:{}: {error}", root.display()));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool {
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
let _ = metadata;
|
||
false
|
||
}
|
||
}
|
||
|
||
pub(crate) fn project_path_has_control_chars(root: &Path) -> bool {
|
||
root.to_string_lossy().chars().any(char::is_control)
|
||
}
|
||
|
||
pub(crate) fn normalize_relative_path(relative_path: &str) -> Result<String, String> {
|
||
if relative_path.is_empty() {
|
||
return Err("项目文件路径不能为空".to_string());
|
||
}
|
||
if Path::new(relative_path).is_absolute() {
|
||
return Err("项目文件路径不能是绝对路径".to_string());
|
||
}
|
||
if relative_path.contains('\\') {
|
||
return Err("项目文件路径不能包含反斜杠".to_string());
|
||
}
|
||
let mut parts = Vec::new();
|
||
for part in relative_path.split('/') {
|
||
if part.is_empty() || part == "." || part == ".." {
|
||
return Err("项目文件路径非法".to_string());
|
||
}
|
||
validate_portable_project_path_component(part)?;
|
||
parts.push(part);
|
||
}
|
||
Ok(parts.join("/"))
|
||
}
|
||
|
||
pub(super) fn validate_portable_project_path_component(component: &str) -> Result<(), String> {
|
||
if component.chars().any(char::is_control) {
|
||
return Err("项目文件路径不能包含控制字符".to_string());
|
||
}
|
||
if component.ends_with('.') || component.ends_with(' ') {
|
||
return Err("项目文件路径组件不能以点或空格结尾".to_string());
|
||
}
|
||
if component
|
||
.chars()
|
||
.any(|character| matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*'))
|
||
{
|
||
return Err("项目文件路径包含 Windows 不支持的字符".to_string());
|
||
}
|
||
if is_windows_reserved_path_component(component) {
|
||
return Err("项目文件路径不能使用 Windows 保留设备名".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn is_windows_reserved_path_component(component: &str) -> bool {
|
||
let base_name = component
|
||
.split('.')
|
||
.next()
|
||
.unwrap_or(component)
|
||
.trim_end_matches(' ')
|
||
.to_ascii_uppercase();
|
||
if matches!(
|
||
base_name.as_str(),
|
||
"CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
|
||
) {
|
||
return true;
|
||
}
|
||
["COM", "LPT"].iter().any(|prefix| {
|
||
base_name.strip_prefix(prefix).is_some_and(|suffix| {
|
||
matches!(
|
||
suffix,
|
||
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
|
||
)
|
||
})
|
||
})
|
||
}
|
||
|
||
pub(crate) fn relative_project_path(root: &Path, path: &Path) -> Result<String, String> {
|
||
let relative = path
|
||
.strip_prefix(root)
|
||
.map_err(|_| "项目文件路径不在项目目录内".to_string())?;
|
||
let parts = relative
|
||
.components()
|
||
.map(|component| component.as_os_str().to_string_lossy().into_owned())
|
||
.collect::<Vec<_>>();
|
||
normalize_relative_path(&parts.join("/"))
|
||
}
|
||
|
||
pub(crate) fn unix_timestamp() -> u64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_secs())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
pub(crate) fn unix_millis() -> u128 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_millis())
|
||
.unwrap_or(0)
|
||
}
|