Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs
T
menghao 216407d93e
Project CI / Frontend tests (push) Failing after 20s
Project CI / Repository checks (push) Successful in 1m2s
Project CI / Backend tests (push) Successful in 2m59s
Project CI / Native shell tests (push) Successful in 11m58s
实现资源画布布局持久化 (#116)
冻结资源画布布局数据与 CAS 合同
实现双模式本地 sidecar 安全读写
接入二维拖动、默认排版和跨重启恢复
补齐并发冲突、安全边界和界面测试
同步技术文档与共享决策

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/116
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-07-31 12:00:48 +08:00

730 lines
25 KiB
Rust

use super::*;
#[cfg(windows)]
pub(super) 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;
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
#[derive(Debug)]
pub(crate) struct ProjectWriteLock {
path: PathBuf,
content: String,
}
impl Drop for ProjectWriteLock {
fn drop(&mut self) {
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> {
let process_id = i32::try_from(process_id).ok().filter(|value| *value > 0)?;
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,
}
}
fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 {
let created_at = fs::read_to_string(path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.and_then(|payload| payload.get("createdAt").and_then(serde_json::Value::as_u64));
if let Some(created_at) = created_at {
return unix_timestamp().saturating_sub(created_at);
}
metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok())
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
fn project_write_lock_can_be_reclaimed(path: &Path) -> bool {
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES
{
return false;
}
let content = fs::read_to_string(path).ok();
let owner_pid = content
.as_deref()
.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok())
.and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64));
#[cfg(unix)]
if let Some(owner_alive) = owner_pid.and_then(project_write_lock_process_is_alive) {
return !owner_alive;
}
project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS
}
pub(crate) fn acquire_project_write_lock(
root: &Path,
command_id: &str,
) -> Result<ProjectWriteLock, String> {
validate_project_root(root)?;
let path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?;
}
let payload = serde_json::json!({
"commandId": command_id,
"pid": 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 {
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
{
Ok(mut file) => {
if let Err(error) = file.write_all(content.as_bytes()) {
let _ = fs::remove_file(&path);
return Err(format!("写入项目写锁失败:{}: {error}", path.display()));
}
return Ok(ProjectWriteLock {
path,
content: content.clone(),
});
}
Err(error)
if error.kind() == std::io::ErrorKind::AlreadyExists
&& !retried_after_reclaim
&& project_write_lock_can_be_reclaimed(&path) =>
{
fs::remove_file(&path).map_err(|error| {
format!("清理失效项目写锁失败:{}: {error}", path.display())
})?;
retried_after_reclaim = true;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(format!("项目正在被其他写操作占用:{}", path.display()));
}
Err(error) => {
return Err(format!("创建项目写锁失败:{}: {error}", path.display()));
}
}
}
}
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 file_type = entry.file_type().map_err(|error| {
format!("读取文件类型失败:{}: {error}", entry.path().display())
})?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
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)
{
continue;
}
let metadata = entry.metadata().map_err(|error| {
format!("读取文件元数据失败:{}: {error}", entry.path().display())
})?;
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)?;
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(())
}
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)?;
let path = resolve_local_project_path(root, &normalized_path)?;
if path.exists() && !path.is_file() {
return Err("只能写入文件".to_string());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?;
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_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());
}
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,
};
if let Some(parent) = root.join(PROJECT_INDEX_PATH).parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目索引目录失败:{}: {error}", parent.display()))?;
}
fs::write(
root.join(PROJECT_INDEX_PATH),
format!(
"{}\n",
serde_json::to_string_pretty(&result)
.map_err(|error| format!("序列化项目索引失败:{error}"))?
),
)
.map_err(|error| {
format!(
"写入项目索引失败:{}: {error}",
root.join(PROJECT_INDEX_PATH).display()
)
})?;
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 file_type = entry.file_type().map_err(|error| {
format!("读取文件类型失败:{}: {error}", entry.path().display())
})?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
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_snapshot_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;
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() => {
return Err("项目文件路径不能包含符号链接".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
should_check_metadata = false;
}
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());
}
match fs::symlink_metadata(root) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err("项目目录不能是符号链接".to_string());
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("读取项目目录失败:{}: {error}", root.display()));
}
}
Ok(())
}
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)
}