//! Настоящий `include()`: поиск в `lua/`, препроцессор, циклы и песочница. use glua::LuaRuntime; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); /// Уникальный временный каталог игры для одного теста. struct TempGame { root: PathBuf, } impl TempGame { fn new(name: &str) -> Self { let root = std::env::temp_dir().join(format!( "glua_include_{name}_{}_{}", std::process::id(), NEXT_DIR.fetch_add(1, Ordering::Relaxed) )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("lua")).expect("каталог игры"); Self { root } } fn write(&self, relative: &str, contents: &str) { let path = self.root.join(relative); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("каталог скрипта"); } std::fs::write(path, contents).expect("файл скрипта"); } fn path(&self) -> &Path { &self.root } } impl Drop for TempGame { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } } #[test] fn include_preprocesses_and_does_not_repeat_files() { let game = TempGame::new("basic"); game.write("lua/inc/counter.lua", "counter = ( counter or 0 ) + 1\n"); game.write( "lua/inc/syntax.lua", r#" local out = {} for i = 1, 3 do if ( i == 2 ) then continue end out[ #out + 1 ] = i end continued = table.concat( out, "" ) if ( !flag ) then not_flag = true end "#, ); game.write( "lua/inc/cycle_a.lua", "cycle_a = ( cycle_a or 0 ) + 1\ninclude( \"inc/cycle_b.lua\" )\n", ); game.write( "lua/inc/cycle_b.lua", "cycle_b = ( cycle_b or 0 ) + 1\ninclude( \"inc/cycle_a.lua\" )\n", ); let rt = LuaRuntime::new().expect("рантайм"); rt.set_file_root(game.path()).expect("корень"); rt.run( "include_test.lua", br#" flag = false include( "inc/counter.lua" ) include( "inc/counter.lua" ) include( "inc/syntax.lua" ) include( "inc/cycle_a.lua" ) "#, ) .expect("include выполнился"); assert_eq!( rt.probe("return tostring(counter)").expect("probe"), Some("1".into()) ); // Препроцессор тот же: continue превратился в goto, `!` — в not. assert_eq!( rt.probe("return continued").expect("probe"), Some("13".into()) ); assert_eq!( rt.probe("return tostring(not_flag)").expect("probe"), Some("true".into()) ); // Цикл a -> b -> a не зациклился и не выполнил a второй раз. assert_eq!( rt.probe("return tostring(cycle_a) .. tostring(cycle_b)") .expect("probe"), Some("11".into()) ); } #[test] fn include_refuses_escape_and_oversized_files() { let game = TempGame::new("sandbox"); game.write("lua/ok.lua", "ok = true\n"); let secret_name = format!( "glua_include_secret_{}_{}.lua", std::process::id(), NEXT_DIR.fetch_add(1, Ordering::Relaxed) ); let outside = game.path().parent().expect("родитель").join(&secret_name); std::fs::write(&outside, "leaked = true\n").expect("файл вне корня"); let rt = LuaRuntime::new().expect("рантайм"); rt.set_file_root(game.path()).expect("корень"); rt.run("ok.lua", b"include( \"ok.lua\" )") .expect("обычный include"); let script = format!("include( \"../../{secret_name}\" )"); let escape = rt.run("escape.lua", script.as_bytes()); assert!(escape.is_err(), "выход за пределы каталога игры запрещён"); game.write( "lua/big.lua", &"-- большой файл\n".repeat((1 << 20) / 16 + 1), ); let big = rt.run("big.lua", b"include( \"big.lua\" )"); assert!(big.is_err(), "файл больше лимита должен быть отклонён"); let _ = std::fs::remove_file(outside); } #[test] fn load_gamemode_collects_errors_and_continues() { let game = TempGame::new("gamemode"); game.write( "gamemodes/test/gamemode/init.lua", "error( \"init упал\" )\n", ); game.write( "gamemodes/test/gamemode/cl_init.lua", "include( \"shared.lua\" )\ncl_loaded = true\n", ); game.write( "gamemodes/test/gamemode/shared.lua", "shared_loaded = true\n", ); let rt = LuaRuntime::new().expect("рантайм"); let report = rt.load_gamemode(game.path(), "test"); assert_eq!(report.loaded, vec!["cl_init.lua".to_string()]); assert_eq!(report.errors.len(), 1, "{:?}", report.errors); assert!( report.errors[0].contains("init упал"), "{:?}", report.errors ); assert_eq!( rt.probe("return tostring(cl_loaded) .. tostring(shared_loaded)") .expect("probe"), Some("truetrue".into()), "cl_init и include рядом с геймодом должны выполниться" ); // Имя режима не должно становиться путём. let bad = rt.load_gamemode(game.path(), "../escape"); assert_eq!(bad.loaded.len(), 0); assert!(!bad.errors.is_empty()); } #[test] fn include_finds_windows_case_on_linux() { let game = TempGame::new("case"); game.write("lua/gui/IconEditor.lua", "case_loaded = true\n"); let rt = LuaRuntime::new().expect("рантайм"); rt.set_file_root(game.path()).expect("корень"); rt.run("case.lua", br#"include( "gui/IconEditor.lua" )"#) .expect("include без учёта регистра"); assert_eq!( rt.probe("return tostring(case_loaded)").expect("probe"), Some("true".into()) ); } #[test] fn include_limits_depth() { let game = TempGame::new("depth"); for index in 1..=10 { game.write( &format!("lua/depth/f{index}.lua"), &format!( "depth = ( depth or 0 ) + 1\ninclude( \"depth/f{}.lua\" )\n", index + 1 ), ); } // Последний файл цепочки ничего не включает. game.write("lua/depth/f11.lua", "depth_end = true\n"); let rt = LuaRuntime::new().expect("рантайм"); rt.set_file_root(game.path()).expect("корень"); let result = rt.run("depth.lua", b"include( \"depth/f1.lua\" )"); let error = result.expect_err("глубина должна быть ограничена"); assert!( error.to_string().contains("глубина"), "неожиданная ошибка: {error}" ); }