修复客户端预览根入口 404

允许项目根布局的 index.html 和网页资源通过预览服务访问

保留 game/ 与 assets/ 旧布局路径支持

增加根入口、资源和控制目录隔离回归测试
This commit is contained in:
2026-08-28 19:17:50 +08:00
parent f1605de4d4
commit e52f43485a
@@ -1455,6 +1455,42 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, Stri
let canonical_file = file_path
.canonicalize()
.map_err(|error| format!("预览文件不可用:{}: {error}", file_path.display()))?;
// New DirectProject layouts may use the project root itself as the web
// root. The old allow-list below only considered `game/` and `assets/`,
// which made a valid root `index.html` resolve to a 404 even though
// `project_game_root` had selected it. Permit web files below the root
// while keeping control/data directories out of the preview surface.
if root.join("index.html").is_file() && canonical_file.starts_with(&canonical_root) {
let relative = canonical_file
.strip_prefix(&canonical_root)
.map_err(|_| "预览路径越过项目目录".to_string())?;
let first_component = relative
.components()
.next()
.and_then(|component| match component {
std::path::Component::Normal(value) => value.to_str(),
_ => None,
});
let protected_root_component = first_component.is_some_and(|component| {
[
".agent",
".git",
".codex",
".hermes",
"memory",
"exports",
"node_modules",
"target",
]
.iter()
.any(|protected| component.eq_ignore_ascii_case(protected))
});
if !protected_root_component && content_type(&canonical_file) != "application/octet-stream" {
return Ok(canonical_file);
}
}
for segment in ["game", "assets"] {
let allowed_dir = root.join(segment);
let metadata = match fs::symlink_metadata(&allowed_dir) {
@@ -1543,3 +1579,83 @@ fn http_response(status: &str, content_type: &str, body: &[u8], content_length:
response.extend_from_slice(body);
response
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() {
let root = tempfile::tempdir().expect("create preview root");
fs::create_dir_all(root.path().join("game")).expect("create game directory");
fs::create_dir_all(root.path().join("assets")).expect("create assets directory");
fs::write(
root.path().join("index.html"),
"<!doctype html><html lang=\"zh-CN\"><body>根入口</body></html>",
)
.expect("write root entry");
fs::write(
root.path().join("game/index.html"),
"<!doctype html><html lang=\"zh-CN\"><body>游戏入口</body></html>",
)
.expect("write game entry");
fs::write(root.path().join("style.css"), "body { color: red; }")
.expect("write root stylesheet");
fs::write(
root.path().join("assets/icon.png"),
[0x89, 0x50, 0x4e, 0x47],
)
.expect("write asset");
let canonical_root_entry = root
.path()
.join("index.html")
.canonicalize()
.expect("canonical root entry");
let canonical_game_entry = root
.path()
.join("game/index.html")
.canonicalize()
.expect("canonical game entry");
assert_eq!(project_game_root(root.path()), root.path());
assert_eq!(
resolve_preview_path(root.path(), "/").unwrap(),
canonical_root_entry
);
assert_eq!(
resolve_preview_path(root.path(), "/index.html").unwrap(),
canonical_root_entry
);
assert_eq!(
resolve_preview_path(root.path(), "/style.css").unwrap(),
root.path().join("style.css").canonicalize().unwrap()
);
assert_eq!(
resolve_preview_path(root.path(), "/game/index.html").unwrap(),
canonical_game_entry
);
assert_eq!(
resolve_preview_path(root.path(), "/assets/icon.png").unwrap(),
root.path().join("assets/icon.png").canonicalize().unwrap()
);
let response = build_preview_response(root.path(), "GET", "/");
let response_text = String::from_utf8_lossy(&response);
assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response_text.contains("根入口"));
}
#[test]
fn root_layout_does_not_expose_control_or_data_directories() {
let root = tempfile::tempdir().expect("create preview root");
fs::create_dir_all(root.path().join(".agent")).expect("create agent directory");
fs::create_dir_all(root.path().join("memory")).expect("create memory directory");
fs::write(root.path().join("index.html"), "<!doctype html>").expect("write root entry");
fs::write(root.path().join(".agent/secret.json"), "{}").expect("write secret");
fs::write(root.path().join("memory/private.md"), "private").expect("write private data");
assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err());
assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err());
}
}