Index: ps/trunk/source/lib/file/file_system.cpp =================================================================== --- ps/trunk/source/lib/file/file_system.cpp (revision 25853) +++ ps/trunk/source/lib/file/file_system.cpp (revision 25854) @@ -1,216 +1,234 @@ /* Copyright (C) 2021 Wildfire Games. * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* * higher-level interface on top of sysdep/filesystem.h */ #include "precompiled.h" #include "lib/file/file_system.h" #include #include #include #include "lib/sysdep/filesystem.h" #include bool DirectoryExists(const OsPath& path) { WDIR* dir = wopendir(path); if(dir) { wclosedir(dir); return true; } return false; } bool FileExists(const OsPath& pathname) { struct stat s; const bool exists = wstat(pathname, &s) == 0; return exists; } u64 FileSize(const OsPath& pathname) { struct stat s; ENSURE(wstat(pathname, &s) == 0); return s.st_size; } Status GetFileInfo(const OsPath& pathname, CFileInfo* pPtrInfo) { errno = 0; struct stat s; memset(&s, 0, sizeof(s)); if(wstat(pathname, &s) != 0) WARN_RETURN(StatusFromErrno()); *pPtrInfo = CFileInfo(pathname.Filename(), s.st_size, s.st_mtime); return INFO::OK; } struct DirDeleter { void operator()(WDIR* osDir) const { const int ret = wclosedir(osDir); ENSURE(ret == 0); } }; Status GetDirectoryEntries(const OsPath& path, CFileInfos* files, DirectoryNames* subdirectoryNames) { // open directory errno = 0; WDIR* pDir = wopendir(path); if(!pDir) return StatusFromErrno(); // NOWARN std::shared_ptr osDir(pDir, DirDeleter()); for(;;) { errno = 0; struct wdirent* osEnt = wreaddir(osDir.get()); if(!osEnt) { // no error, just no more entries to return if(!errno) return INFO::OK; WARN_RETURN(StatusFromErrno()); } for(size_t i = 0; osEnt->d_name[i] != '\0'; i++) RETURN_STATUS_IF_ERR(Path::Validate(osEnt->d_name[i])); const OsPath name(osEnt->d_name); // get file information (mode, size, mtime) struct stat s; #if OS_WIN // .. return wdirent directly (much faster than calling stat). RETURN_STATUS_IF_ERR(wreaddir_stat_np(osDir.get(), &s)); #else // .. call regular stat(). errno = 0; const OsPath pathname = path / name; if(wstat(pathname, &s) != 0) WARN_RETURN(StatusFromErrno()); #endif if(files && S_ISREG(s.st_mode)) files->push_back(CFileInfo(name, s.st_size, s.st_mtime)); else if(subdirectoryNames && S_ISDIR(s.st_mode) && name != L"." && name != L"..") subdirectoryNames->push_back(name); } } Status CreateDirectories(const OsPath& path, mode_t mode, bool breakpoint) { if(path.empty()) return INFO::OK; struct stat s; if(wstat(path, &s) == 0) { if(!S_ISDIR(s.st_mode)) // encountered a file WARN_RETURN(ERR::FAIL); return INFO::OK; } // If we were passed a path ending with '/', strip the '/' now so that // we can consistently use Parent to find parent directory names if(path.IsDirectory()) return CreateDirectories(path.Parent(), mode, breakpoint); RETURN_STATUS_IF_ERR(CreateDirectories(path.Parent(), mode)); errno = 0; if(wmkdir(path, mode) != 0) { debug_printf("CreateDirectories: failed to mkdir %s (mode %d)\n", path.string8().c_str(), mode); if (breakpoint) WARN_RETURN(StatusFromErrno()); else return StatusFromErrno(); } return INFO::OK; } Status DeleteDirectory(const OsPath& path) { // note: we have to recursively empty the directory before it can // be deleted (required by Windows and POSIX rmdir()). CFileInfos files; DirectoryNames subdirectoryNames; RETURN_STATUS_IF_ERR(GetDirectoryEntries(path, &files, &subdirectoryNames)); // delete files for(size_t i = 0; i < files.size(); i++) { const OsPath pathname = path / files[i].Name(); errno = 0; if(wunlink(pathname) != 0) WARN_RETURN(StatusFromErrno()); } // recurse over subdirectoryNames for(size_t i = 0; i < subdirectoryNames.size(); i++) RETURN_STATUS_IF_ERR(DeleteDirectory(path / subdirectoryNames[i])); errno = 0; if(wrmdir(path) != 0) WARN_RETURN(StatusFromErrno()); return INFO::OK; } +Status RenameFile(const OsPath& path, const OsPath& newPath) +{ + if (path.empty()) + return INFO::OK; + + try + { + fs::rename(path.string8(), newPath.string8()); + } + catch (fs::filesystem_error& err) + { + debug_printf("RenameFile: failed to rename %s to %s.\n%s\n", path.string8().c_str(), path.string8().c_str(), err.what()); + return ERR::EXCEPTION; + } + + return INFO::OK; + +} Status CopyFile(const OsPath& path, const OsPath& newPath, bool override_if_exists/* = false*/) { if(path.empty()) return INFO::OK; try { if(override_if_exists) fs::copy_file(path.string8(), newPath.string8(), boost::filesystem::copy_option::overwrite_if_exists); else fs::copy_file(path.string8(), newPath.string8()); } catch(fs::filesystem_error& err) { debug_printf("CopyFile: failed to copy %s to %s.\n%s\n", path.string8().c_str(), path.string8().c_str(), err.what()); return ERR::EXCEPTION; } return INFO::OK; } Index: ps/trunk/source/lib/file/file_system.h =================================================================== --- ps/trunk/source/lib/file/file_system.h (revision 25853) +++ ps/trunk/source/lib/file/file_system.h (revision 25854) @@ -1,91 +1,93 @@ -/* Copyright (C) 2020 Wildfire Games. +/* Copyright (C) 2021 Wildfire Games. * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* * higher-level interface on top of sysdep/filesystem.h */ #ifndef INCLUDED_FILE_SYSTEM #define INCLUDED_FILE_SYSTEM #include "lib/os_path.h" #include "lib/posix/posix_filesystem.h" // mode_t #include LIB_API bool DirectoryExists(const OsPath& path); LIB_API bool FileExists(const OsPath& pathname); LIB_API u64 FileSize(const OsPath& pathname); // (bundling size and mtime avoids a second expensive call to stat()) class CFileInfo { public: CFileInfo() { } CFileInfo(const OsPath& name, off_t size, time_t mtime) : name(name), size(size), mtime(mtime) { } const OsPath& Name() const { return name; } off_t Size() const { return size; } time_t MTime() const { return mtime; } private: OsPath name; off_t size; time_t mtime; }; LIB_API Status GetFileInfo(const OsPath& pathname, CFileInfo* fileInfo); typedef std::vector CFileInfos; typedef std::vector DirectoryNames; LIB_API Status GetDirectoryEntries(const OsPath& path, CFileInfos* files, DirectoryNames* subdirectoryNames); // same as boost::filesystem::create_directories, except that mkdir is invoked with // instead of 0755. // If the breakpoint is enabled, debug_break will be called if the directory didn't exist and couldn't be created. LIB_API Status CreateDirectories(const OsPath& path, mode_t mode, bool breakpoint = true); LIB_API Status DeleteDirectory(const OsPath& dirPath); LIB_API Status CopyFile(const OsPath& path, const OsPath& newPath, bool override_if_exists = false); +LIB_API Status RenameFile(const OsPath& path, const OsPath& newPath); + #endif // #ifndef INCLUDED_FILE_SYSTEM Index: ps/trunk/source/main.cpp =================================================================== --- ps/trunk/source/main.cpp (revision 25853) +++ ps/trunk/source/main.cpp (revision 25854) @@ -1,755 +1,759 @@ /* Copyright (C) 2021 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ /* This module drives the game when running without Atlas (our integrated map editor). It receives input and OS messages via SDL and feeds them into the input dispatcher, where they are passed on to the game GUI and simulation. It also contains main(), which either runs the above controller or that of Atlas depending on commandline parameters. */ // not for any PCH effort, but instead for the (common) definitions // included there. #define MINIMAL_PCH 2 #include "lib/precompiled.h" #include "lib/debug.h" #include "lib/status.h" #include "lib/secure_crt.h" #include "lib/frequency_filter.h" #include "lib/input.h" #include "lib/ogl.h" #include "lib/timer.h" #include "lib/external_libraries/libsdl.h" #include "ps/ArchiveBuilder.h" #include "ps/CConsole.h" #include "ps/CLogger.h" #include "ps/ConfigDB.h" #include "ps/Filesystem.h" #include "ps/Game.h" #include "ps/Globals.h" #include "ps/Hotkey.h" #include "ps/Loader.h" #include "ps/Mod.h" #include "ps/ModInstaller.h" #include "ps/Profile.h" #include "ps/Profiler2.h" #include "ps/Pyrogenesis.h" #include "ps/Replay.h" #include "ps/TouchInput.h" #include "ps/UserReport.h" #include "ps/Util.h" #include "ps/VideoMode.h" #include "ps/TaskManager.h" #include "ps/World.h" #include "ps/GameSetup/GameSetup.h" #include "ps/GameSetup/Atlas.h" #include "ps/GameSetup/Config.h" #include "ps/GameSetup/CmdLineArgs.h" #include "ps/GameSetup/Paths.h" #include "ps/XML/Xeromyces.h" #include "network/NetClient.h" #include "network/NetServer.h" #include "network/NetSession.h" #include "lobby/IXmppClient.h" #include "graphics/Camera.h" #include "graphics/GameView.h" #include "graphics/TextureManager.h" #include "gui/GUIManager.h" #include "renderer/Renderer.h" #include "rlinterface/RLInterface.h" #include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptEngine.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/JSON.h" #include "simulation2/Simulation2.h" #include "simulation2/system/TurnManager.h" #include "soundmanager/ISoundManager.h" #if OS_UNIX #include #include // geteuid #endif // OS_UNIX #if OS_MACOSX #include "lib/sysdep/os/osx/osx_atlas.h" #endif #if MSC_VERSION #include #define getpid _getpid // Use the non-deprecated function name #endif #include extern CStrW g_UniqueLogPostfix; // Marks terrain as modified so the minimap can repaint (is there a cleaner way of handling this?) bool g_GameRestarted = false; // Determines the lifetime of the mainloop enum ShutdownType { // The application shall continue the main loop. None, // The process shall terminate as soon as possible. Quit, // The engine should be restarted in the same process, for instance to activate different mods. Restart, // Atlas should be started in the same process. RestartAsAtlas }; static ShutdownType g_Shutdown = ShutdownType::None; // to avoid redundant and/or recursive resizing, we save the new // size after VIDEORESIZE messages and only update the video mode // once per frame. // these values are the latest resize message, and reset to 0 once we've // updated the video mode static int g_ResizedW; static int g_ResizedH; static std::chrono::high_resolution_clock::time_point lastFrameTime; bool IsQuitRequested() { return g_Shutdown == ShutdownType::Quit; } void QuitEngine() { g_Shutdown = ShutdownType::Quit; } void RestartEngine() { g_Shutdown = ShutdownType::Restart; } void StartAtlas() { g_Shutdown = ShutdownType::RestartAsAtlas; } // main app message handler static InReaction MainInputHandler(const SDL_Event_* ev) { switch(ev->ev.type) { case SDL_WINDOWEVENT: switch(ev->ev.window.event) { case SDL_WINDOWEVENT_ENTER: RenderCursor(true); break; case SDL_WINDOWEVENT_LEAVE: RenderCursor(false); break; case SDL_WINDOWEVENT_RESIZED: g_ResizedW = ev->ev.window.data1; g_ResizedH = ev->ev.window.data2; break; case SDL_WINDOWEVENT_MOVED: g_VideoMode.UpdatePosition(ev->ev.window.data1, ev->ev.window.data2); } break; case SDL_QUIT: QuitEngine(); break; case SDL_DROPFILE: { char* dropped_filedir = ev->ev.drop.file; const Paths paths(g_CmdLineArgs); CModInstaller installer(paths.UserData() / "mods", paths.Cache()); installer.Install(std::string(dropped_filedir), g_ScriptContext, true); SDL_free(dropped_filedir); if (installer.GetInstalledMods().empty()) LOGERROR("Failed to install mod %s", dropped_filedir); else { LOGMESSAGE("Installed mod %s", installer.GetInstalledMods().front()); ScriptInterface modInterface("Engine", "Mod", g_ScriptContext); g_Mods.UpdateAvailableMods(modInterface); RestartEngine(); } break; } case SDL_HOTKEYPRESS: std::string hotkey = static_cast(ev->ev.user.data1); if (hotkey == "exit") { QuitEngine(); return IN_HANDLED; } else if (hotkey == "screenshot") { WriteScreenshot(L".png"); return IN_HANDLED; } else if (hotkey == "bigscreenshot") { int tiles = 4, tileWidth = 256, tileHeight = 256; CFG_GET_VAL("screenshot.tiles", tiles); CFG_GET_VAL("screenshot.tilewidth", tileWidth); CFG_GET_VAL("screenshot.tileheight", tileHeight); if (tiles > 0 && tileWidth > 0 && tileHeight > 0) WriteBigScreenshot(L".bmp", tiles, tileWidth, tileHeight); else LOGWARNING("Invalid big screenshot size: tiles=%d tileWidth=%d tileHeight=%d", tiles, tileWidth, tileHeight); return IN_HANDLED; } else if (hotkey == "togglefullscreen") { g_VideoMode.ToggleFullscreen(); return IN_HANDLED; } else if (hotkey == "profile2.toggle") { g_Profiler2.Toggle(); return IN_HANDLED; } break; } return IN_PASS; } // dispatch all pending events to the various receivers. static void PumpEvents() { ScriptRequest rq(g_GUI->GetScriptInterface()); PROFILE3("dispatch events"); SDL_Event_ ev; while (in_poll_event(&ev)) { PROFILE2("event"); if (g_GUI) { JS::RootedValue tmpVal(rq.cx); Script::ToJSVal(rq, &tmpVal, ev); std::string data = Script::StringifyJSON(rq, &tmpVal); PROFILE2_ATTR("%s", data.c_str()); } in_dispatch_event(&ev); } g_TouchInput.Frame(); } /** * Optionally throttle the render frequency in order to * prevent 100% workload of the currently used CPU core. */ inline static void LimitFPS() { if (g_VideoMode.IsVSyncEnabled()) return; double fpsLimit = 0.0; CFG_GET_VAL(g_Game && g_Game->IsGameStarted() ? "adaptivefps.session" : "adaptivefps.menu", fpsLimit); // Keep in sync with options.json if (fpsLimit < 20.0 || fpsLimit >= 100.0) return; double wait = 1000.0 / fpsLimit - std::chrono::duration_cast( std::chrono::high_resolution_clock::now() - lastFrameTime).count() / 1000.0; if (wait > 0.0) SDL_Delay(wait); lastFrameTime = std::chrono::high_resolution_clock::now(); } static int ProgressiveLoad() { PROFILE3("progressive load"); wchar_t description[100]; int progress_percent; try { Status ret = LDR_ProgressiveLoad(10e-3, description, ARRAY_SIZE(description), &progress_percent); switch(ret) { // no load active => no-op (skip code below) case INFO::OK: return 0; // current task didn't complete. we only care about this insofar as the // load process is therefore not yet finished. case ERR::TIMED_OUT: break; // just finished loading case INFO::ALL_COMPLETE: g_Game->ReallyStartGame(); wcscpy_s(description, ARRAY_SIZE(description), L"Game is starting.."); // LDR_ProgressiveLoad returns L""; set to valid text to // avoid problems in converting to JSString break; // error! default: WARN_RETURN_STATUS_IF_ERR(ret); // can't do this above due to legit ERR::TIMED_OUT break; } } catch (PSERROR_Game_World_MapLoadFailed& e) { // Map loading failed // Call script function to do the actual work // (delete game data, switch GUI page, show error, etc.) CancelLoad(CStr(e.what()).FromUTF8()); } GUI_DisplayLoadProgress(progress_percent, description); return 0; } static void RendererIncrementalLoad() { PROFILE3("renderer incremental load"); const double maxTime = 0.1f; double startTime = timer_Time(); bool more; do { more = g_Renderer.GetTextureManager().MakeProgress(); } while (more && timer_Time() - startTime < maxTime); } static void Frame() { g_Profiler2.RecordFrameStart(); PROFILE2("frame"); g_Profiler2.IncrementFrameNumber(); PROFILE2_ATTR("%d", g_Profiler2.GetFrameNumber()); ogl_WarnIfError(); // get elapsed time const double time = timer_Time(); g_frequencyFilter->Update(time); // .. old method - "exact" but contains jumps #if 0 static double last_time; const double time = timer_Time(); const float TimeSinceLastFrame = (float)(time-last_time); last_time = time; ONCE(return); // first call: set last_time and return // .. new method - filtered and more smooth, but errors may accumulate #else const float realTimeSinceLastFrame = 1.0 / g_frequencyFilter->SmoothedFrequency(); #endif ENSURE(realTimeSinceLastFrame > 0.0f); // Decide if update is necessary bool need_update = true; // If we are not running a multiplayer game, disable updates when the game is // minimized or out of focus and relinquish the CPU a bit, in order to make // debugging easier. if (g_PauseOnFocusLoss && !g_NetClient && !g_app_has_focus) { PROFILE3("non-focus delay"); need_update = false; // don't use SDL_WaitEvent: don't want the main loop to freeze until app focus is restored SDL_Delay(10); } // this scans for changed files/directories and reloads them, thus // allowing hotloading (changes are immediately assimilated in-game). ReloadChangedFiles(); ProgressiveLoad(); RendererIncrementalLoad(); PumpEvents(); // if the user quit by closing the window, the GL context will be broken and // may crash when we call Render() on some drivers, so leave this loop // before rendering if (g_Shutdown != ShutdownType::None) return; // respond to pumped resize events if (g_ResizedW || g_ResizedH) { g_VideoMode.ResizeWindow(g_ResizedW, g_ResizedH); g_ResizedW = g_ResizedH = 0; } if (g_NetClient) g_NetClient->Poll(); ogl_WarnIfError(); g_GUI->TickObjects(); ogl_WarnIfError(); if (g_RLInterface) g_RLInterface->TryApplyMessage(); if (g_Game && g_Game->IsGameStarted() && need_update) { if (!g_RLInterface) g_Game->Update(realTimeSinceLastFrame); g_Game->GetView()->Update(float(realTimeSinceLastFrame)); } // Keep us connected to any XMPP servers if (g_XmppClient) g_XmppClient->recv(); g_UserReporter.Update(); g_Console->Update(realTimeSinceLastFrame); ogl_WarnIfError(); if (g_SoundManager) g_SoundManager->IdleTask(); if (ShouldRender()) { Render(); { PROFILE3("swap buffers"); SDL_GL_SwapWindow(g_VideoMode.GetWindow()); ogl_WarnIfError(); } g_Renderer.OnSwapBuffers(); } g_Profiler.Frame(); g_GameRestarted = false; LimitFPS(); } static void NonVisualFrame() { g_Profiler2.RecordFrameStart(); PROFILE2("frame"); g_Profiler2.IncrementFrameNumber(); PROFILE2_ATTR("%d", g_Profiler2.GetFrameNumber()); static u32 turn = 0; debug_printf("Turn %u (%u)...\n", turn++, DEFAULT_TURN_LENGTH); g_Game->GetSimulation2()->Update(DEFAULT_TURN_LENGTH); g_Profiler.Frame(); if (g_Game->IsGameFinished()) QuitEngine(); } static void MainControllerInit() { // add additional input handlers only needed by this controller: // must be registered after gui_handler. Should mayhap even be last. in_add_handler(MainInputHandler); } static void MainControllerShutdown() { in_reset_handlers(); } static void StartRLInterface(CmdLineArgs args) { std::string server_address; CFG_GET_VAL("rlinterface.address", server_address); if (!args.Get("rl-interface").empty()) server_address = args.Get("rl-interface"); g_RLInterface = std::make_unique(server_address.c_str()); debug_printf("RL interface listening on %s\n", server_address.c_str()); } // moved into a helper function to ensure args is destroyed before // exit(), which may result in a memory leak. static void RunGameOrAtlas(int argc, const char* argv[]) { CmdLineArgs args(argc, argv); g_CmdLineArgs = args; if (args.Has("version")) { debug_printf("Pyrogenesis %s\n", engine_version); return; } if (args.Has("autostart-nonvisual") && args.Get("autostart").empty() && !args.Has("rl-interface")) { LOGERROR("-autostart-nonvisual cant be used alone. A map with -autostart=\"TYPEDIR/MAPNAME\" is needed."); return; } if (args.Has("unique-logs")) g_UniqueLogPostfix = L"_" + std::to_wstring(std::time(nullptr)) + L"_" + std::to_wstring(getpid()); const bool isVisualReplay = args.Has("replay-visual"); const bool isNonVisualReplay = args.Has("replay"); const bool isNonVisual = args.Has("autostart-nonvisual"); const bool isUsingRLInterface = args.Has("rl-interface"); const OsPath replayFile( isVisualReplay ? args.Get("replay-visual") : isNonVisualReplay ? args.Get("replay") : ""); if (isVisualReplay || isNonVisualReplay) { if (!FileExists(replayFile)) { debug_printf("ERROR: The requested replay file '%s' does not exist!\n", replayFile.string8().c_str()); return; } if (DirectoryExists(replayFile)) { debug_printf("ERROR: The requested replay file '%s' is a directory!\n", replayFile.string8().c_str()); return; } } std::vector modsToInstall; for (const CStr& arg : args.GetArgsWithoutName()) { const OsPath modPath(arg); if (!CModInstaller::IsDefaultModExtension(modPath.Extension())) { debug_printf("Skipping file '%s' which does not have a mod file extension.\n", modPath.string8().c_str()); continue; } if (!FileExists(modPath)) { debug_printf("ERROR: The mod file '%s' does not exist!\n", modPath.string8().c_str()); continue; } if (DirectoryExists(modPath)) { debug_printf("ERROR: The mod file '%s' is a directory!\n", modPath.string8().c_str()); continue; } modsToInstall.emplace_back(std::move(modPath)); } // We need to initialize SpiderMonkey and libxml2 in the main thread before // any thread uses them. So initialize them here before we might run Atlas. ScriptEngine scriptEngine; CXeromyces::Startup(); // Initialise the global task manager at this point (JS & Profiler2 are set up). Threading::TaskManager::Initialise(); if (ATLAS_RunIfOnCmdLine(args, false)) { CXeromyces::Terminate(); return; } if (isNonVisualReplay) { Paths paths(args); g_VFS = CreateVfs(); // Mount with highest priority, we don't want mods overwriting this. g_VFS->Mount(L"cache/", paths.Cache(), VFS_MOUNT_ARCHIVABLE, VFS_MAX_PRIORITY); { CReplayPlayer replay; replay.Load(replayFile); replay.Replay( args.Has("serializationtest"), args.Has("rejointest") ? args.Get("rejointest").ToInt() : -1, args.Has("ooslog"), !args.Has("hashtest-full") || args.Get("hashtest-full") == "true", args.Has("hashtest-quick") && args.Get("hashtest-quick") == "true"); } g_VFS.reset(); CXeromyces::Terminate(); return; } // run in archive-building mode if requested if (args.Has("archivebuild")) { Paths paths(args); OsPath mod(args.Get("archivebuild")); OsPath zip; if (args.Has("archivebuild-output")) zip = args.Get("archivebuild-output"); else zip = mod.Filename().ChangeExtension(L".zip"); CArchiveBuilder builder(mod, paths.Cache()); // Add mods provided on the command line // NOTE: We do not handle mods in the user mod path here std::vector mods = args.GetMultiple("mod"); for (size_t i = 0; i < mods.size(); ++i) builder.AddBaseMod(paths.RData()/"mods"/mods[i]); builder.Build(zip, args.Has("archivebuild-compress")); CXeromyces::Terminate(); return; } const double res = timer_Resolution(); g_frequencyFilter = CreateFrequencyFilter(res, 30.0); // run the game int flags = INIT_MODS; do { g_Shutdown = ShutdownType::None; if (!Init(args, flags)) { flags &= ~INIT_MODS; Shutdown(SHUTDOWN_FROM_CONFIG); continue; } std::vector installedMods; if (!modsToInstall.empty()) { Paths paths(args); CModInstaller installer(paths.UserData() / "mods", paths.Cache()); // Install the mods without deleting the pyromod files for (const OsPath& modPath : modsToInstall) - installer.Install(modPath, g_ScriptContext, true); + { + CModInstaller::ModInstallationResult result = installer.Install(modPath, g_ScriptContext, true); + if (result != CModInstaller::ModInstallationResult::SUCCESS) + LOGERROR("Failed to install '%s'", modPath.string8().c_str()); + } installedMods = installer.GetInstalledMods(); ScriptInterface modInterface("Engine", "Mod", g_ScriptContext); g_Mods.UpdateAvailableMods(modInterface); } if (isNonVisual) { InitNonVisual(args); if (isUsingRLInterface) StartRLInterface(args); while (g_Shutdown == ShutdownType::None) { if (isUsingRLInterface) g_RLInterface->TryApplyMessage(); else NonVisualFrame(); } } else { InitGraphics(args, 0, installedMods); MainControllerInit(); if (isUsingRLInterface) StartRLInterface(args); while (g_Shutdown == ShutdownType::None) Frame(); } // Do not install mods again in case of restart (typically from the mod selector) modsToInstall.clear(); Shutdown(0); MainControllerShutdown(); flags &= ~INIT_MODS; } while (g_Shutdown == ShutdownType::Restart); #if OS_MACOSX if (g_Shutdown == ShutdownType::RestartAsAtlas) startNewAtlasProcess(g_Mods.GetEnabledMods()); #else if (g_Shutdown == ShutdownType::RestartAsAtlas) ATLAS_RunIfOnCmdLine(args, true); #endif Threading::TaskManager::Instance().ClearQueue(); CXeromyces::Terminate(); } #if OS_ANDROID // In Android we compile the engine as a shared library, not an executable, // so rename main() to a different symbol that the wrapper library can load #undef main #define main pyrogenesis_main extern "C" __attribute__((visibility ("default"))) int main(int argc, char* argv[]); #endif extern "C" int main(int argc, char* argv[]) { #if OS_UNIX // Don't allow people to run the game with root permissions, // because bad things can happen, check before we do anything if (geteuid() == 0) { std::cerr << "********************************************************\n" << "WARNING: Attempted to run the game with root permission!\n" << "This is not allowed because it can alter home directory \n" << "permissions and opens your system to vulnerabilities. \n" << "(You received this message because you were either \n" <<" logged in as root or used e.g. the 'sudo' command.) \n" << "********************************************************\n\n"; return EXIT_FAILURE; } #endif // OS_UNIX EarlyInit(); // must come at beginning of main RunGameOrAtlas(argc, const_cast(argv)); // Shut down profiler initialised by EarlyInit g_Profiler2.Shutdown(); return EXIT_SUCCESS; } Index: ps/trunk/source/ps/ModInstaller.cpp =================================================================== --- ps/trunk/source/ps/ModInstaller.cpp (revision 25853) +++ ps/trunk/source/ps/ModInstaller.cpp (revision 25854) @@ -1,112 +1,125 @@ /* Copyright (C) 2021 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "ModInstaller.h" #include "lib/file/vfs/vfs_util.h" #include "ps/Filesystem.h" #include "ps/XML/Xeromyces.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/JSON.h" #include CModInstaller::CModInstaller(const OsPath& modsdir, const OsPath& tempdir) : m_ModsDir(modsdir), m_TempDir(tempdir / "_modscache"), m_CacheDir("cache/") { m_VFS = CreateVfs(); CreateDirectories(m_TempDir, 0700); } CModInstaller::~CModInstaller() { m_VFS.reset(); DeleteDirectory(m_TempDir); } CModInstaller::ModInstallationResult CModInstaller::Install( const OsPath& mod, const std::shared_ptr& scriptContext, bool keepFile) { const OsPath modTemp = m_TempDir / mod.Basename() / mod.Filename().ChangeExtension(L".zip"); CreateDirectories(modTemp.Parent(), 0700); if (keepFile) - CopyFile(mod, modTemp, true); - else - wrename(mod, modTemp); + { + if (CopyFile(mod, modTemp, true) != INFO::OK) + { + LOGERROR("Failed to copy '%s' to '%s'", mod.string8().c_str(), modTemp.string8().c_str()); + return FAIL_ON_MOD_COPY; + } + } + else if (RenameFile(mod, modTemp) != INFO::OK) + { + LOGERROR("Failed to rename '%s' into '%s'", mod.string8().c_str(), modTemp.string8().c_str()); + return FAIL_ON_MOD_MOVE; + } // Load the mod to VFS if (m_VFS->Mount(m_CacheDir, m_TempDir / "") != INFO::OK) return FAIL_ON_VFS_MOUNT; CVFSFile modinfo; PSRETURN modinfo_status = modinfo.Load(m_VFS, m_CacheDir / modTemp.Basename() / "mod.json", false); m_VFS->Clear(); if (modinfo_status != PSRETURN_OK) return FAIL_ON_MOD_LOAD; // Extract the name of the mod CStr modName; { ScriptInterface scriptInterface("Engine", "ModInstaller", scriptContext); ScriptRequest rq(scriptInterface); JS::RootedValue json_val(rq.cx); if (!Script::ParseJSON(rq, modinfo.GetAsString(), &json_val)) return FAIL_ON_PARSE_JSON; JS::RootedObject json_obj(rq.cx, json_val.toObjectOrNull()); JS::RootedValue name_val(rq.cx); if (!JS_GetProperty(rq.cx, json_obj, "name", &name_val)) return FAIL_ON_EXTRACT_NAME; Script::FromJSVal(rq, name_val, modName); if (modName.empty()) return FAIL_ON_EXTRACT_NAME; } const OsPath modDir = m_ModsDir / modName; const OsPath modPath = modDir / (modName + ".zip"); // Create a directory with the following structure: // mod-name/ // mod-name.zip // mod.json CreateDirectories(modDir, 0700); - if (wrename(modTemp, modPath) != 0) + if (RenameFile(modTemp, modPath) != INFO::OK) + { + LOGERROR("Failed to rename '%s' into '%s'", modTemp.string8().c_str(), modPath.string8().c_str()); return FAIL_ON_MOD_MOVE; + } + DeleteDirectory(modTemp.Parent()); std::ofstream mod_json((modDir / "mod.json").string8()); if (mod_json.good()) { mod_json << modinfo.GetAsString(); mod_json.close(); } else return FAIL_ON_JSON_WRITE; m_InstalledMods.emplace_back(modName); return SUCCESS; } const std::vector& CModInstaller::GetInstalledMods() const { return m_InstalledMods; } Index: ps/trunk/source/ps/ModInstaller.h =================================================================== --- ps/trunk/source/ps/ModInstaller.h (revision 25853) +++ ps/trunk/source/ps/ModInstaller.h (revision 25854) @@ -1,86 +1,87 @@ /* Copyright (C) 2021 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #ifndef INCLUDED_MODINSTALLER #define INCLUDED_MODINSTALLER #include "CStr.h" #include "lib/file/vfs/vfs.h" #include class ScriptContext; /** * Install a mod into the mods directory. */ class CModInstaller { public: enum ModInstallationResult { SUCCESS, FAIL_ON_VFS_MOUNT, FAIL_ON_MOD_LOAD, FAIL_ON_PARSE_JSON, FAIL_ON_EXTRACT_NAME, FAIL_ON_MOD_MOVE, - FAIL_ON_JSON_WRITE + FAIL_ON_JSON_WRITE, + FAIL_ON_MOD_COPY }; /** * Initialise the mod installer for processing the given mod. * * @param modsdir path to the data directory that contains mods * @param tempdir path to a writable directory for temporary files */ CModInstaller(const OsPath& modsdir, const OsPath& tempdir); ~CModInstaller(); /** * Process and unpack the mod. * @param mod path of .pyromod/.zip file * @param keepFile if true, copy the file, if false move it */ ModInstallationResult Install( const OsPath& mod, const std::shared_ptr& scriptContext, bool keepFile); /** * @return a list of all mods installed so far by this CModInstaller. */ const std::vector& GetInstalledMods() const; /** * @return whether the path has a mod-like extension. */ static bool IsDefaultModExtension(const Path& ext) { return ext == ".pyromod" || ext == ".zip"; } private: PIVFS m_VFS; OsPath m_ModsDir; OsPath m_TempDir; VfsPath m_CacheDir; std::vector m_InstalledMods; }; #endif // INCLUDED_MODINSTALLER Index: ps/trunk/source/ps/ModIo.cpp =================================================================== --- ps/trunk/source/ps/ModIo.cpp (revision 25853) +++ ps/trunk/source/ps/ModIo.cpp (revision 25854) @@ -1,858 +1,860 @@ /* Copyright (C) 2021 Wildfire Games. * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #include "precompiled.h" #include "ModIo.h" #include "i18n/L10n.h" #include "lib/file/file_system.h" #include "lib/sysdep/filesystem.h" #include "lib/sysdep/sysdep.h" #include "maths/MD5.h" #include "ps/CLogger.h" #include "ps/ConfigDB.h" #include "ps/GameSetup/CmdLineArgs.h" #include "ps/GameSetup/Paths.h" #include "ps/Mod.h" #include "ps/ModInstaller.h" #include "ps/Util.h" #include "scriptinterface/ScriptConversions.h" #include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptRequest.h" #include "scriptinterface/JSON.h" #include #include ModIo* g_ModIo = nullptr; struct DownloadCallbackData { DownloadCallbackData() : fp(nullptr), md5(), hash_state(nullptr) { } DownloadCallbackData(FILE* _fp) : fp(_fp), md5() { hash_state = static_cast( sodium_malloc(crypto_generichash_statebytes())); ENSURE(hash_state); crypto_generichash_init(hash_state, nullptr, 0U, crypto_generichash_BYTES_MAX); } ~DownloadCallbackData() { if (hash_state) sodium_free(hash_state); } FILE* fp; MD5 md5; crypto_generichash_state* hash_state; }; ModIo::ModIo() : m_GamesRequest("/games"), m_CallbackData(nullptr) { // Get config values from the default namespace. // This can be overridden on the command line. // // We do this so a malicious mod cannot change the base url and // get the user to make connections to someone else's endpoint. // If another user of the engine wants to provide different values // here, while still using the same engine version, they can just // provide some shortcut/script that sets these using command line // parameters. std::string pk_str; g_ConfigDB.GetValue(CFG_DEFAULT, "modio.public_key", pk_str); g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.baseurl", m_BaseUrl); { std::string api_key; g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.api_key", api_key); m_ApiKey = "api_key=" + api_key; } { std::string nameid; g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.name_id", nameid); m_IdQuery = "name_id="+nameid; } m_CurlMulti = curl_multi_init(); ENSURE(m_CurlMulti); m_Curl = curl_easy_init(); ENSURE(m_Curl); // Capture error messages curl_easy_setopt(m_Curl, CURLOPT_ERRORBUFFER, m_ErrorBuffer); // Fail if the server did curl_easy_setopt(m_Curl, CURLOPT_FAILONERROR, 1L); // Disable signal handlers (required for multithreaded applications) curl_easy_setopt(m_Curl, CURLOPT_NOSIGNAL, 1L); // To minimise security risks, don't support redirects (except for file // downloads, for which this setting will be enabled). curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L); // For file downloads, one redirect seems plenty for a CDN serving the files. curl_easy_setopt(m_Curl, CURLOPT_MAXREDIRS, 1L); m_Headers = NULL; std::string ua = "User-Agent: pyrogenesis "; ua += curl_version(); ua += " (https://play0ad.com/)"; m_Headers = curl_slist_append(m_Headers, ua.c_str()); curl_easy_setopt(m_Curl, CURLOPT_HTTPHEADER, m_Headers); if (sodium_init() < 0) ENSURE(0 && "Failed to initialize libsodium."); size_t bin_len = 0; if (sodium_base642bin((unsigned char*)&m_pk, sizeof m_pk, pk_str.c_str(), pk_str.size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof m_pk) ENSURE(0 && "Failed to decode base64 public key. Please fix your configuration or mod.io will be unusable."); } ModIo::~ModIo() { // Clean things up to avoid unpleasant surprises, // and delete the temporary file if any. TearDownRequest(); if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) DeleteDownloadedFile(); curl_slist_free_all(m_Headers); curl_easy_cleanup(m_Curl); curl_multi_cleanup(m_CurlMulti); delete m_CallbackData; } size_t ModIo::ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp) { ModIo* self = static_cast(userp); self->m_ResponseData += std::string((char*)buffer, (char*)buffer+size*nmemb); return size*nmemb; } size_t ModIo::DownloadCallback(void* buffer, size_t size, size_t nmemb, void* userp) { DownloadCallbackData* data = static_cast(userp); if (!data->fp) return 0; size_t len = fwrite(buffer, size, nmemb, data->fp); // Only update the hash with data we actually managed to write. // In case we did not write all of it we will fail the download, // but we do not want to have a possibly valid hash in that case. size_t written = len*size; data->md5.Update(static_cast(buffer), written); ENSURE(data->hash_state); crypto_generichash_update(data->hash_state, static_cast(buffer), written); return written; } int ModIo::DownloadProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t UNUSED(ultotal), curl_off_t UNUSED(ulnow)) { DownloadProgressData* data = static_cast(clientp); // If we got more data than curl expected, something is very wrong, abort. if (dltotal != 0 && dlnow > dltotal) return 1; data->progress = dltotal == 0 ? 0 : static_cast(dlnow) / static_cast(dltotal); return 0; } CURLMcode ModIo::SetupRequest(const std::string& url, bool fileDownload) { if (fileDownload) { // The download link will most likely redirect elsewhere, so allow that. // We verify the validity of the file later. curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 1L); // Enable the progress meter curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 0L); // Set IO callbacks curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, DownloadCallback); curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, static_cast(m_CallbackData)); curl_easy_setopt(m_Curl, CURLOPT_XFERINFOFUNCTION, DownloadProgressCallback); curl_easy_setopt(m_Curl, CURLOPT_XFERINFODATA, static_cast(&m_DownloadProgressData)); // Initialize the progress counter m_DownloadProgressData.progress = 0; } else { // To minimise security risks, don't support redirects curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L); // Disable the progress meter curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 1L); // Set IO callbacks curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, ReceiveCallback); curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, this); } m_ErrorBuffer[0] = '\0'; curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str()); return curl_multi_add_handle(m_CurlMulti, m_Curl); } void ModIo::TearDownRequest() { ENSURE(curl_multi_remove_handle(m_CurlMulti, m_Curl) == CURLM_OK); if (m_CallbackData) { if (m_CallbackData->fp) fclose(m_CallbackData->fp); m_CallbackData->fp = nullptr; } } void ModIo::StartGetGameId() { // Don't start such a request during active downloads. if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID || m_DownloadProgressData.status == DownloadProgressStatus::LISTING || m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) return; m_GameId.clear(); CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+"?"+m_ApiKey+"&"+m_IdQuery, false); if (err != CURLM_OK) { TearDownRequest(); m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; m_DownloadProgressData.error = fmt::sprintf( g_L10n.Translate("Failure while starting querying for game id. Error: %s; %s."), curl_multi_strerror(err), m_ErrorBuffer); return; } m_DownloadProgressData.status = DownloadProgressStatus::GAMEID; } void ModIo::StartListMods() { // Don't start such a request during active downloads. if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID || m_DownloadProgressData.status == DownloadProgressStatus::LISTING || m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) return; m_ModData.clear(); if (m_GameId.empty()) { LOGERROR("Game ID not fetched from mod.io. Call StartGetGameId first and wait for it to finish."); return; } CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+m_GameId+"/mods?"+m_ApiKey, false); if (err != CURLM_OK) { TearDownRequest(); m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; m_DownloadProgressData.error = fmt::sprintf( g_L10n.Translate("Failure while starting querying for mods. Error: %s; %s."), curl_multi_strerror(err), m_ErrorBuffer); return; } m_DownloadProgressData.status = DownloadProgressStatus::LISTING; } void ModIo::StartDownloadMod(u32 idx) { // Don't start such a request during active downloads. if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID || m_DownloadProgressData.status == DownloadProgressStatus::LISTING || m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) return; if (idx >= m_ModData.size()) return; const Paths paths(g_CmdLineArgs); const OsPath modUserPath = paths.UserData()/"mods"; const OsPath modPath = modUserPath/m_ModData[idx].properties["name_id"]; if (!DirectoryExists(modPath) && INFO::OK != CreateDirectories(modPath, 0700, false)) { m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; m_DownloadProgressData.error = fmt::sprintf( g_L10n.Translate("Could not create mod directory: %s."), modPath.string8()); return; } // Name the file after the name_id, since using the filename would mean that // we could end up with multiple zip files in the folder that might not work // as expected for a user (since a later version might remove some files // that aren't compatible anymore with the engine version). // So we ignore the filename provided by the API and assume that we do not // care about handling update.zip files. If that is the case we would need // a way to find out what files are required by the current one and which // should be removed for everything to work. This seems to be too complicated // so we just do not support that usage. // NOTE: We do save the file under a slightly different name from the final // one, to ensure that in case a download aborts and the file stays // around, the game will not attempt to open the file which has not // been verified. m_DownloadFilePath = modPath/(m_ModData[idx].properties["name_id"]+".zip.temp"); delete m_CallbackData; m_CallbackData = new DownloadCallbackData(sys_OpenFile(m_DownloadFilePath, "wb")); if (!m_CallbackData->fp) { m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; m_DownloadProgressData.error = fmt::sprintf( g_L10n.Translate("Could not open temporary file for mod download: %s."), m_DownloadFilePath.string8()); return; } CURLMcode err = SetupRequest(m_ModData[idx].properties["binary_url"], true); if (err != CURLM_OK) { TearDownRequest(); m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; m_DownloadProgressData.error = fmt::sprintf( g_L10n.Translate("Failed to start the download. Error: %s; %s."), curl_multi_strerror(err), m_ErrorBuffer); return; } m_DownloadModID = idx; m_DownloadProgressData.status = DownloadProgressStatus::DOWNLOADING; } void ModIo::CancelRequest() { TearDownRequest(); switch (m_DownloadProgressData.status) { case DownloadProgressStatus::GAMEID: case DownloadProgressStatus::FAILED_GAMEID: m_DownloadProgressData.status = DownloadProgressStatus::NONE; break; case DownloadProgressStatus::LISTING: case DownloadProgressStatus::FAILED_LISTING: m_DownloadProgressData.status = DownloadProgressStatus::READY; break; case DownloadProgressStatus::DOWNLOADING: case DownloadProgressStatus::FAILED_DOWNLOADING: m_DownloadProgressData.status = DownloadProgressStatus::LISTED; DeleteDownloadedFile(); break; default: break; } } bool ModIo::AdvanceRequest(const ScriptInterface& scriptInterface) { // If the request was cancelled, stop trying to advance it if (m_DownloadProgressData.status != DownloadProgressStatus::GAMEID && m_DownloadProgressData.status != DownloadProgressStatus::LISTING && m_DownloadProgressData.status != DownloadProgressStatus::DOWNLOADING) return true; int stillRunning; CURLMcode err = curl_multi_perform(m_CurlMulti, &stillRunning); if (err != CURLM_OK) { std::string error = fmt::sprintf( g_L10n.Translate("Asynchronous download failure: %s, %s."), curl_multi_strerror(err), m_ErrorBuffer); TearDownRequest(); if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID) m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING) m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) { m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; DeleteDownloadedFile(); } m_DownloadProgressData.error = error; return true; } CURLMsg* message; do { int in_queue; message = curl_multi_info_read(m_CurlMulti, &in_queue); if (!message) continue; if (message->data.result == CURLE_OK) continue; std::string error = fmt::sprintf( g_L10n.Translate("Download failure. Server response: %s; %s."), curl_easy_strerror(message->data.result), m_ErrorBuffer); TearDownRequest(); if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID) m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING) m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) { m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; DeleteDownloadedFile(); } m_DownloadProgressData.error = error; return true; } while (message); if (stillRunning) return false; // Download finished. TearDownRequest(); // Perform parsing and/or checks std::string error; switch (m_DownloadProgressData.status) { case DownloadProgressStatus::GAMEID: if (!ParseGameId(scriptInterface, error)) { m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; m_DownloadProgressData.error = error; break; } m_DownloadProgressData.status = DownloadProgressStatus::READY; break; case DownloadProgressStatus::LISTING: if (!ParseMods(scriptInterface, error)) { m_ModData.clear(); // Failed during parsing, make sure we don't provide partial data m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; m_DownloadProgressData.error = error; break; } m_DownloadProgressData.status = DownloadProgressStatus::LISTED; break; case DownloadProgressStatus::DOWNLOADING: if (!VerifyDownloadedFile(error)) { m_DownloadProgressData.status = DownloadProgressStatus::FAILED_FILECHECK; m_DownloadProgressData.error = error; DeleteDownloadedFile(); break; } m_DownloadProgressData.status = DownloadProgressStatus::SUCCESS; { Paths paths(g_CmdLineArgs); CModInstaller installer(paths.UserData() / "mods", paths.Cache()); - installer.Install(m_DownloadFilePath, g_ScriptContext, false); + CModInstaller::ModInstallationResult result = installer.Install(m_DownloadFilePath, g_ScriptContext, false); + if (result != CModInstaller::ModInstallationResult::SUCCESS) + LOGERROR("Failed to install '%s'", m_DownloadFilePath.string8().c_str()); g_Mods.UpdateAvailableMods(scriptInterface); } break; default: break; } return true; } bool ModIo::ParseGameId(const ScriptInterface& scriptInterface, std::string& err) { int id = -1; bool ret = ParseGameIdResponse(scriptInterface, m_ResponseData, id, err); m_ResponseData.clear(); if (!ret) return false; m_GameId = "/" + std::to_string(id); return true; } bool ModIo::ParseMods(const ScriptInterface& scriptInterface, std::string& err) { bool ret = ParseModsResponse(scriptInterface, m_ResponseData, m_ModData, m_pk, err); m_ResponseData.clear(); return ret; } void ModIo::DeleteDownloadedFile() { if (wunlink(m_DownloadFilePath) != 0) LOGERROR("Failed to delete temporary file."); m_DownloadFilePath = OsPath(); } bool ModIo::VerifyDownloadedFile(std::string& err) { // Verify filesize, as a first basic download check. { u64 filesize = std::stoull(m_ModData[m_DownloadModID].properties.at("filesize")); if (filesize != FileSize(m_DownloadFilePath)) { err = g_L10n.Translate("Mismatched filesize."); return false; } } ENSURE(m_CallbackData); // MD5 (because upstream provides it) // Just used to make sure there was no obvious corruption during transfer. { u8 digest[MD5::DIGESTSIZE]; m_CallbackData->md5.Final(digest); std::string md5digest = Hexify(digest, MD5::DIGESTSIZE); if (m_ModData[m_DownloadModID].properties.at("filehash_md5") != md5digest) { err = fmt::sprintf( g_L10n.Translate("Invalid file. Expected md5 %s, got %s."), m_ModData[m_DownloadModID].properties.at("filehash_md5").c_str(), md5digest); return false; } } // Verify file signature. // Used to make sure that the downloaded file was actually checked and signed // by Wildfire Games. And has not been tampered with by the API provider, or the CDN. unsigned char hash_fin[crypto_generichash_BYTES_MAX] = {}; ENSURE(m_CallbackData->hash_state); if (crypto_generichash_final(m_CallbackData->hash_state, hash_fin, sizeof hash_fin) != 0) { err = g_L10n.Translate("Failed to compute final hash."); return false; } if (crypto_sign_verify_detached(m_ModData[m_DownloadModID].sig.sig, hash_fin, sizeof hash_fin, m_pk.pk) != 0) { err = g_L10n.Translate("Failed to verify signature."); return false; } return true; } #define FAIL(...) STMT(err = fmt::sprintf(__VA_ARGS__); CLEANUP(); return false;) /** * Parses the current content of m_ResponseData to extract m_GameId. * * The JSON data is expected to look like * { "data": [{"id": 42, ...}, ...], ... } * where we are only interested in the value of the id property. * * @returns true iff it successfully parsed the id. */ bool ModIo::ParseGameIdResponse(const ScriptInterface& scriptInterface, const std::string& responseData, int& id, std::string& err) { #define CLEANUP() id = -1; ScriptRequest rq(scriptInterface); JS::RootedValue gameResponse(rq.cx); if (!Script::ParseJSON(rq, responseData, &gameResponse)) FAIL("Failed to parse response as JSON."); if (!gameResponse.isObject()) FAIL("response not an object."); JS::RootedObject gameResponseObj(rq.cx, gameResponse.toObjectOrNull()); JS::RootedValue dataVal(rq.cx); if (!JS_GetProperty(rq.cx, gameResponseObj, "data", &dataVal)) FAIL("data property not in response."); // [{"id": 42, ...}, ...] if (!dataVal.isObject()) FAIL("data property not an object."); JS::RootedObject data(rq.cx, dataVal.toObjectOrNull()); u32 length; bool isArray; if (!JS::IsArrayObject(rq.cx, data, &isArray) || !isArray || !JS::GetArrayLength(rq.cx, data, &length) || !length) FAIL("data property not an array with at least one element."); // {"id": 42, ...} JS::RootedValue first(rq.cx); if (!JS_GetElement(rq.cx, data, 0, &first)) FAIL("Couldn't get first element."); if (!first.isObject()) FAIL("First element not an object."); JS::RootedObject firstObj(rq.cx, &first.toObject()); bool hasIdProperty; if (!JS_HasProperty(rq.cx, firstObj, "id", &hasIdProperty) || !hasIdProperty) FAIL("No id property in first element."); JS::RootedValue idProperty(rq.cx); ENSURE(JS_GetProperty(rq.cx, firstObj, "id", &idProperty)); // Make sure the property is not set to something that could be converted to a bogus value // TODO: We should be able to convert JS::Values to C++ variables in a way that actually // fails when types do not match (see https://trac.wildfiregames.com/ticket/5128). if (!idProperty.isNumber()) FAIL("id property not a number."); id = -1; if (!Script::FromJSVal(rq, idProperty, id) || id <= 0) FAIL("Invalid id."); return true; #undef CLEANUP } /** * Parses the current content of m_ResponseData into m_ModData. * * The JSON data is expected to look like * { data: [modobj1, modobj2, ...], ... (including result_count) } * where modobjN has the following structure * { homepage_url: "url", name: "displayname", nameid: "short-non-whitespace-name", * summary: "short desc.", modfile: { version: "1.2.4", filename: "asdf.zip", * filehash: { md5: "deadbeef" }, filesize: 1234, download: { binary_url: "someurl", ... } }, ... }. * Only the listed properties are of interest to consumers, and we flatten * the modfile structure as that simplifies handling and there are no conflicts. */ bool ModIo::ParseModsResponse(const ScriptInterface& scriptInterface, const std::string& responseData, std::vector& modData, const PKStruct& pk, std::string& err) { // Make sure we don't end up passing partial results back #define CLEANUP() modData.clear(); ScriptRequest rq(scriptInterface); JS::RootedValue modResponse(rq.cx); if (!Script::ParseJSON(rq, responseData, &modResponse)) FAIL("Failed to parse response as JSON."); if (!modResponse.isObject()) FAIL("response not an object."); JS::RootedObject modResponseObj(rq.cx, modResponse.toObjectOrNull()); JS::RootedValue dataVal(rq.cx); if (!JS_GetProperty(rq.cx, modResponseObj, "data", &dataVal)) FAIL("data property not in response."); // [modobj1, modobj2, ... ] if (!dataVal.isObject()) FAIL("data property not an object."); JS::RootedObject rData(rq.cx, dataVal.toObjectOrNull()); u32 length; bool isArray; if (!JS::IsArrayObject(rq.cx, rData, &isArray) || !isArray || !JS::GetArrayLength(rq.cx, rData, &length) || !length) FAIL("data property not an array with at least one element."); modData.clear(); modData.reserve(length); #define INVALIDATE_DATA_AND_CONTINUE(...) \ {\ data.properties.emplace("invalid", "true");\ data.properties.emplace("error", __VA_ARGS__);\ continue;\ } for (u32 i = 0; i < length; ++i) { modData.emplace_back(); ModIoModData& data = modData.back(); JS::RootedValue el(rq.cx); if (!JS_GetElement(rq.cx, rData, i, &el) || !el.isObject()) INVALIDATE_DATA_AND_CONTINUE("Failed to get array element object.") bool ok = true; std::string copyStringError; #define COPY_STRINGS_ELSE_CONTINUE(prefix, obj, ...) \ for (const std::string& prop : { __VA_ARGS__ }) \ { \ std::string val; \ if (!Script::FromJSProperty(rq, obj, prop.c_str(), val, true)) \ { \ ok = false; \ copyStringError = "Failed to get " + prop + " from " + #obj + "."; \ break; \ }\ data.properties.emplace(prefix+prop, val); \ } \ if (!ok) \ INVALIDATE_DATA_AND_CONTINUE(copyStringError); // TODO: Currently the homepage_url field does not contain a non-null value for any entry. COPY_STRINGS_ELSE_CONTINUE("", el, "name", "name_id", "summary") // Now copy over the modfile part, but without the pointless substructure JS::RootedObject elObj(rq.cx, el.toObjectOrNull()); JS::RootedValue modFile(rq.cx); if (!JS_GetProperty(rq.cx, elObj, "modfile", &modFile)) INVALIDATE_DATA_AND_CONTINUE("Failed to get modfile data."); if (!modFile.isObject()) INVALIDATE_DATA_AND_CONTINUE("modfile not an object."); COPY_STRINGS_ELSE_CONTINUE("", modFile, "version", "filesize"); JS::RootedObject modFileObj(rq.cx, modFile.toObjectOrNull()); JS::RootedValue filehash(rq.cx); if (!JS_GetProperty(rq.cx, modFileObj, "filehash", &filehash)) INVALIDATE_DATA_AND_CONTINUE("Failed to get filehash data."); COPY_STRINGS_ELSE_CONTINUE("filehash_", filehash, "md5"); JS::RootedValue download(rq.cx); if (!JS_GetProperty(rq.cx, modFileObj, "download", &download)) INVALIDATE_DATA_AND_CONTINUE("Failed to get download data."); COPY_STRINGS_ELSE_CONTINUE("", download, "binary_url"); // Parse metadata_blob (sig+deps) std::string metadata_blob; if (!Script::FromJSProperty(rq, modFile, "metadata_blob", metadata_blob, true)) INVALIDATE_DATA_AND_CONTINUE("Failed to get metadata_blob from modFile."); JS::RootedValue metadata(rq.cx); if (!Script::ParseJSON(rq, metadata_blob, &metadata)) INVALIDATE_DATA_AND_CONTINUE("Failed to parse metadata_blob as JSON."); if (!metadata.isObject()) INVALIDATE_DATA_AND_CONTINUE("metadata_blob is not decoded as an object."); if (!Script::FromJSProperty(rq, metadata, "dependencies", data.dependencies, true)) INVALIDATE_DATA_AND_CONTINUE("Failed to get dependencies from metadata_blob."); std::vector minisigs; if (!Script::FromJSProperty(rq, metadata, "minisigs", minisigs, true)) INVALIDATE_DATA_AND_CONTINUE("Failed to get minisigs from metadata_blob."); // Check we did find a valid matching signature. std::string signatureParsingErr; if (!ParseSignature(minisigs, data.sig, pk, signatureParsingErr)) INVALIDATE_DATA_AND_CONTINUE(signatureParsingErr); #undef COPY_STRINGS_ELSE_CONTINUE #undef INVALIDATE_DATA_AND_CONTINUE } return true; #undef CLEANUP } /** * Parse signatures to find one that matches the public key, and has a valid global signature. * Returns true and sets @param sig to the valid matching signature. */ bool ModIo::ParseSignature(const std::vector& minisigs, SigStruct& sig, const PKStruct& pk, std::string& err) { #define CLEANUP() sig = {}; for (const std::string& file_sig : minisigs) { // Format of a .minisig file (created using minisign(1) with -SHm file.zip) // untrusted comment: .*\nb64sign_of_file\ntrusted comment: .*\nb64sign_of_sign_of_file_and_trusted_comment std::vector sig_lines; boost::split(sig_lines, file_sig, boost::is_any_of("\n")); if (sig_lines.size() < 4) FAIL("Invalid (too short) sig."); // Verify that both the untrusted comment and the trusted comment start with the correct prefix // because that is easy. const std::string untrusted_comment_prefix = "untrusted comment: "; const std::string trusted_comment_prefix = "trusted comment: "; if (!boost::algorithm::starts_with(sig_lines[0], untrusted_comment_prefix)) FAIL("Malformed untrusted comment."); if (!boost::algorithm::starts_with(sig_lines[2], trusted_comment_prefix)) FAIL("Malformed trusted comment."); // We only _really_ care about the second line which is the signature of the file (b64-encoded) // Also handling the other signature is nice, but not really required. const std::string& msg_sig = sig_lines[1]; size_t bin_len = 0; if (sodium_base642bin((unsigned char*)&sig, sizeof sig, msg_sig.c_str(), msg_sig.size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof sig) FAIL("Failed to decode base64 sig."); cassert(sizeof pk.keynum == sizeof sig.keynum); if (memcmp(&pk.keynum, &sig.keynum, sizeof sig.keynum) != 0) continue; // mismatched key, try another one if (memcmp(&sig.sig_alg, "ED", 2) != 0) FAIL("Only hashed minisign signatures are supported."); // Signature matches our public key // Now verify the global signature (sig || trusted_comment) unsigned char global_sig[crypto_sign_BYTES]; if (sodium_base642bin(global_sig, sizeof global_sig, sig_lines[3].c_str(), sig_lines[3].size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof global_sig) FAIL("Failed to decode base64 global_sig."); const std::string trusted_comment = sig_lines[2].substr(trusted_comment_prefix.size()); unsigned char* sig_and_trusted_comment = (unsigned char*)sodium_malloc((sizeof sig.sig) + trusted_comment.size()); if (!sig_and_trusted_comment) FAIL("sodium_malloc failed."); memcpy(sig_and_trusted_comment, sig.sig, sizeof sig.sig); memcpy(sig_and_trusted_comment + sizeof sig.sig, trusted_comment.data(), trusted_comment.size()); if (crypto_sign_verify_detached(global_sig, sig_and_trusted_comment, (sizeof sig.sig) + trusted_comment.size(), pk.pk) != 0) { err = "Failed to verify global signature."; sodium_free(sig_and_trusted_comment); return false; } sodium_free(sig_and_trusted_comment); // Valid global sig, and the keynum matches the real one return true; } return false; #undef CLEANUP } #undef FAIL