修复 Game Agent 项目外读取的父目录链接绕过
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

文件读取和目录列表逐段拒绝符号链接及 Windows 重解析点
新增受保护目录别名与文件链接回归测试,保留普通外部读取
同步技术方案与排障记录,明确真实路径要求
This commit is contained in:
2026-09-29 13:34:59 +01:00
parent 5454062423
commit 0dacfec429
4 changed files with 80 additions and 7 deletions
@@ -193,6 +193,23 @@ pub(super) fn external_read_is_protected(display: &str) -> bool {
.is_some_and(|name| should_skip_project_snapshot_path(name))
}
/// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。
pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
// Windows 盘符 / UNC 前缀需与 RootDir 合并后再查元数据。
if matches!(component, std::path::Component::Prefix(_)) {
continue;
}
let metadata = std::fs::symlink_metadata(&current).map_err(|_| "file-not-found")?;
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
return Err("linked-path");
}
}
Ok(())
}
fn normalize_requests(root: &Path, arguments: &Value) -> Result<Vec<FileRequest>, String> {
let input: BatchRequest = serde_json::from_value(arguments.clone())
.map_err(|_| "批量读取参数只接受 files,以及 path/startLine/maxLines".to_string())?;
@@ -245,9 +262,7 @@ fn bounded_bytes(root: &Path, raw: &str) -> Result<Vec<u8>, &'static str> {
if external_read_is_protected(&target.display) {
return Err("sensitive-path");
}
if !target.absolute.exists() {
return Err("file-not-found");
}
reject_external_read_links(&target.absolute)?;
target.absolute
};
let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取")
@@ -539,7 +554,7 @@ mod tests {
use super::*;
fn project() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
let root = temp.path().canonicalize().unwrap().join("project");
init_local_game_project_at(&root, "batch-read-project", "批读测试").unwrap();
(temp, root)
}
@@ -564,6 +579,35 @@ mod tests {
);
assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1);
}
#[cfg(unix)]
#[test]
fn external_reads_reject_directory_and_file_links() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let base = temp.path().canonicalize().unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let private = base.join(".ssh");
std::fs::create_dir(&private).unwrap();
std::fs::write(private.join("config"), "test-only-private-data").unwrap();
symlink(&private, base.join("alias")).unwrap();
symlink(private.join("config"), base.join("linked.txt")).unwrap();
for path in [
base.join("alias/config"),
PathBuf::from("../alias/config"),
base.join("linked.txt"),
] {
assert_eq!(
bounded_bytes(&root, &path.to_string_lossy()),
Err("linked-path")
);
}
std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap();
assert_eq!(
bounded_bytes(&root, "../ordinary.txt").unwrap(),
b"ordinary"
);
}
#[tokio::test]
async fn hard_links_and_control_files_are_not_project_context() {
let (_temp, root) = project();
@@ -704,7 +748,7 @@ mod tests {
assert_eq!(outside["files"][1]["status"], "error");
let absolute = read_project_context(
&root,
&json!({"files":[{"path": _temp.path().join("outside.txt").to_string_lossy()}]}),
&json!({"files":[{"path": _temp.path().canonicalize().unwrap().join("outside.txt").to_string_lossy()}]}),
)
.await
.unwrap();
@@ -1157,12 +1157,16 @@ fn bridge_project_file_is_hidden_control_path(path: &str) -> bool {
const EXTERNAL_READ_LIST_MAX_FILES: usize = 2000;
fn list_external_read_files(dir: &Path) -> Result<Vec<(String, u64)>, String> {
super::direct_project_context::reject_external_read_links(dir)
.map_err(|code| format!("读取目录失败:{code}"))?;
if !dir.is_dir() {
return Err(format!("读取目录失败:{} 不是目录", dir.display()));
}
let mut files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(current) = dirs.pop() {
super::direct_project_context::reject_external_read_links(&current)
.map_err(|code| format!("读取目录失败:{code}"))?;
let entries = std::fs::read_dir(&current)
.map_err(|error| format!("读取目录失败:{}: {error}", current.display()))?;
for entry in entries {
@@ -4006,6 +4010,30 @@ mod tests {
}
}
#[cfg(unix)]
#[test]
fn external_listing_rejects_linked_scope_and_ancestors() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let base = temp.path().canonicalize().unwrap();
let private = base.join(".ssh");
fs::create_dir_all(private.join("nested")).unwrap();
fs::write(private.join("nested/config"), "test-only-private-data").unwrap();
symlink(&private, base.join("alias")).unwrap();
for path in [base.join("alias"), base.join("alias/nested")] {
assert!(list_external_read_files(&path)
.unwrap_err()
.contains("linked-path"));
}
let ordinary = base.join("ordinary");
fs::create_dir(&ordinary).unwrap();
fs::write(ordinary.join("note.txt"), "ordinary").unwrap();
symlink(&private, ordinary.join("alias")).unwrap();
let files = list_external_read_files(&ordinary).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].0.ends_with("/ordinary/note.txt"));
}
/// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType`
/// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。
///
@@ -4111,7 +4139,7 @@ mod tests {
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
let sibling = tempfile::tempdir().expect("outside root");
let outside = sibling.path().join("outside");
let outside = sibling.path().canonicalize().unwrap().join("outside");
fs::create_dir(&outside).expect("create outside directory");
fs::write(outside.join("note.txt"), b"hello").expect("write outside note");
let listed = bridge_list_project_files(