jni/src/util/srp.cpp \
jni/src/util/timetaker.cpp \
jni/src/unittest/test.cpp \
+ jni/src/unittest/test_authdatabase.cpp \
jni/src/unittest/test_collision.cpp \
jni/src/unittest/test_compression.cpp \
jni/src/unittest/test_connection.cpp \
jni/src/script/cpp_api/s_security.cpp \
jni/src/script/cpp_api/s_server.cpp \
jni/src/script/lua_api/l_areastore.cpp \
+ jni/src/script/lua_api/l_auth.cpp \
jni/src/script/lua_api/l_base.cpp \
jni/src/script/lua_api/l_camera.cpp \
jni/src/script/lua_api/l_client.cpp \
-- Builtin authentication handler
--
-local auth_file_path = core.get_worldpath().."/auth.txt"
-local auth_table = {}
-
-local function read_auth_file()
- local newtable = {}
- local file, errmsg = io.open(auth_file_path, 'rb')
- if not file then
- core.log("info", auth_file_path.." could not be opened for reading ("..errmsg.."); assuming new world")
- return
- end
- for line in file:lines() do
- if line ~= "" then
- local fields = line:split(":", true)
- local name, password, privilege_string, last_login = unpack(fields)
- last_login = tonumber(last_login)
- if not (name and password and privilege_string) then
- error("Invalid line in auth.txt: "..dump(line))
- end
- local privileges = core.string_to_privs(privilege_string)
- newtable[name] = {password=password, privileges=privileges, last_login=last_login}
- end
- end
- io.close(file)
- auth_table = newtable
- core.notify_authentication_modified()
-end
-
-local function save_auth_file()
- local newtable = {}
- -- Check table for validness before attempting to save
- for name, stuff in pairs(auth_table) do
- assert(type(name) == "string")
- assert(name ~= "")
- assert(type(stuff) == "table")
- assert(type(stuff.password) == "string")
- assert(type(stuff.privileges) == "table")
- assert(stuff.last_login == nil or type(stuff.last_login) == "number")
- end
- local content = {}
- for name, stuff in pairs(auth_table) do
- local priv_string = core.privs_to_string(stuff.privileges)
- local parts = {name, stuff.password, priv_string, stuff.last_login or ""}
- content[#content + 1] = table.concat(parts, ":")
- end
- if not core.safe_file_write(auth_file_path, table.concat(content, "\n")) then
- error(auth_file_path.." could not be written to")
- end
-end
-
-read_auth_file()
+-- Make the auth object private, deny access to mods
+local core_auth = core.auth
+core.auth = nil
core.builtin_auth_handler = {
get_auth = function(name)
assert(type(name) == "string")
- -- Figure out what password to use for a new player (singleplayer
- -- always has an empty password, otherwise use default, which is
- -- usually empty too)
- local new_password_hash = ""
- -- If not in authentication table, return nil
- if not auth_table[name] then
+ local auth_entry = core_auth.read(name)
+ -- If no such auth found, return nil
+ if not auth_entry then
return nil
end
-- Figure out what privileges the player should have.
-- Take a copy of the privilege table
local privileges = {}
- for priv, _ in pairs(auth_table[name].privileges) do
+ for priv, _ in pairs(auth_entry.privileges) do
privileges[priv] = true
end
-- If singleplayer, give all privileges except those marked as give_to_singleplayer = false
end
-- All done
return {
- password = auth_table[name].password,
+ password = auth_entry.password,
privileges = privileges,
-- Is set to nil if unknown
- last_login = auth_table[name].last_login,
+ last_login = auth_entry.last_login,
}
end,
create_auth = function(name, password)
assert(type(name) == "string")
assert(type(password) == "string")
core.log('info', "Built-in authentication handler adding player '"..name.."'")
- auth_table[name] = {
+ return core_auth.create({
+ name = name,
password = password,
privileges = core.string_to_privs(core.settings:get("default_privs")),
last_login = os.time(),
- }
- save_auth_file()
+ })
end,
delete_auth = function(name)
assert(type(name) == "string")
- if not auth_table[name] then
+ local auth_entry = core_auth.read(name)
+ if not auth_entry then
return false
end
core.log('info', "Built-in authentication handler deleting player '"..name.."'")
- auth_table[name] = nil
- save_auth_file()
- return true
+ return core_auth.delete(name)
end,
set_password = function(name, password)
assert(type(name) == "string")
assert(type(password) == "string")
- if not auth_table[name] then
+ local auth_entry = core_auth.read(name)
+ if not auth_entry then
core.builtin_auth_handler.create_auth(name, password)
else
core.log('info', "Built-in authentication handler setting password of player '"..name.."'")
- auth_table[name].password = password
- save_auth_file()
+ auth_entry.password = password
+ core_auth.save(auth_entry)
end
return true
end,
set_privileges = function(name, privileges)
assert(type(name) == "string")
assert(type(privileges) == "table")
- if not auth_table[name] then
- core.builtin_auth_handler.create_auth(name,
+ local auth_entry = core_auth.read(name)
+ if not auth_entry then
+ auth_entry = core.builtin_auth_handler.create_auth(name,
core.get_password_hash(name,
core.settings:get("default_password")))
end
-- Run grant callbacks
for priv, _ in pairs(privileges) do
- if not auth_table[name].privileges[priv] then
+ if not auth_entry.privileges[priv] then
core.run_priv_callbacks(name, priv, nil, "grant")
end
end
-- Run revoke callbacks
- for priv, _ in pairs(auth_table[name].privileges) do
+ for priv, _ in pairs(auth_entry.privileges) do
if not privileges[priv] then
core.run_priv_callbacks(name, priv, nil, "revoke")
end
end
- auth_table[name].privileges = privileges
+ auth_entry.privileges = privileges
+ core_auth.save(auth_entry)
core.notify_authentication_modified(name)
- save_auth_file()
end,
reload = function()
- read_auth_file()
+ core_auth.reload()
return true
end,
record_login = function(name)
assert(type(name) == "string")
- assert(auth_table[name]).last_login = os.time()
- save_auth_file()
+ local auth_entry = core_auth.read(name)
+ assert(auth_entry)
+ auth_entry.last_login = os.time()
+ core_auth.save(auth_entry)
end,
iterate = function()
local names = {}
- for k in pairs(auth_table) do
- names[k] = true
+ local nameslist = core_auth.list_names()
+ for k,v in pairs(nameslist) do
+ names[v] = true
end
return pairs(names)
end,
if core.registered_auth_handler ~= nil then
return -- Don't do anything if custom auth handler registered
end
- if auth_table[name] ~= nil then
+ local auth_entry = core_auth.read(name)
+ if auth_entry ~= nil then
return
end
local name_lower = name:lower()
- for k in pairs(auth_table) do
+ for k in core.builtin_auth_handler.iterate() do
if k:lower() == name_lower then
return string.format("\nCannot create new player called '%s'. "..
"Another account called '%s' is already registered. "..
World
|-- auth.txt ----- Authentication data
+|-- auth.sqlite -- Authentication data (SQLite alternative)
|-- env_meta.txt - Environment metadata
|-- ipban.txt ---- Banned ips/users
|-- map_meta.txt - Map metadata
- Player "bar", no password, no privileges:
bar::
+auth.sqlite
+------------
+Contains authentification data as an SQLite database. This replaces auth.txt
+above when auth_backend is set to "sqlite3" in world.mt .
+
+This database contains two tables "auth" and "user_privileges":
+
+CREATE TABLE `auth` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `name` VARCHAR(32) UNIQUE,
+ `password` VARCHAR(512),
+ `last_login` INTEGER
+);
+CREATE TABLE `user_privileges` (
+ `id` INTEGER,
+ `privilege` VARCHAR(32),
+ PRIMARY KEY (id, privilege)
+ CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES auth (id) ON DELETE CASCADE
+);
+
+The "name" and "password" fields of the auth table are the same as the auth.txt
+fields (with modern password hash). The "last_login" field is the last login
+time as a unix time stamp.
+
+The "user_privileges" table contains one entry per privilege and player.
+A player with "interact" and "shout" privileges will have two entries, one
+with privilege="interact" and the second with privilege="shout".
+
env_meta.txt
-------------
Simple global environment variables.
readonly_backend = sqlite3 - optionally readonly seed DB (DB file _must_ be located in "readonly" subfolder)
server_announce = false - whether the server is publicly announced or not
load_mod_<mod> = false - whether <mod> is to be loaded in this world
+ auth_backend = files - which DB backend to use for authentication data
Player File Format
===================
#include "settings.h"
#include "porting.h"
#include "filesys.h"
+#include "util/string.h"
// !!! WARNING !!!
// This backend is intended to be used on Minetest 0.4.16 only for the transition backend
res.emplace_back(player.getName());
}
}
+
+AuthDatabaseFiles::AuthDatabaseFiles(const std::string &savedir) : m_savedir(savedir)
+{
+ readAuthFile();
+}
+
+bool AuthDatabaseFiles::getAuth(const std::string &name, AuthEntry &res)
+{
+ const auto res_i = m_auth_list.find(name);
+ if (res_i == m_auth_list.end()) {
+ return false;
+ }
+ res = res_i->second;
+ return true;
+}
+
+bool AuthDatabaseFiles::saveAuth(const AuthEntry &authEntry)
+{
+ m_auth_list[authEntry.name] = authEntry;
+
+ // save entire file
+ return writeAuthFile();
+}
+
+bool AuthDatabaseFiles::createAuth(AuthEntry &authEntry)
+{
+ m_auth_list[authEntry.name] = authEntry;
+
+ // save entire file
+ return writeAuthFile();
+}
+
+bool AuthDatabaseFiles::deleteAuth(const std::string &name)
+{
+ if (!m_auth_list.erase(name)) {
+ // did not delete anything -> hadn't existed
+ return false;
+ }
+ return writeAuthFile();
+}
+
+void AuthDatabaseFiles::listNames(std::vector<std::string> &res)
+{
+ res.clear();
+ res.reserve(m_auth_list.size());
+ for (const auto &res_pair : m_auth_list) {
+ res.push_back(res_pair.first);
+ }
+}
+
+void AuthDatabaseFiles::reload()
+{
+ readAuthFile();
+}
+
+bool AuthDatabaseFiles::readAuthFile()
+{
+ std::string path = m_savedir + DIR_DELIM + "auth.txt";
+ std::ifstream file(path, std::ios::binary);
+ if (!file.good()) {
+ return false;
+ }
+ m_auth_list.clear();
+ while (file.good()) {
+ std::string line;
+ std::getline(file, line);
+ std::vector<std::string> parts = str_split(line, ':');
+ if (parts.size() < 3) // also: empty line at end
+ continue;
+ const std::string &name = parts[0];
+ const std::string &password = parts[1];
+ std::vector<std::string> privileges = str_split(parts[2], ',');
+ s64 last_login = parts.size() > 3 ? atol(parts[3].c_str()) : 0;
+
+ m_auth_list[name] = {
+ 1,
+ name,
+ password,
+ privileges,
+ last_login,
+ };
+ }
+ return true;
+}
+
+bool AuthDatabaseFiles::writeAuthFile()
+{
+ std::string path = m_savedir + DIR_DELIM + "auth.txt";
+ std::ostringstream output(std::ios_base::binary);
+ for (const auto &auth_i : m_auth_list) {
+ const AuthEntry &authEntry = auth_i.second;
+ output << authEntry.name << ":" << authEntry.password << ":";
+ output << str_join(authEntry.privileges, ",");
+ output << ":" << authEntry.last_login;
+ output << std::endl;
+ }
+ if (!fs::safeWriteToFile(path, output.str())) {
+ infostream << "Failed to write " << path << std::endl;
+ return false;
+ }
+ return true;
+}
// for player files
#include "database.h"
+#include <unordered_map>
class PlayerDatabaseFiles : public PlayerDatabase
{
std::string m_savedir;
};
+
+class AuthDatabaseFiles : public AuthDatabase
+{
+public:
+ AuthDatabaseFiles(const std::string &savedir);
+ virtual ~AuthDatabaseFiles() = default;
+
+ virtual bool getAuth(const std::string &name, AuthEntry &res);
+ virtual bool saveAuth(const AuthEntry &authEntry);
+ virtual bool createAuth(AuthEntry &authEntry);
+ virtual bool deleteAuth(const std::string &name);
+ virtual void listNames(std::vector<std::string> &res);
+ virtual void reload();
+
+private:
+ std::unordered_map<std::string, AuthEntry> m_auth_list;
+ std::string m_savedir;
+ bool readAuthFile();
+ bool writeAuthFile();
+};
sqlite3_reset(m_stmt_player_list);
}
+
+/*
+ * Auth database
+ */
+
+AuthDatabaseSQLite3::AuthDatabaseSQLite3(const std::string &savedir) :
+ Database_SQLite3(savedir, "auth"), AuthDatabase()
+{
+}
+
+AuthDatabaseSQLite3::~AuthDatabaseSQLite3()
+{
+ FINALIZE_STATEMENT(m_stmt_read)
+ FINALIZE_STATEMENT(m_stmt_write)
+ FINALIZE_STATEMENT(m_stmt_create)
+ FINALIZE_STATEMENT(m_stmt_delete)
+ FINALIZE_STATEMENT(m_stmt_list_names)
+ FINALIZE_STATEMENT(m_stmt_read_privs)
+ FINALIZE_STATEMENT(m_stmt_write_privs)
+ FINALIZE_STATEMENT(m_stmt_delete_privs)
+ FINALIZE_STATEMENT(m_stmt_last_insert_rowid)
+}
+
+void AuthDatabaseSQLite3::createDatabase()
+{
+ assert(m_database); // Pre-condition
+
+ SQLOK(sqlite3_exec(m_database,
+ "CREATE TABLE IF NOT EXISTS `auth` ("
+ "`id` INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "`name` VARCHAR(32) UNIQUE,"
+ "`password` VARCHAR(512),"
+ "`last_login` INTEGER"
+ ");",
+ NULL, NULL, NULL),
+ "Failed to create auth table");
+
+ SQLOK(sqlite3_exec(m_database,
+ "CREATE TABLE IF NOT EXISTS `user_privileges` ("
+ "`id` INTEGER,"
+ "`privilege` VARCHAR(32),"
+ "PRIMARY KEY (id, privilege)"
+ "CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES auth (id) ON DELETE CASCADE"
+ ");",
+ NULL, NULL, NULL),
+ "Failed to create auth privileges table");
+}
+
+void AuthDatabaseSQLite3::initStatements()
+{
+ PREPARE_STATEMENT(read, "SELECT id, name, password, last_login FROM auth WHERE name = ?");
+ PREPARE_STATEMENT(write, "UPDATE auth set name = ?, password = ?, last_login = ? WHERE id = ?");
+ PREPARE_STATEMENT(create, "INSERT INTO auth (name, password, last_login) VALUES (?, ?, ?)");
+ PREPARE_STATEMENT(delete, "DELETE FROM auth WHERE name = ?");
+
+ PREPARE_STATEMENT(list_names, "SELECT name FROM auth ORDER BY name DESC");
+
+ PREPARE_STATEMENT(read_privs, "SELECT privilege FROM user_privileges WHERE id = ?");
+ PREPARE_STATEMENT(write_privs, "INSERT OR IGNORE INTO user_privileges (id, privilege) VALUES (?, ?)");
+ PREPARE_STATEMENT(delete_privs, "DELETE FROM user_privileges WHERE id = ?");
+
+ PREPARE_STATEMENT(last_insert_rowid, "SELECT last_insert_rowid()");
+}
+
+bool AuthDatabaseSQLite3::getAuth(const std::string &name, AuthEntry &res)
+{
+ verifyDatabase();
+ str_to_sqlite(m_stmt_read, 1, name);
+ if (sqlite3_step(m_stmt_read) != SQLITE_ROW) {
+ sqlite3_reset(m_stmt_read);
+ return false;
+ }
+ res.id = sqlite_to_uint(m_stmt_read, 0);
+ res.name = sqlite_to_string(m_stmt_read, 1);
+ res.password = sqlite_to_string(m_stmt_read, 2);
+ res.last_login = sqlite_to_int64(m_stmt_read, 3);
+ sqlite3_reset(m_stmt_read);
+
+ int64_to_sqlite(m_stmt_read_privs, 1, res.id);
+ while (sqlite3_step(m_stmt_read_privs) == SQLITE_ROW) {
+ res.privileges.emplace_back(sqlite_to_string(m_stmt_read_privs, 0));
+ }
+ sqlite3_reset(m_stmt_read_privs);
+
+ return true;
+}
+
+bool AuthDatabaseSQLite3::saveAuth(const AuthEntry &authEntry)
+{
+ beginSave();
+
+ str_to_sqlite(m_stmt_write, 1, authEntry.name);
+ str_to_sqlite(m_stmt_write, 2, authEntry.password);
+ int64_to_sqlite(m_stmt_write, 3, authEntry.last_login);
+ int64_to_sqlite(m_stmt_write, 4, authEntry.id);
+ sqlite3_vrfy(sqlite3_step(m_stmt_write), SQLITE_DONE);
+ sqlite3_reset(m_stmt_write);
+
+ writePrivileges(authEntry);
+
+ endSave();
+ return true;
+}
+
+bool AuthDatabaseSQLite3::createAuth(AuthEntry &authEntry)
+{
+ beginSave();
+
+ // id autoincrements
+ str_to_sqlite(m_stmt_create, 1, authEntry.name);
+ str_to_sqlite(m_stmt_create, 2, authEntry.password);
+ int64_to_sqlite(m_stmt_create, 3, authEntry.last_login);
+ sqlite3_vrfy(sqlite3_step(m_stmt_create), SQLITE_DONE);
+ sqlite3_reset(m_stmt_create);
+
+ // obtain id and write back to original authEntry
+ sqlite3_step(m_stmt_last_insert_rowid);
+ authEntry.id = sqlite_to_uint(m_stmt_last_insert_rowid, 0);
+ sqlite3_reset(m_stmt_last_insert_rowid);
+
+ writePrivileges(authEntry);
+
+ endSave();
+ return true;
+}
+
+bool AuthDatabaseSQLite3::deleteAuth(const std::string &name)
+{
+ verifyDatabase();
+
+ str_to_sqlite(m_stmt_delete, 1, name);
+ sqlite3_vrfy(sqlite3_step(m_stmt_delete), SQLITE_DONE);
+ int changes = sqlite3_changes(m_database);
+ sqlite3_reset(m_stmt_delete);
+
+ // privileges deleted by foreign key on delete cascade
+
+ return changes > 0;
+}
+
+void AuthDatabaseSQLite3::listNames(std::vector<std::string> &res)
+{
+ verifyDatabase();
+
+ while (sqlite3_step(m_stmt_list_names) == SQLITE_ROW) {
+ res.push_back(sqlite_to_string(m_stmt_list_names, 0));
+ }
+ sqlite3_reset(m_stmt_list_names);
+}
+
+void AuthDatabaseSQLite3::reload()
+{
+ // noop for SQLite
+}
+
+void AuthDatabaseSQLite3::writePrivileges(const AuthEntry &authEntry)
+{
+ int64_to_sqlite(m_stmt_delete_privs, 1, authEntry.id);
+ sqlite3_vrfy(sqlite3_step(m_stmt_delete_privs), SQLITE_DONE);
+ sqlite3_reset(m_stmt_delete_privs);
+ for (const std::string &privilege : authEntry.privileges) {
+ int64_to_sqlite(m_stmt_write_privs, 1, authEntry.id);
+ str_to_sqlite(m_stmt_write_privs, 2, privilege);
+ sqlite3_vrfy(sqlite3_step(m_stmt_write_privs), SQLITE_DONE);
+ sqlite3_reset(m_stmt_write_privs);
+ }
+}
return (u32) sqlite3_column_int(s, iCol);
}
+ inline s64 sqlite_to_int64(sqlite3_stmt *s, int iCol)
+ {
+ return (s64) sqlite3_column_int64(s, iCol);
+ }
+
+ inline u64 sqlite_to_uint64(sqlite3_stmt *s, int iCol)
+ {
+ return (u64) sqlite3_column_int64(s, iCol);
+ }
+
inline float sqlite_to_float(sqlite3_stmt *s, int iCol)
{
return (float) sqlite3_column_double(s, iCol);
sqlite3_stmt *m_stmt_player_metadata_remove = nullptr;
sqlite3_stmt *m_stmt_player_metadata_add = nullptr;
};
+
+class AuthDatabaseSQLite3 : private Database_SQLite3, public AuthDatabase
+{
+public:
+ AuthDatabaseSQLite3(const std::string &savedir);
+ virtual ~AuthDatabaseSQLite3();
+
+ virtual bool getAuth(const std::string &name, AuthEntry &res);
+ virtual bool saveAuth(const AuthEntry &authEntry);
+ virtual bool createAuth(AuthEntry &authEntry);
+ virtual bool deleteAuth(const std::string &name);
+ virtual void listNames(std::vector<std::string> &res);
+ virtual void reload();
+
+protected:
+ virtual void createDatabase();
+ virtual void initStatements();
+
+private:
+ virtual void writePrivileges(const AuthEntry &authEntry);
+
+ sqlite3_stmt *m_stmt_read = nullptr;
+ sqlite3_stmt *m_stmt_write = nullptr;
+ sqlite3_stmt *m_stmt_create = nullptr;
+ sqlite3_stmt *m_stmt_delete = nullptr;
+ sqlite3_stmt *m_stmt_list_names = nullptr;
+ sqlite3_stmt *m_stmt_read_privs = nullptr;
+ sqlite3_stmt *m_stmt_write_privs = nullptr;
+ sqlite3_stmt *m_stmt_delete_privs = nullptr;
+ sqlite3_stmt *m_stmt_last_insert_rowid = nullptr;
+};
#pragma once
+#include <set>
#include <string>
#include <vector>
#include "irr_v3d.h"
virtual bool removePlayer(const std::string &name) = 0;
virtual void listPlayers(std::vector<std::string> &res) = 0;
};
+
+struct AuthEntry
+{
+ u64 id;
+ std::string name;
+ std::string password;
+ std::vector<std::string> privileges;
+ s64 last_login;
+};
+
+class AuthDatabase
+{
+public:
+ virtual ~AuthDatabase() = default;
+
+ virtual bool getAuth(const std::string &name, AuthEntry &res) = 0;
+ virtual bool saveAuth(const AuthEntry &authEntry) = 0;
+ virtual bool createAuth(AuthEntry &authEntry) = 0;
+ virtual bool deleteAuth(const std::string &name) = 0;
+ virtual void listNames(std::vector<std::string> &res) = 0;
+ virtual void reload() = 0;
+};
_("Migrate from current map backend to another (Only works when using minetestserver or with --server)"))));
allowed_options->insert(std::make_pair("migrate-players", ValueSpec(VALUETYPE_STRING,
_("Migrate from current players backend to another (Only works when using minetestserver or with --server)"))));
+ allowed_options->insert(std::make_pair("migrate-auth", ValueSpec(VALUETYPE_STRING,
+ _("Migrate from current auth backend to another (Only works when using minetestserver or with --server)"))));
allowed_options->insert(std::make_pair("terminal", ValueSpec(VALUETYPE_FLAG,
_("Feature an interactive terminal (Only works when using minetestserver or with --server)"))));
#ifndef SERVER
if (cmd_args.exists("migrate-players"))
return ServerEnvironment::migratePlayersDatabase(game_params, cmd_args);
+ if (cmd_args.exists("migrate-auth"))
+ return ServerEnvironment::migrateAuthDatabase(game_params, cmd_args);
+
if (cmd_args.exists("terminal")) {
#if USE_CURSES
bool name_ok = true;
set(common_SCRIPT_LUA_API_SRCS
${CMAKE_CURRENT_SOURCE_DIR}/l_areastore.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/l_auth.cpp
${CMAKE_CURRENT_SOURCE_DIR}/l_base.cpp
${CMAKE_CURRENT_SOURCE_DIR}/l_craft.cpp
${CMAKE_CURRENT_SOURCE_DIR}/l_env.cpp
--- /dev/null
+/*
+Minetest
+Copyright (C) 2018 bendeutsch, Ben Deutsch <ben@bendeutsch.de>
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation; either version 2.1 of the License, or
+(at your option) any later version.
+
+This program 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 Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+*/
+
+#include "lua_api/l_auth.h"
+#include "lua_api/l_internal.h"
+#include "common/c_converter.h"
+#include "common/c_content.h"
+#include "cpp_api/s_base.h"
+#include "server.h"
+#include "environment.h"
+#include "database/database.h"
+#include <algorithm>
+
+// common start: ensure auth db
+AuthDatabase *ModApiAuth::getAuthDb(lua_State *L)
+{
+ ServerEnvironment *server_environment =
+ dynamic_cast<ServerEnvironment *>(getEnv(L));
+ if (!server_environment)
+ return nullptr;
+ return server_environment->getAuthDatabase();
+}
+
+void ModApiAuth::pushAuthEntry(lua_State *L, const AuthEntry &authEntry)
+{
+ lua_newtable(L);
+ int table = lua_gettop(L);
+ // id
+ lua_pushnumber(L, authEntry.id);
+ lua_setfield(L, table, "id");
+ // name
+ lua_pushstring(L, authEntry.name.c_str());
+ lua_setfield(L, table, "name");
+ // password
+ lua_pushstring(L, authEntry.password.c_str());
+ lua_setfield(L, table, "password");
+ // privileges
+ lua_newtable(L);
+ int privtable = lua_gettop(L);
+ for (const std::string &privs : authEntry.privileges) {
+ lua_pushboolean(L, true);
+ lua_setfield(L, privtable, privs.c_str());
+ }
+ lua_setfield(L, table, "privileges");
+ // last_login
+ lua_pushnumber(L, authEntry.last_login);
+ lua_setfield(L, table, "last_login");
+
+ lua_pushvalue(L, table);
+}
+
+// auth_read(name)
+int ModApiAuth::l_auth_read(lua_State *L)
+{
+ NO_MAP_LOCK_REQUIRED;
+ AuthDatabase *auth_db = getAuthDb(L);
+ if (!auth_db)
+ return 0;
+ AuthEntry authEntry;
+ const char *name = luaL_checkstring(L, 1);
+ bool success = auth_db->getAuth(std::string(name), authEntry);
+ if (!success)
+ return 0;
+
+ pushAuthEntry(L, authEntry);
+ return 1;
+}
+
+// auth_save(table)
+int ModApiAuth::l_auth_save(lua_State *L)
+{
+ NO_MAP_LOCK_REQUIRED;
+ AuthDatabase *auth_db = getAuthDb(L);
+ if (!auth_db)
+ return 0;
+ luaL_checktype(L, 1, LUA_TTABLE);
+ int table = 1;
+ AuthEntry authEntry;
+ bool success;
+ success = getintfield(L, table, "id", authEntry.id);
+ success = success && getstringfield(L, table, "name", authEntry.name);
+ success = success && getstringfield(L, table, "password", authEntry.password);
+ lua_getfield(L, table, "privileges");
+ if (lua_istable(L, -1)) {
+ lua_pushnil(L);
+ while (lua_next(L, -2)) {
+ authEntry.privileges.emplace_back(
+ lua_tostring(L, -2)); // the key, not the value
+ lua_pop(L, 1);
+ }
+ } else {
+ success = false;
+ }
+ lua_pop(L, 1); // the table
+ success = success && getintfield(L, table, "last_login", authEntry.last_login);
+
+ if (!success) {
+ lua_pushboolean(L, false);
+ return 1;
+ }
+
+ lua_pushboolean(L, auth_db->saveAuth(authEntry));
+ return 1;
+}
+
+// auth_create(table)
+int ModApiAuth::l_auth_create(lua_State *L)
+{
+ NO_MAP_LOCK_REQUIRED;
+ AuthDatabase *auth_db = getAuthDb(L);
+ if (!auth_db)
+ return 0;
+ luaL_checktype(L, 1, LUA_TTABLE);
+ int table = 1;
+ AuthEntry authEntry;
+ bool success;
+ // no meaningful id field, we assume
+ success = getstringfield(L, table, "name", authEntry.name);
+ success = success && getstringfield(L, table, "password", authEntry.password);
+ lua_getfield(L, table, "privileges");
+ if (lua_istable(L, -1)) {
+ lua_pushnil(L);
+ while (lua_next(L, -2)) {
+ authEntry.privileges.emplace_back(
+ lua_tostring(L, -2)); // the key, not the value
+ lua_pop(L, 1);
+ }
+ } else {
+ success = false;
+ }
+ lua_pop(L, 1); // the table
+ success = success && getintfield(L, table, "last_login", authEntry.last_login);
+
+ if (!success)
+ return 0;
+
+ if (auth_db->createAuth(authEntry)) {
+ pushAuthEntry(L, authEntry);
+ return 1;
+ }
+
+ return 0;
+}
+
+// auth_delete(name)
+int ModApiAuth::l_auth_delete(lua_State *L)
+{
+ NO_MAP_LOCK_REQUIRED;
+ AuthDatabase *auth_db = getAuthDb(L);
+ if (!auth_db)
+ return 0;
+ std::string name(luaL_checkstring(L, 1));
+ lua_pushboolean(L, auth_db->deleteAuth(name));
+ return 1;
+}
+
+// auth_list_names()
+int ModApiAuth::l_auth_list_names(lua_State *L)
+{
+ NO_MAP_LOCK_REQUIRED;
+ AuthDatabase *auth_db = getAuthDb(L);
+ if (!auth_db)
+ return 0;
+ std::vector<std::string> names;
+ auth_db->listNames(names);
+ lua_createtable(L, names.size(), 0);
+ int table = lua_gettop(L);
+ int i = 1;
+ for (const std::string &name : names) {
+ lua_pushstring(L, name.c_str());
+ lua_rawseti(L, table, i++);
+ }
+ return 1;
+}
+
+// auth_reload()
+int ModApiAuth::l_auth_reload(lua_State *L)
+{
+ NO_MAP_LOCK_REQUIRED;
+ AuthDatabase *auth_db = getAuthDb(L);
+ if (auth_db)
+ auth_db->reload();
+ return 0;
+}
+
+void ModApiAuth::Initialize(lua_State *L, int top)
+{
+
+ lua_newtable(L);
+ int auth_top = lua_gettop(L);
+
+ registerFunction(L, "read", l_auth_read, auth_top);
+ registerFunction(L, "save", l_auth_save, auth_top);
+ registerFunction(L, "create", l_auth_create, auth_top);
+ registerFunction(L, "delete", l_auth_delete, auth_top);
+ registerFunction(L, "list_names", l_auth_list_names, auth_top);
+ registerFunction(L, "reload", l_auth_reload, auth_top);
+
+ lua_setfield(L, top, "auth");
+}
--- /dev/null
+/*
+Minetest
+Copyright (C) 2018 bendeutsch, Ben Deutsch <ben@bendeutsch.de>
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation; either version 2.1 of the License, or
+(at your option) any later version.
+
+This program 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 Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+*/
+
+#pragma once
+
+#include "lua_api/l_base.h"
+
+class AuthDatabase;
+struct AuthEntry;
+
+class ModApiAuth : public ModApiBase
+{
+private:
+ // auth_read(name)
+ static int l_auth_read(lua_State *L);
+
+ // auth_save(table)
+ static int l_auth_save(lua_State *L);
+
+ // auth_create(table)
+ static int l_auth_create(lua_State *L);
+
+ // auth_delete(name)
+ static int l_auth_delete(lua_State *L);
+
+ // auth_list_names()
+ static int l_auth_list_names(lua_State *L);
+
+ // auth_reload()
+ static int l_auth_reload(lua_State *L);
+
+ // helper for auth* methods
+ static AuthDatabase *getAuthDb(lua_State *L);
+ static void pushAuthEntry(lua_State *L, const AuthEntry &authEntry);
+
+public:
+ static void Initialize(lua_State *L, int top);
+};
#include "settings.h"
#include "cpp_api/s_internal.h"
#include "lua_api/l_areastore.h"
+#include "lua_api/l_auth.h"
#include "lua_api/l_base.h"
#include "lua_api/l_craft.h"
#include "lua_api/l_env.h"
ModChannelRef::Register(L);
// Initialize mod api modules
+ ModApiAuth::Initialize(L, top);
ModApiCraft::Initialize(L, top);
ModApiEnvMod::Initialize(L, top);
ModApiInventory::Initialize(L, top);
std::string name;
conf.getNoEx("player_backend", name);
m_player_database = openPlayerDatabase(name, path_world, conf);
+
+ std::string auth_name = "files";
+ if (conf.exists("auth_backend")) {
+ conf.getNoEx("auth_backend", auth_name);
+ } else {
+ conf.set("auth_backend", "files");
+ if (!conf.updateConfigFile(conf_path.c_str())) {
+ errorstream << "ServerEnvironment::ServerEnvironment(): "
+ << "Failed to update world.mt!" << std::endl;
+ }
+ }
+ m_auth_database = openAuthDatabase(auth_name, path_world, conf);
}
ServerEnvironment::~ServerEnvironment()
}
delete m_player_database;
+ delete m_auth_database;
}
Map & ServerEnvironment::getMap()
}
return true;
}
+
+AuthDatabase *ServerEnvironment::openAuthDatabase(
+ const std::string &name, const std::string &savedir, const Settings &conf)
+{
+ if (name == "sqlite3")
+ return new AuthDatabaseSQLite3(savedir);
+
+ if (name == "files")
+ return new AuthDatabaseFiles(savedir);
+
+ throw BaseException(std::string("Database backend ") + name + " not supported.");
+}
+
+bool ServerEnvironment::migrateAuthDatabase(
+ const GameParams &game_params, const Settings &cmd_args)
+{
+ std::string migrate_to = cmd_args.get("migrate-auth");
+ Settings world_mt;
+ std::string world_mt_path = game_params.world_path + DIR_DELIM + "world.mt";
+ if (!world_mt.readConfigFile(world_mt_path.c_str())) {
+ errorstream << "Cannot read world.mt!" << std::endl;
+ return false;
+ }
+
+ std::string backend = "files";
+ if (world_mt.exists("auth_backend"))
+ backend = world_mt.get("auth_backend");
+ else
+ warningstream << "No auth_backend found in world.mt, "
+ "assuming \"files\"." << std::endl;
+
+ if (backend == migrate_to) {
+ errorstream << "Cannot migrate: new backend is same"
+ << " as the old one" << std::endl;
+ return false;
+ }
+
+ try {
+ const std::unique_ptr<AuthDatabase> srcdb(ServerEnvironment::openAuthDatabase(
+ backend, game_params.world_path, world_mt));
+ const std::unique_ptr<AuthDatabase> dstdb(ServerEnvironment::openAuthDatabase(
+ migrate_to, game_params.world_path, world_mt));
+
+ std::vector<std::string> names_list;
+ srcdb->listNames(names_list);
+ for (const std::string &name : names_list) {
+ actionstream << "Migrating auth entry for " << name << std::endl;
+ bool success;
+ AuthEntry authEntry;
+ success = srcdb->getAuth(name, authEntry);
+ success = success && dstdb->createAuth(authEntry);
+ if (!success)
+ errorstream << "Failed to migrate " << name << std::endl;
+ }
+
+ actionstream << "Successfully migrated " << names_list.size()
+ << " auth entries" << std::endl;
+ world_mt.set("auth_backend", migrate_to);
+ if (!world_mt.updateConfigFile(world_mt_path.c_str()))
+ errorstream << "Failed to update world.mt!" << std::endl;
+ else
+ actionstream << "world.mt updated" << std::endl;
+
+ if (backend == "files") {
+ // special-case files migration:
+ // move auth.txt to auth.txt.bak if possible
+ std::string auth_txt_path =
+ game_params.world_path + DIR_DELIM + "auth.txt";
+ std::string auth_bak_path = auth_txt_path + ".bak";
+ if (!fs::PathExists(auth_bak_path))
+ if (fs::Rename(auth_txt_path, auth_bak_path))
+ actionstream << "Renamed auth.txt to auth.txt.bak"
+ << std::endl;
+ else
+ errorstream << "Could not rename auth.txt to "
+ "auth.txt.bak" << std::endl;
+ else
+ warningstream << "auth.txt.bak already exists, auth.txt "
+ "not renamed" << std::endl;
+ }
+
+ } catch (BaseException &e) {
+ errorstream << "An error occured during migration: " << e.what()
+ << std::endl;
+ return false;
+ }
+ return true;
+}
class MapBlock;
class RemotePlayer;
class PlayerDatabase;
+class AuthDatabase;
class PlayerSAO;
class ServerEnvironment;
class ActiveBlockModifier;
static bool migratePlayersDatabase(const GameParams &game_params,
const Settings &cmd_args);
+
+ AuthDatabase *getAuthDatabase() { return m_auth_database; }
+ static bool migrateAuthDatabase(const GameParams &game_params,
+ const Settings &cmd_args);
private:
/**
static PlayerDatabase *openPlayerDatabase(const std::string &name,
const std::string &savedir, const Settings &conf);
+ static AuthDatabase *openAuthDatabase(const std::string &name,
+ const std::string &savedir, const Settings &conf);
/*
Internal ActiveObject interface
-------------------------------------------
std::vector<RemotePlayer*> m_players;
PlayerDatabase *m_player_database = nullptr;
+ AuthDatabase *m_auth_database = nullptr;
// Particles
IntervalLimiter m_particle_management_interval;
set (UNITTEST_SRCS
${CMAKE_CURRENT_SOURCE_DIR}/test.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/test_authdatabase.cpp
${CMAKE_CURRENT_SOURCE_DIR}/test_activeobject.cpp
${CMAKE_CURRENT_SOURCE_DIR}/test_areastore.cpp
${CMAKE_CURRENT_SOURCE_DIR}/test_ban.cpp
--- /dev/null
+/*
+Minetest
+Copyright (C) 2018 bendeutsch, Ben Deutsch <ben@bendeutsch.de>
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation; either version 2.1 of the License, or
+(at your option) any later version.
+
+This program 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 Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+*/
+
+#include "test.h"
+
+#include <algorithm>
+#include "database/database-files.h"
+#include "database/database-sqlite3.h"
+#include "util/string.h"
+#include "filesys.h"
+
+namespace
+{
+// Anonymous namespace to create classes that are only
+// visible to this file
+//
+// These are helpers that return a *AuthDatabase and
+// allow us to run the same tests on different databases and
+// database acquisition strategies.
+
+class AuthDatabaseProvider
+{
+public:
+ virtual ~AuthDatabaseProvider() = default;
+ virtual AuthDatabase *getAuthDatabase() = 0;
+};
+
+class FixedProvider : public AuthDatabaseProvider
+{
+public:
+ FixedProvider(AuthDatabase *auth_db) : auth_db(auth_db){};
+ virtual ~FixedProvider(){};
+ virtual AuthDatabase *getAuthDatabase() { return auth_db; };
+
+private:
+ AuthDatabase *auth_db;
+};
+
+class FilesProvider : public AuthDatabaseProvider
+{
+public:
+ FilesProvider(const std::string &dir) : dir(dir){};
+ virtual ~FilesProvider() { delete auth_db; };
+ virtual AuthDatabase *getAuthDatabase()
+ {
+ delete auth_db;
+ auth_db = new AuthDatabaseFiles(dir);
+ return auth_db;
+ };
+
+private:
+ std::string dir;
+ AuthDatabase *auth_db = nullptr;
+};
+
+class SQLite3Provider : public AuthDatabaseProvider
+{
+public:
+ SQLite3Provider(const std::string &dir) : dir(dir){};
+ virtual ~SQLite3Provider() { delete auth_db; };
+ virtual AuthDatabase *getAuthDatabase()
+ {
+ delete auth_db;
+ auth_db = new AuthDatabaseSQLite3(dir);
+ return auth_db;
+ };
+
+private:
+ std::string dir;
+ AuthDatabase *auth_db = nullptr;
+};
+}
+
+class TestAuthDatabase : public TestBase
+{
+public:
+ TestAuthDatabase()
+ {
+ TestManager::registerTestModule(this);
+ // fixed directory, for persistence
+ test_dir = getTestTempDirectory();
+ }
+ const char *getName() { return "TestAuthDatabase"; }
+
+ void runTests(IGameDef *gamedef);
+ void runTestsForCurrentDB();
+
+ void testRecallFail();
+ void testCreate();
+ void testRecall();
+ void testChange();
+ void testRecallChanged();
+ void testChangePrivileges();
+ void testRecallChangedPrivileges();
+ void testListNames();
+ void testDelete();
+
+private:
+ std::string test_dir;
+ AuthDatabaseProvider *auth_provider;
+};
+
+static TestAuthDatabase g_test_instance;
+
+void TestAuthDatabase::runTests(IGameDef *gamedef)
+{
+ // Each set of tests is run twice for each database type:
+ // one where we reuse the same AuthDatabase object (to test local caching),
+ // and one where we create a new AuthDatabase object for each call
+ // (to test actual persistence).
+
+ rawstream << "-------- Files database (same object)" << std::endl;
+
+ AuthDatabase *auth_db = new AuthDatabaseFiles(test_dir);
+ auth_provider = new FixedProvider(auth_db);
+
+ runTestsForCurrentDB();
+
+ delete auth_db;
+ delete auth_provider;
+
+ // reset database
+ fs::DeleteSingleFileOrEmptyDirectory(test_dir + DIR_DELIM + "auth.txt");
+
+ rawstream << "-------- Files database (new objects)" << std::endl;
+
+ auth_provider = new FilesProvider(test_dir);
+
+ runTestsForCurrentDB();
+
+ delete auth_provider;
+
+ rawstream << "-------- SQLite3 database (same object)" << std::endl;
+
+ auth_db = new AuthDatabaseSQLite3(test_dir);
+ auth_provider = new FixedProvider(auth_db);
+
+ runTestsForCurrentDB();
+
+ delete auth_db;
+ delete auth_provider;
+
+ // reset database
+ fs::DeleteSingleFileOrEmptyDirectory(test_dir + DIR_DELIM + "auth.sqlite");
+
+ rawstream << "-------- SQLite3 database (new objects)" << std::endl;
+
+ auth_provider = new SQLite3Provider(test_dir);
+
+ runTestsForCurrentDB();
+
+ delete auth_provider;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+void TestAuthDatabase::runTestsForCurrentDB()
+{
+ TEST(testRecallFail);
+ TEST(testCreate);
+ TEST(testRecall);
+ TEST(testChange);
+ TEST(testRecallChanged);
+ TEST(testChangePrivileges);
+ TEST(testRecallChangedPrivileges);
+ TEST(testListNames);
+ TEST(testDelete);
+ TEST(testRecallFail);
+}
+
+void TestAuthDatabase::testRecallFail()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ // no such user yet
+ UASSERT(!auth_db->getAuth("TestName", authEntry));
+}
+
+void TestAuthDatabase::testCreate()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ authEntry.name = "TestName";
+ authEntry.password = "TestPassword";
+ authEntry.privileges.emplace_back("shout");
+ authEntry.privileges.emplace_back("interact");
+ authEntry.last_login = 1000;
+ UASSERT(auth_db->createAuth(authEntry));
+}
+
+void TestAuthDatabase::testRecall()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ UASSERT(auth_db->getAuth("TestName", authEntry));
+ UASSERTEQ(std::string, authEntry.name, "TestName");
+ UASSERTEQ(std::string, authEntry.password, "TestPassword");
+ // the order of privileges is unimportant
+ std::sort(authEntry.privileges.begin(), authEntry.privileges.end());
+ UASSERTEQ(std::string, str_join(authEntry.privileges, ","), "interact,shout");
+}
+
+void TestAuthDatabase::testChange()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ UASSERT(auth_db->getAuth("TestName", authEntry));
+ authEntry.password = "NewPassword";
+ authEntry.last_login = 1002;
+ UASSERT(auth_db->saveAuth(authEntry));
+}
+
+void TestAuthDatabase::testRecallChanged()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ UASSERT(auth_db->getAuth("TestName", authEntry));
+ UASSERTEQ(std::string, authEntry.password, "NewPassword");
+ // the order of privileges is unimportant
+ std::sort(authEntry.privileges.begin(), authEntry.privileges.end());
+ UASSERTEQ(std::string, str_join(authEntry.privileges, ","), "interact,shout");
+ UASSERTEQ(u64, authEntry.last_login, 1002);
+}
+
+void TestAuthDatabase::testChangePrivileges()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ UASSERT(auth_db->getAuth("TestName", authEntry));
+ authEntry.privileges.clear();
+ authEntry.privileges.emplace_back("interact");
+ authEntry.privileges.emplace_back("fly");
+ authEntry.privileges.emplace_back("dig");
+ UASSERT(auth_db->saveAuth(authEntry));
+}
+
+void TestAuthDatabase::testRecallChangedPrivileges()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ AuthEntry authEntry;
+
+ UASSERT(auth_db->getAuth("TestName", authEntry));
+ // the order of privileges is unimportant
+ std::sort(authEntry.privileges.begin(), authEntry.privileges.end());
+ UASSERTEQ(std::string, str_join(authEntry.privileges, ","), "dig,fly,interact");
+}
+
+void TestAuthDatabase::testListNames()
+{
+
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+ std::vector<std::string> list;
+
+ AuthEntry authEntry;
+
+ authEntry.name = "SecondName";
+ authEntry.password = "SecondPassword";
+ authEntry.privileges.emplace_back("shout");
+ authEntry.privileges.emplace_back("interact");
+ authEntry.last_login = 1003;
+ auth_db->createAuth(authEntry);
+
+ auth_db->listNames(list);
+ // not necessarily sorted, so sort before comparing
+ std::sort(list.begin(), list.end());
+ UASSERTEQ(std::string, str_join(list, ","), "SecondName,TestName");
+}
+
+void TestAuthDatabase::testDelete()
+{
+ AuthDatabase *auth_db = auth_provider->getAuthDatabase();
+
+ UASSERT(!auth_db->deleteAuth("NoSuchName"));
+ UASSERT(auth_db->deleteAuth("TestName"));
+ // second try, expect failure
+ UASSERT(!auth_db->deleteAuth("TestName"));
+}
void testIsNumber();
void testIsPowerOfTwo();
void testMyround();
+ void testStringJoin();
};
static TestUtilities g_test_instance;
TEST(testIsNumber);
TEST(testIsPowerOfTwo);
TEST(testMyround);
+ TEST(testStringJoin);
}
////////////////////////////////////////////////////////////////////////////////
UASSERT(myround(-6.5f) == -7);
}
+void TestUtilities::testStringJoin()
+{
+ std::vector<std::string> input;
+ UASSERT(str_join(input, ",") == "");
+
+ input.emplace_back("one");
+ UASSERT(str_join(input, ",") == "one");
+
+ input.emplace_back("two");
+ UASSERT(str_join(input, ",") == "one,two");
+
+ input.emplace_back("three");
+ UASSERT(str_join(input, ",") == "one,two,three");
+
+ input[1] = "";
+ UASSERT(str_join(input, ",") == "one,,three");
+
+ input[1] = "two";
+ UASSERT(str_join(input, " and ") == "one and two and three");
+}
+
return ss.str();
}
+
+/**
+ * Joins a vector of strings by the string \p delimiter.
+ *
+ * @return A std::string
+ */
+inline std::string str_join(const std::vector<std::string> &list,
+ const std::string &delimiter)
+{
+ std::ostringstream oss;
+ bool first = true;
+ for (const auto &part : list) {
+ if (!first)
+ oss << delimiter;
+ oss << part;
+ first = false;
+ }
+ return oss.str();
+}