Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs
T
suzmii 4ecab19429
Project CI / Repository checks (pull_request) Successful in 3m5s
Project CI / Frontend tests (pull_request) Successful in 4m2s
Project CI / Backend tests (pull_request) Successful in 6m51s
Project CI / Native shell tests (pull_request) Successful in 19m16s
修正 CI 权限用例在 root 容器下的前提并补平台无关的分类判据
- 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 绕过的端到端用例
2026-09-10 16:52:30 +08:00

1347 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use 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)
}