修复 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
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:
@@ -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(¤t).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(¤t)
|
||||
.map_err(|code| format!("读取目录失败:{code}"))?;
|
||||
let entries = std::fs::read_dir(¤t)
|
||||
.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(
|
||||
|
||||
Reference in New Issue
Block a user