LCOV - code coverage report
Current view: top level - source/ps - ModIo.cpp (source / functions) Hit Total Coverage
Test: 0 A.D. test coverage report Lines: 121 417 29.0 %
Date: 2023-01-19 00:18:29 Functions: 5 24 20.8 %

          Line data    Source code
       1             : /* Copyright (C) 2021 Wildfire Games.
       2             :  *
       3             :  * Permission is hereby granted, free of charge, to any person obtaining
       4             :  * a copy of this software and associated documentation files (the
       5             :  * "Software"), to deal in the Software without restriction, including
       6             :  * without limitation the rights to use, copy, modify, merge, publish,
       7             :  * distribute, sublicense, and/or sell copies of the Software, and to
       8             :  * permit persons to whom the Software is furnished to do so, subject to
       9             :  * the following conditions:
      10             :  *
      11             :  * The above copyright notice and this permission notice shall be included
      12             :  * in all copies or substantial portions of the Software.
      13             :  *
      14             :  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
      15             :  * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      16             :  * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
      17             :  * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      18             :  * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
      19             :  * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
      20             :  * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      21             :  */
      22             : 
      23             : #include "precompiled.h"
      24             : 
      25             : #include "ModIo.h"
      26             : 
      27             : #include "i18n/L10n.h"
      28             : #include "lib/file/file_system.h"
      29             : #include "lib/sysdep/filesystem.h"
      30             : #include "lib/sysdep/sysdep.h"
      31             : #include "maths/MD5.h"
      32             : #include "ps/CLogger.h"
      33             : #include "ps/ConfigDB.h"
      34             : #include "ps/GameSetup/CmdLineArgs.h"
      35             : #include "ps/GameSetup/Paths.h"
      36             : #include "ps/Mod.h"
      37             : #include "ps/ModInstaller.h"
      38             : #include "ps/Util.h"
      39             : #include "scriptinterface/ScriptConversions.h"
      40             : #include "scriptinterface/ScriptContext.h"
      41             : #include "scriptinterface/ScriptRequest.h"
      42             : #include "scriptinterface/JSON.h"
      43             : 
      44             : #include <boost/algorithm/string.hpp>
      45             : #include <iomanip>
      46             : 
      47             : ModIo* g_ModIo = nullptr;
      48             : 
      49             : struct DownloadCallbackData
      50             : {
      51             :     DownloadCallbackData()
      52             :         : fp(nullptr), md5(), hash_state(nullptr)
      53             :     {
      54             :     }
      55             : 
      56           0 :     DownloadCallbackData(FILE* _fp)
      57           0 :         : fp(_fp), md5()
      58             :     {
      59           0 :         hash_state = static_cast<crypto_generichash_state*>(
      60           0 :             sodium_malloc(crypto_generichash_statebytes()));
      61             : 
      62           0 :         ENSURE(hash_state);
      63             : 
      64           0 :         crypto_generichash_init(hash_state, nullptr, 0U, crypto_generichash_BYTES_MAX);
      65           0 :     }
      66             : 
      67           0 :     ~DownloadCallbackData()
      68           0 :     {
      69           0 :         if (hash_state)
      70           0 :             sodium_free(hash_state);
      71           0 :     }
      72             : 
      73             :     FILE* fp;
      74             :     MD5 md5;
      75             :     crypto_generichash_state* hash_state;
      76             : };
      77             : 
      78           0 : ModIo::ModIo()
      79           0 :     : m_GamesRequest("/games"), m_CallbackData(nullptr)
      80             : {
      81             :     // Get config values from the default namespace.
      82             :     // This can be overridden on the command line.
      83             :     //
      84             :     // We do this so a malicious mod cannot change the base url and
      85             :     // get the user to make connections to someone else's endpoint.
      86             :     // If another user of the engine wants to provide different values
      87             :     // here, while still using the same engine version, they can just
      88             :     // provide some shortcut/script that sets these using command line
      89             :     // parameters.
      90           0 :     std::string pk_str;
      91           0 :     g_ConfigDB.GetValue(CFG_DEFAULT, "modio.public_key", pk_str);
      92           0 :     g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.baseurl", m_BaseUrl);
      93             :     {
      94           0 :         std::string api_key;
      95           0 :         g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.api_key", api_key);
      96           0 :         m_ApiKey = "api_key=" + api_key;
      97             :     }
      98             :     {
      99           0 :         std::string nameid;
     100           0 :         g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.name_id", nameid);
     101           0 :         m_IdQuery = "name_id="+nameid;
     102             :     }
     103             : 
     104           0 :     m_CurlMulti = curl_multi_init();
     105           0 :     ENSURE(m_CurlMulti);
     106             : 
     107           0 :     m_Curl = curl_easy_init();
     108           0 :     ENSURE(m_Curl);
     109             : 
     110             :     // Capture error messages
     111           0 :     curl_easy_setopt(m_Curl, CURLOPT_ERRORBUFFER, m_ErrorBuffer);
     112             : 
     113             :     // Fail if the server did
     114           0 :     curl_easy_setopt(m_Curl, CURLOPT_FAILONERROR, 1L);
     115             : 
     116             :     // Disable signal handlers (required for multithreaded applications)
     117           0 :     curl_easy_setopt(m_Curl, CURLOPT_NOSIGNAL, 1L);
     118             : 
     119             :     // To minimise security risks, don't support redirects (except for file
     120             :     // downloads, for which this setting will be enabled).
     121           0 :     curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L);
     122             : 
     123             :     // For file downloads, one redirect seems plenty for a CDN serving the files.
     124           0 :     curl_easy_setopt(m_Curl, CURLOPT_MAXREDIRS, 1L);
     125             : 
     126           0 :     m_Headers = NULL;
     127           0 :     std::string ua = "User-Agent: pyrogenesis ";
     128           0 :     ua += curl_version();
     129           0 :     ua += " (https://play0ad.com/)";
     130           0 :     m_Headers = curl_slist_append(m_Headers, ua.c_str());
     131           0 :     curl_easy_setopt(m_Curl, CURLOPT_HTTPHEADER, m_Headers);
     132             : 
     133           0 :     if (sodium_init() < 0)
     134           0 :         ENSURE(0 && "Failed to initialize libsodium.");
     135             : 
     136           0 :     size_t bin_len = 0;
     137           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)
     138           0 :         ENSURE(0 && "Failed to decode base64 public key. Please fix your configuration or mod.io will be unusable.");
     139           0 : }
     140             : 
     141           0 : ModIo::~ModIo()
     142             : {
     143             :     // Clean things up to avoid unpleasant surprises,
     144             :     // and delete the temporary file if any.
     145           0 :     TearDownRequest();
     146           0 :     if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
     147           0 :         DeleteDownloadedFile();
     148             : 
     149           0 :     curl_slist_free_all(m_Headers);
     150           0 :     curl_easy_cleanup(m_Curl);
     151           0 :     curl_multi_cleanup(m_CurlMulti);
     152             : 
     153           0 :     delete m_CallbackData;
     154           0 : }
     155             : 
     156           0 : size_t ModIo::ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp)
     157             : {
     158           0 :     ModIo* self = static_cast<ModIo*>(userp);
     159             : 
     160           0 :     self->m_ResponseData += std::string((char*)buffer, (char*)buffer+size*nmemb);
     161             : 
     162           0 :     return size*nmemb;
     163             : }
     164             : 
     165           0 : size_t ModIo::DownloadCallback(void* buffer, size_t size, size_t nmemb, void* userp)
     166             : {
     167           0 :     DownloadCallbackData* data = static_cast<DownloadCallbackData*>(userp);
     168           0 :     if (!data->fp)
     169           0 :         return 0;
     170             : 
     171           0 :     size_t len = fwrite(buffer, size, nmemb, data->fp);
     172             : 
     173             :     // Only update the hash with data we actually managed to write.
     174             :     // In case we did not write all of it we will fail the download,
     175             :     // but we do not want to have a possibly valid hash in that case.
     176           0 :     size_t written = len*size;
     177             : 
     178           0 :     data->md5.Update(static_cast<const u8*>(buffer), written);
     179             : 
     180           0 :     ENSURE(data->hash_state);
     181             : 
     182           0 :     crypto_generichash_update(data->hash_state, static_cast<const u8*>(buffer), written);
     183             : 
     184           0 :     return written;
     185             : }
     186             : 
     187           0 : int ModIo::DownloadProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t UNUSED(ultotal), curl_off_t UNUSED(ulnow))
     188             : {
     189           0 :     DownloadProgressData* data = static_cast<DownloadProgressData*>(clientp);
     190             : 
     191             :     // If we got more data than curl expected, something is very wrong, abort.
     192           0 :     if (dltotal != 0 && dlnow > dltotal)
     193           0 :         return 1;
     194             : 
     195           0 :     data->progress = dltotal == 0 ? 0 : static_cast<double>(dlnow) / static_cast<double>(dltotal);
     196             : 
     197           0 :     return 0;
     198             : }
     199             : 
     200           0 : CURLMcode ModIo::SetupRequest(const std::string& url, bool fileDownload)
     201             : {
     202           0 :     if (fileDownload)
     203             :     {
     204             :         // The download link will most likely redirect elsewhere, so allow that.
     205             :         // We verify the validity of the file later.
     206           0 :         curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 1L);
     207             :         // Enable the progress meter
     208           0 :         curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 0L);
     209             : 
     210             :         // Set IO callbacks
     211           0 :         curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, DownloadCallback);
     212           0 :         curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, static_cast<void*>(m_CallbackData));
     213           0 :         curl_easy_setopt(m_Curl, CURLOPT_XFERINFOFUNCTION, DownloadProgressCallback);
     214           0 :         curl_easy_setopt(m_Curl, CURLOPT_XFERINFODATA, static_cast<void*>(&m_DownloadProgressData));
     215             : 
     216             :         // Initialize the progress counter
     217           0 :         m_DownloadProgressData.progress = 0;
     218             :     }
     219             :     else
     220             :     {
     221             :         // To minimise security risks, don't support redirects
     222           0 :         curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L);
     223             :         // Disable the progress meter
     224           0 :         curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 1L);
     225             : 
     226             :         // Set IO callbacks
     227           0 :         curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, ReceiveCallback);
     228           0 :         curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, this);
     229             :     }
     230             : 
     231           0 :     m_ErrorBuffer[0] = '\0';
     232           0 :     curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str());
     233           0 :     return curl_multi_add_handle(m_CurlMulti, m_Curl);
     234             : }
     235             : 
     236           0 : void ModIo::TearDownRequest()
     237             : {
     238           0 :     ENSURE(curl_multi_remove_handle(m_CurlMulti, m_Curl) == CURLM_OK);
     239             : 
     240           0 :     if (m_CallbackData)
     241             :     {
     242           0 :         if (m_CallbackData->fp)
     243           0 :             fclose(m_CallbackData->fp);
     244           0 :         m_CallbackData->fp = nullptr;
     245             :     }
     246           0 : }
     247             : 
     248           0 : void ModIo::StartGetGameId()
     249             : {
     250             :     // Don't start such a request during active downloads.
     251           0 :     if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID ||
     252           0 :         m_DownloadProgressData.status == DownloadProgressStatus::LISTING ||
     253           0 :         m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
     254           0 :         return;
     255             : 
     256           0 :     m_GameId.clear();
     257             : 
     258           0 :     CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+"?"+m_ApiKey+"&"+m_IdQuery, false);
     259           0 :     if (err != CURLM_OK)
     260             :     {
     261           0 :         TearDownRequest();
     262           0 :         m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
     263           0 :         m_DownloadProgressData.error = fmt::sprintf(
     264           0 :             g_L10n.Translate("Failure while starting querying for game id. Error: %s; %s."),
     265           0 :             curl_multi_strerror(err), m_ErrorBuffer);
     266           0 :         return;
     267             :     }
     268             : 
     269           0 :     m_DownloadProgressData.status = DownloadProgressStatus::GAMEID;
     270             : }
     271             : 
     272           0 : void ModIo::StartListMods()
     273             : {
     274             :     // Don't start such a request during active downloads.
     275           0 :     if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID ||
     276           0 :         m_DownloadProgressData.status == DownloadProgressStatus::LISTING ||
     277           0 :         m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
     278           0 :         return;
     279             : 
     280           0 :     m_ModData.clear();
     281             : 
     282           0 :     if (m_GameId.empty())
     283             :     {
     284           0 :         LOGERROR("Game ID not fetched from mod.io. Call StartGetGameId first and wait for it to finish.");
     285           0 :         return;
     286             :     }
     287             : 
     288           0 :     CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+m_GameId+"/mods?"+m_ApiKey, false);
     289           0 :     if (err != CURLM_OK)
     290             :     {
     291           0 :         TearDownRequest();
     292           0 :         m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
     293           0 :         m_DownloadProgressData.error = fmt::sprintf(
     294           0 :             g_L10n.Translate("Failure while starting querying for mods. Error: %s; %s."),
     295           0 :             curl_multi_strerror(err), m_ErrorBuffer);
     296           0 :         return;
     297             :     }
     298             : 
     299           0 :     m_DownloadProgressData.status = DownloadProgressStatus::LISTING;
     300             : }
     301             : 
     302           0 : void ModIo::StartDownloadMod(u32 idx)
     303             : {
     304             :     // Don't start such a request during active downloads.
     305           0 :     if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID ||
     306           0 :         m_DownloadProgressData.status == DownloadProgressStatus::LISTING ||
     307           0 :         m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
     308           0 :         return;
     309             : 
     310           0 :     if (idx >= m_ModData.size())
     311           0 :         return;
     312             : 
     313           0 :     const Paths paths(g_CmdLineArgs);
     314           0 :     const OsPath modUserPath = paths.UserData()/"mods";
     315           0 :     const OsPath modPath = modUserPath/m_ModData[idx].properties["name_id"];
     316           0 :     if (!DirectoryExists(modPath) && INFO::OK != CreateDirectories(modPath, 0700, false))
     317             :     {
     318           0 :         m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
     319           0 :         m_DownloadProgressData.error = fmt::sprintf(
     320           0 :             g_L10n.Translate("Could not create mod directory: %s."), modPath.string8());
     321           0 :         return;
     322             :     }
     323             : 
     324             :     // Name the file after the name_id, since using the filename would mean that
     325             :     // we could end up with multiple zip files in the folder that might not work
     326             :     // as expected for a user (since a later version might remove some files
     327             :     // that aren't compatible anymore with the engine version).
     328             :     // So we ignore the filename provided by the API and assume that we do not
     329             :     // care about handling update.zip files. If that is the case we would need
     330             :     // a way to find out what files are required by the current one and which
     331             :     // should be removed for everything to work. This seems to be too complicated
     332             :     // so we just do not support that usage.
     333             :     // NOTE: We do save the file under a slightly different name from the final
     334             :     //       one, to ensure that in case a download aborts and the file stays
     335             :     //       around, the game will not attempt to open the file which has not
     336             :     //       been verified.
     337           0 :     m_DownloadFilePath = modPath/(m_ModData[idx].properties["name_id"]+".zip.temp");
     338             : 
     339           0 :     delete m_CallbackData;
     340           0 :     m_CallbackData = new DownloadCallbackData(sys_OpenFile(m_DownloadFilePath, "wb"));
     341           0 :     if (!m_CallbackData->fp)
     342             :     {
     343           0 :         m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
     344           0 :         m_DownloadProgressData.error = fmt::sprintf(
     345           0 :             g_L10n.Translate("Could not open temporary file for mod download: %s."), m_DownloadFilePath.string8());
     346           0 :         return;
     347             :     }
     348             : 
     349           0 :     CURLMcode err = SetupRequest(m_ModData[idx].properties["binary_url"], true);
     350           0 :     if (err != CURLM_OK)
     351             :     {
     352           0 :         TearDownRequest();
     353           0 :         m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
     354           0 :         m_DownloadProgressData.error = fmt::sprintf(
     355           0 :             g_L10n.Translate("Failed to start the download. Error: %s; %s."), curl_multi_strerror(err), m_ErrorBuffer);
     356           0 :         return;
     357             :     }
     358             : 
     359           0 :     m_DownloadModID = idx;
     360           0 :     m_DownloadProgressData.status = DownloadProgressStatus::DOWNLOADING;
     361             : }
     362             : 
     363           0 : void ModIo::CancelRequest()
     364             : {
     365           0 :     TearDownRequest();
     366             : 
     367           0 :     switch (m_DownloadProgressData.status)
     368             :     {
     369           0 :     case DownloadProgressStatus::GAMEID:
     370             :     case DownloadProgressStatus::FAILED_GAMEID:
     371           0 :         m_DownloadProgressData.status = DownloadProgressStatus::NONE;
     372           0 :         break;
     373           0 :     case DownloadProgressStatus::LISTING:
     374             :     case DownloadProgressStatus::FAILED_LISTING:
     375           0 :         m_DownloadProgressData.status = DownloadProgressStatus::READY;
     376           0 :         break;
     377           0 :     case DownloadProgressStatus::DOWNLOADING:
     378             :     case DownloadProgressStatus::FAILED_DOWNLOADING:
     379           0 :         m_DownloadProgressData.status = DownloadProgressStatus::LISTED;
     380           0 :         DeleteDownloadedFile();
     381           0 :         break;
     382           0 :     default:
     383           0 :         break;
     384             :     }
     385           0 : }
     386             : 
     387           0 : bool ModIo::AdvanceRequest(const ScriptInterface& scriptInterface)
     388             : {
     389             :     // If the request was cancelled, stop trying to advance it
     390           0 :     if (m_DownloadProgressData.status != DownloadProgressStatus::GAMEID &&
     391           0 :         m_DownloadProgressData.status != DownloadProgressStatus::LISTING &&
     392           0 :         m_DownloadProgressData.status != DownloadProgressStatus::DOWNLOADING)
     393           0 :         return true;
     394             : 
     395             :     int stillRunning;
     396           0 :     CURLMcode err = curl_multi_perform(m_CurlMulti, &stillRunning);
     397           0 :     if (err != CURLM_OK)
     398             :     {
     399             :         std::string error = fmt::sprintf(
     400           0 :             g_L10n.Translate("Asynchronous download failure: %s, %s."), curl_multi_strerror(err), m_ErrorBuffer);
     401           0 :         TearDownRequest();
     402           0 :         if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID)
     403           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
     404           0 :         else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING)
     405           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
     406           0 :         else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
     407             :         {
     408           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
     409           0 :             DeleteDownloadedFile();
     410             :         }
     411           0 :         m_DownloadProgressData.error = error;
     412           0 :         return true;
     413             :     }
     414             : 
     415             :     CURLMsg* message;
     416           0 :     do
     417             :     {
     418             :         int in_queue;
     419           0 :         message = curl_multi_info_read(m_CurlMulti, &in_queue);
     420             : 
     421           0 :         if (!message)
     422           0 :             continue;
     423             : 
     424           0 :         if (message->data.result == CURLE_OK)
     425           0 :             continue;
     426             : 
     427             :         std::string error = fmt::sprintf(
     428           0 :             g_L10n.Translate("Download failure. Server response: %s; %s."), curl_easy_strerror(message->data.result), m_ErrorBuffer);
     429           0 :         TearDownRequest();
     430           0 :         if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID)
     431           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
     432           0 :         else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING)
     433           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
     434           0 :         else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
     435             :         {
     436           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
     437           0 :             DeleteDownloadedFile();
     438             :         }
     439           0 :         m_DownloadProgressData.error = error;
     440           0 :         return true;
     441           0 :     } while (message);
     442             : 
     443           0 :     if (stillRunning)
     444           0 :         return false;
     445             : 
     446             :     // Download finished.
     447           0 :     TearDownRequest();
     448             : 
     449             :     // Perform parsing and/or checks
     450           0 :     std::string error;
     451           0 :     switch (m_DownloadProgressData.status)
     452             :     {
     453           0 :     case DownloadProgressStatus::GAMEID:
     454           0 :         if (!ParseGameId(scriptInterface, error))
     455             :         {
     456           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
     457           0 :             m_DownloadProgressData.error = error;
     458           0 :             break;
     459             :         }
     460             : 
     461           0 :         m_DownloadProgressData.status = DownloadProgressStatus::READY;
     462           0 :         break;
     463           0 :     case DownloadProgressStatus::LISTING:
     464           0 :         if (!ParseMods(scriptInterface, error))
     465             :         {
     466           0 :             m_ModData.clear(); // Failed during parsing, make sure we don't provide partial data
     467           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
     468           0 :             m_DownloadProgressData.error = error;
     469           0 :             break;
     470             :         }
     471             : 
     472           0 :         m_DownloadProgressData.status = DownloadProgressStatus::LISTED;
     473           0 :         break;
     474           0 :     case DownloadProgressStatus::DOWNLOADING:
     475           0 :         if (!VerifyDownloadedFile(error))
     476             :         {
     477           0 :             m_DownloadProgressData.status = DownloadProgressStatus::FAILED_FILECHECK;
     478           0 :             m_DownloadProgressData.error = error;
     479           0 :             DeleteDownloadedFile();
     480           0 :             break;
     481             :         }
     482             : 
     483           0 :         m_DownloadProgressData.status = DownloadProgressStatus::SUCCESS;
     484             : 
     485             :         {
     486           0 :             Paths paths(g_CmdLineArgs);
     487           0 :             CModInstaller installer(paths.UserData() / "mods", paths.Cache());
     488           0 :             CModInstaller::ModInstallationResult result = installer.Install(m_DownloadFilePath, g_ScriptContext, false);
     489           0 :             if (result != CModInstaller::ModInstallationResult::SUCCESS)
     490           0 :                 LOGERROR("Failed to install '%s'", m_DownloadFilePath.string8().c_str());
     491           0 :             g_Mods.UpdateAvailableMods(scriptInterface);
     492             :         }
     493           0 :         break;
     494           0 :     default:
     495           0 :         break;
     496             :     }
     497             : 
     498           0 :     return true;
     499             : }
     500             : 
     501           0 : bool ModIo::ParseGameId(const ScriptInterface& scriptInterface, std::string& err)
     502             : {
     503           0 :     int id = -1;
     504           0 :     bool ret = ParseGameIdResponse(scriptInterface, m_ResponseData, id, err);
     505           0 :     m_ResponseData.clear();
     506           0 :     if (!ret)
     507           0 :         return false;
     508             : 
     509           0 :     m_GameId = "/" + std::to_string(id);
     510           0 :     return true;
     511             : }
     512             : 
     513           0 : bool ModIo::ParseMods(const ScriptInterface& scriptInterface, std::string& err)
     514             : {
     515           0 :     bool ret = ParseModsResponse(scriptInterface, m_ResponseData, m_ModData, m_pk, err);
     516           0 :     m_ResponseData.clear();
     517           0 :     return ret;
     518             : }
     519             : 
     520           0 : void ModIo::DeleteDownloadedFile()
     521             : {
     522           0 :     if (wunlink(m_DownloadFilePath) != 0)
     523           0 :         LOGERROR("Failed to delete temporary file.");
     524           0 :     m_DownloadFilePath = OsPath();
     525           0 : }
     526             : 
     527           0 : bool ModIo::VerifyDownloadedFile(std::string& err)
     528             : {
     529             :     // Verify filesize, as a first basic download check.
     530             :     {
     531           0 :         u64 filesize = std::stoull(m_ModData[m_DownloadModID].properties.at("filesize"));
     532           0 :         if (filesize != FileSize(m_DownloadFilePath))
     533             :         {
     534           0 :             err = g_L10n.Translate("Mismatched filesize.");
     535           0 :             return false;
     536             :         }
     537             :     }
     538             : 
     539           0 :     ENSURE(m_CallbackData);
     540             : 
     541             :     // MD5 (because upstream provides it)
     542             :     // Just used to make sure there was no obvious corruption during transfer.
     543             :     {
     544             :         u8 digest[MD5::DIGESTSIZE];
     545           0 :         m_CallbackData->md5.Final(digest);
     546           0 :         std::string md5digest = Hexify(digest, MD5::DIGESTSIZE);
     547             : 
     548             : 
     549           0 :         if (m_ModData[m_DownloadModID].properties.at("filehash_md5") != md5digest)
     550             :         {
     551           0 :             err = fmt::sprintf(
     552           0 :                 g_L10n.Translate("Invalid file. Expected md5 %s, got %s."),
     553           0 :                 m_ModData[m_DownloadModID].properties.at("filehash_md5").c_str(),
     554             :                 md5digest);
     555           0 :             return false;
     556             :         }
     557             :     }
     558             : 
     559             :     // Verify file signature.
     560             :     // Used to make sure that the downloaded file was actually checked and signed
     561             :     // by Wildfire Games. And has not been tampered with by the API provider, or the CDN.
     562             : 
     563           0 :     unsigned char hash_fin[crypto_generichash_BYTES_MAX] = {};
     564           0 :     ENSURE(m_CallbackData->hash_state);
     565           0 :     if (crypto_generichash_final(m_CallbackData->hash_state, hash_fin, sizeof hash_fin) != 0)
     566             :     {
     567           0 :         err = g_L10n.Translate("Failed to compute final hash.");
     568           0 :         return false;
     569             :     }
     570             : 
     571           0 :     if (crypto_sign_verify_detached(m_ModData[m_DownloadModID].sig.sig, hash_fin, sizeof hash_fin, m_pk.pk) != 0)
     572             :     {
     573           0 :         err = g_L10n.Translate("Failed to verify signature.");
     574           0 :         return false;
     575             :     }
     576             : 
     577           0 :     return true;
     578             : }
     579             : 
     580             : #define FAIL(...) STMT(err = fmt::sprintf(__VA_ARGS__); CLEANUP(); return false;)
     581             : 
     582             : /**
     583             :  * Parses the current content of m_ResponseData to extract m_GameId.
     584             :  *
     585             :  * The JSON data is expected to look like
     586             :  * { "data": [{"id": 42, ...}, ...], ... }
     587             :  * where we are only interested in the value of the id property.
     588             :  *
     589             :  * @returns true iff it successfully parsed the id.
     590             :  */
     591          18 : bool ModIo::ParseGameIdResponse(const ScriptInterface& scriptInterface, const std::string& responseData, int& id, std::string& err)
     592             : {
     593             : #define CLEANUP() id = -1;
     594          36 :     ScriptRequest rq(scriptInterface);
     595             : 
     596          36 :     JS::RootedValue gameResponse(rq.cx);
     597             : 
     598          18 :     if (!Script::ParseJSON(rq, responseData, &gameResponse))
     599           2 :         FAIL("Failed to parse response as JSON.");
     600             : 
     601          16 :     if (!gameResponse.isObject())
     602           1 :         FAIL("response not an object.");
     603             : 
     604          30 :     JS::RootedObject gameResponseObj(rq.cx, gameResponse.toObjectOrNull());
     605          30 :     JS::RootedValue dataVal(rq.cx);
     606          15 :     if (!JS_GetProperty(rq.cx, gameResponseObj, "data", &dataVal))
     607           0 :         FAIL("data property not in response.");
     608             : 
     609             :     // [{"id": 42, ...}, ...]
     610          15 :     if (!dataVal.isObject())
     611           3 :         FAIL("data property not an object.");
     612             : 
     613          24 :     JS::RootedObject data(rq.cx, dataVal.toObjectOrNull());
     614             :     u32 length;
     615             :     bool isArray;
     616          12 :     if (!JS::IsArrayObject(rq.cx, data, &isArray) || !isArray || !JS::GetArrayLength(rq.cx, data, &length) || !length)
     617           2 :         FAIL("data property not an array with at least one element.");
     618             : 
     619             :     // {"id": 42, ...}
     620          20 :     JS::RootedValue first(rq.cx);
     621          10 :     if (!JS_GetElement(rq.cx, data, 0, &first))
     622           0 :         FAIL("Couldn't get first element.");
     623          10 :     if (!first.isObject())
     624           2 :         FAIL("First element not an object.");
     625             : 
     626          16 :     JS::RootedObject firstObj(rq.cx, &first.toObject());
     627             :     bool hasIdProperty;
     628           8 :     if (!JS_HasProperty(rq.cx, firstObj, "id", &hasIdProperty) || !hasIdProperty)
     629           2 :         FAIL("No id property in first element.");
     630             : 
     631          12 :     JS::RootedValue idProperty(rq.cx);
     632           6 :     ENSURE(JS_GetProperty(rq.cx, firstObj, "id", &idProperty));
     633             : 
     634             :     // Make sure the property is not set to something that could be converted to a bogus value
     635             :     // TODO: We should be able to convert JS::Values to C++ variables in a way that actually
     636             :     // fails when types do not match (see https://trac.wildfiregames.com/ticket/5128).
     637           6 :     if (!idProperty.isNumber())
     638           3 :         FAIL("id property not a number.");
     639             : 
     640           3 :     id = -1;
     641           3 :     if (!Script::FromJSVal(rq, idProperty, id) || id <= 0)
     642           2 :         FAIL("Invalid id.");
     643             : 
     644           1 :     return true;
     645             : #undef CLEANUP
     646             : }
     647             : 
     648             : /**
     649             :  * Parses the current content of m_ResponseData into m_ModData.
     650             :  *
     651             :  * The JSON data is expected to look like
     652             :  * { data: [modobj1, modobj2, ...], ... (including result_count) }
     653             :  * where modobjN has the following structure
     654             :  * { homepage_url: "url", name: "displayname", nameid: "short-non-whitespace-name",
     655             :  *   summary: "short desc.", modfile: { version: "1.2.4", filename: "asdf.zip",
     656             :  *   filehash: { md5: "deadbeef" }, filesize: 1234, download: { binary_url: "someurl", ... } }, ... }.
     657             :  * Only the listed properties are of interest to consumers, and we flatten
     658             :  * the modfile structure as that simplifies handling and there are no conflicts.
     659             :  */
     660          43 : bool ModIo::ParseModsResponse(const ScriptInterface& scriptInterface, const std::string& responseData, std::vector<ModIoModData>& modData, const PKStruct& pk, std::string& err)
     661             : {
     662             : // Make sure we don't end up passing partial results back
     663             : #define CLEANUP() modData.clear();
     664             : 
     665          86 :     ScriptRequest rq(scriptInterface);
     666             : 
     667          86 :     JS::RootedValue modResponse(rq.cx);
     668             : 
     669          43 :     if (!Script::ParseJSON(rq, responseData, &modResponse))
     670           2 :         FAIL("Failed to parse response as JSON.");
     671             : 
     672          41 :     if (!modResponse.isObject())
     673           1 :         FAIL("response not an object.");
     674             : 
     675          80 :     JS::RootedObject modResponseObj(rq.cx, modResponse.toObjectOrNull());
     676          80 :     JS::RootedValue dataVal(rq.cx);
     677          40 :     if (!JS_GetProperty(rq.cx, modResponseObj, "data", &dataVal))
     678           0 :         FAIL("data property not in response.");
     679             : 
     680             :     // [modobj1, modobj2, ... ]
     681          40 :     if (!dataVal.isObject())
     682           3 :         FAIL("data property not an object.");
     683             : 
     684          74 :     JS::RootedObject rData(rq.cx, dataVal.toObjectOrNull());
     685             :     u32 length;
     686             :     bool isArray;
     687          37 :     if (!JS::IsArrayObject(rq.cx, rData, &isArray) || !isArray || !JS::GetArrayLength(rq.cx, rData, &length) || !length)
     688           2 :         FAIL("data property not an array with at least one element.");
     689             : 
     690          35 :     modData.clear();
     691          35 :     modData.reserve(length);
     692             : 
     693             : #define INVALIDATE_DATA_AND_CONTINUE(...) \
     694             :     {\
     695             :         data.properties.emplace("invalid", "true");\
     696             :         data.properties.emplace("error", __VA_ARGS__);\
     697             :         continue;\
     698             :     }
     699             : 
     700          70 :     for (u32 i = 0; i < length; ++i)
     701             :     {
     702          35 :         modData.emplace_back();
     703          35 :         ModIoModData& data = modData.back();
     704          36 :         JS::RootedValue el(rq.cx);
     705          38 :         if (!JS_GetElement(rq.cx, rData, i, &el) || !el.isObject())
     706           6 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get array element object.")
     707             : 
     708          32 :         bool ok = true;
     709          33 :         std::string copyStringError;
     710             : #define COPY_STRINGS_ELSE_CONTINUE(prefix, obj, ...) \
     711             :     for (const std::string& prop : { __VA_ARGS__ }) \
     712             :     { \
     713             :         std::string val; \
     714             :         if (!Script::FromJSProperty(rq, obj, prop.c_str(), val, true)) \
     715             :         { \
     716             :             ok  = false; \
     717             :             copyStringError = "Failed to get " + prop + " from " + #obj + "."; \
     718             :             break; \
     719             :         }\
     720             :         data.properties.emplace(prefix+prop, val); \
     721             :     } \
     722             :     if (!ok) \
     723             :         INVALIDATE_DATA_AND_CONTINUE(copyStringError);
     724             : 
     725             :         // TODO: Currently the homepage_url field does not contain a non-null value for any entry.
     726          32 :         COPY_STRINGS_ELSE_CONTINUE("", el, "name", "name_id", "summary")
     727             : 
     728             :         // Now copy over the modfile part, but without the pointless substructure
     729          24 :         JS::RootedObject elObj(rq.cx, el.toObjectOrNull());
     730          24 :         JS::RootedValue modFile(rq.cx);
     731          23 :         if (!JS_GetProperty(rq.cx, elObj, "modfile", &modFile))
     732           0 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get modfile data.");
     733             : 
     734          25 :         if (!modFile.isObject())
     735           4 :             INVALIDATE_DATA_AND_CONTINUE("modfile not an object.");
     736             : 
     737          21 :         COPY_STRINGS_ELSE_CONTINUE("", modFile, "version", "filesize");
     738             : 
     739          18 :         JS::RootedObject modFileObj(rq.cx, modFile.toObjectOrNull());
     740          18 :         JS::RootedValue filehash(rq.cx);
     741          17 :         if (!JS_GetProperty(rq.cx, modFileObj, "filehash", &filehash))
     742           0 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get filehash data.");
     743             : 
     744          17 :         COPY_STRINGS_ELSE_CONTINUE("filehash_", filehash, "md5");
     745             : 
     746          14 :         JS::RootedValue download(rq.cx);
     747          13 :         if (!JS_GetProperty(rq.cx, modFileObj, "download", &download))
     748           0 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get download data.");
     749             : 
     750          13 :         COPY_STRINGS_ELSE_CONTINUE("", download, "binary_url");
     751             : 
     752             :         // Parse metadata_blob (sig+deps)
     753          11 :         std::string metadata_blob;
     754          12 :         if (!Script::FromJSProperty(rq, modFile, "metadata_blob", metadata_blob, true))
     755           4 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get metadata_blob from modFile.");
     756             : 
     757           9 :         JS::RootedValue metadata(rq.cx);
     758           9 :         if (!Script::ParseJSON(rq, metadata_blob, &metadata))
     759           2 :             INVALIDATE_DATA_AND_CONTINUE("Failed to parse metadata_blob as JSON.");
     760             : 
     761           8 :         if (!metadata.isObject())
     762           2 :             INVALIDATE_DATA_AND_CONTINUE("metadata_blob is not decoded as an object.");
     763             : 
     764           8 :         if (!Script::FromJSProperty(rq, metadata, "dependencies", data.dependencies, true))
     765           4 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get dependencies from metadata_blob.");
     766             : 
     767           5 :         std::vector<std::string> minisigs;
     768           6 :         if (!Script::FromJSProperty(rq, metadata, "minisigs", minisigs, true))
     769           4 :             INVALIDATE_DATA_AND_CONTINUE("Failed to get minisigs from metadata_blob.");
     770             : 
     771             :         // Check we did find a valid matching signature.
     772           3 :         std::string signatureParsingErr;
     773           3 :         if (!ParseSignature(minisigs, data.sig, pk, signatureParsingErr))
     774           2 :             INVALIDATE_DATA_AND_CONTINUE(signatureParsingErr);
     775             : 
     776             : #undef COPY_STRINGS_ELSE_CONTINUE
     777             : #undef INVALIDATE_DATA_AND_CONTINUE
     778             :     }
     779             : 
     780          35 :     return true;
     781             : #undef CLEANUP
     782             : }
     783             : 
     784             : /**
     785             :  * Parse signatures to find one that matches the public key, and has a valid global signature.
     786             :  * Returns true and sets @param sig to the valid matching signature.
     787             :  */
     788          15 : bool ModIo::ParseSignature(const std::vector<std::string>& minisigs, SigStruct& sig, const PKStruct& pk, std::string& err)
     789             : {
     790             : #define CLEANUP() sig = {};
     791          16 :     for (const std::string& file_sig : minisigs)
     792             :     {
     793             :         // Format of a .minisig file (created using minisign(1) with -SHm file.zip)
     794             :         // untrusted comment: .*\nb64sign_of_file\ntrusted comment: .*\nb64sign_of_sign_of_file_and_trusted_comment
     795             : 
     796          14 :         std::vector<std::string> sig_lines;
     797          14 :         boost::split(sig_lines, file_sig, boost::is_any_of("\n"));
     798          14 :         if (sig_lines.size() < 4)
     799           2 :             FAIL("Invalid (too short) sig.");
     800             : 
     801             :         // Verify that both the untrusted comment and the trusted comment start with the correct prefix
     802             :         // because that is easy.
     803          12 :         const std::string untrusted_comment_prefix = "untrusted comment: ";
     804          12 :         const std::string trusted_comment_prefix = "trusted comment: ";
     805          12 :         if (!boost::algorithm::starts_with(sig_lines[0], untrusted_comment_prefix))
     806           2 :             FAIL("Malformed untrusted comment.");
     807          10 :         if (!boost::algorithm::starts_with(sig_lines[2], trusted_comment_prefix))
     808           2 :             FAIL("Malformed trusted comment.");
     809             : 
     810             :         // We only _really_ care about the second line which is the signature of the file (b64-encoded)
     811             :         // Also handling the other signature is nice, but not really required.
     812           8 :         const std::string& msg_sig = sig_lines[1];
     813             : 
     814           8 :         size_t bin_len = 0;
     815           8 :         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)
     816           2 :             FAIL("Failed to decode base64 sig.");
     817             : 
     818             :         cassert(sizeof pk.keynum == sizeof sig.keynum);
     819             : 
     820           6 :         if (memcmp(&pk.keynum, &sig.keynum, sizeof sig.keynum) != 0)
     821           1 :             continue; // mismatched key, try another one
     822             : 
     823           5 :         if (memcmp(&sig.sig_alg, "ED", 2) != 0)
     824           1 :             FAIL("Only hashed minisign signatures are supported.");
     825             : 
     826             :         // Signature matches our public key
     827             : 
     828             :         // Now verify the global signature (sig || trusted_comment)
     829             : 
     830             :         unsigned char global_sig[crypto_sign_BYTES];
     831           4 :         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)
     832           1 :             FAIL("Failed to decode base64 global_sig.");
     833             : 
     834           6 :         const std::string trusted_comment = sig_lines[2].substr(trusted_comment_prefix.size());
     835             : 
     836           3 :         unsigned char* sig_and_trusted_comment = (unsigned char*)sodium_malloc((sizeof sig.sig) + trusted_comment.size());
     837           3 :         if (!sig_and_trusted_comment)
     838           0 :             FAIL("sodium_malloc failed.");
     839             : 
     840           3 :         memcpy(sig_and_trusted_comment, sig.sig, sizeof sig.sig);
     841           3 :         memcpy(sig_and_trusted_comment + sizeof sig.sig, trusted_comment.data(), trusted_comment.size());
     842             : 
     843           3 :         if (crypto_sign_verify_detached(global_sig, sig_and_trusted_comment, (sizeof sig.sig) + trusted_comment.size(), pk.pk) != 0)
     844             :         {
     845           1 :             err = "Failed to verify global signature.";
     846           1 :             sodium_free(sig_and_trusted_comment);
     847           1 :             return false;
     848             :         }
     849             : 
     850           2 :         sodium_free(sig_and_trusted_comment);
     851             : 
     852             :         // Valid global sig, and the keynum matches the real one
     853           2 :         return true;
     854             :     }
     855             : 
     856           2 :     FAIL("Invalid signature.");
     857             : #undef CLEANUP
     858           3 : }
     859             : 
     860             : #undef FAIL

Generated by: LCOV version 1.13