216407d93e
冻结资源画布布局数据与 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>
730 lines
25 KiB
Rust
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)
|
|
}
|