diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index b5b51944b..f6aafa57e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1455,6 +1455,42 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result 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"), + "根入口", + ) + .expect("write root entry"); + fs::write( + root.path().join("game/index.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"), "").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()); + } +}