Ctrl K
ComunidadGuías…

server/src/provider_scan_protocol.cpp

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * @file src/provider_scan_protocol.cpp
 * @brief Bounded protocol for unprivileged Linux provider discovery.
 */
#include "provider_scan_protocol.h"

#include <algorithm>
#include <cerrno>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <limits>
#include <nlohmann/json.hpp>
#include <set>
#include <system_error>

#if defined(__linux__)
  #include <fcntl.h>
  #include <poll.h>
  #include <signal.h>
  #include <spawn.h>
  #include <sys/types.h>
  #include <sys/wait.h>
  #include <unistd.h>

extern char **environ;
#endif

namespace fs = std::filesystem;

namespace platf::provider_scan {
  namespace {
    using json = nlohmann::json;

    constexpr std::size_t max_name_bytes = 512;
    constexpr std::size_t max_label_bytes = 128;

#if defined(__linux__)
    constexpr auto child_reap_timeout = std::chrono::seconds {1};
    constexpr auto child_reap_poll_interval = std::chrono::milliseconds {10};

    bool reap_child_until(pid_t child, int &status,
                          std::chrono::steady_clock::time_point deadline) {
      while (true) {
        const auto waited = waitpid(child, &status, WNOHANG);
        if (waited == child || (waited < 0 && errno == ECHILD)) return true;
        if (waited < 0 && errno != EINTR) return false;

        const auto now = std::chrono::steady_clock::now();
        if (now >= deadline) return false;
        if (waited < 0) continue;

        const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now);
        const auto delay = std::min(remaining, child_reap_poll_interval);
        const int wait_ms = static_cast<int>(std::max<std::int64_t>(delay.count(), 1));
        if (poll(nullptr, 0, wait_ms) < 0 && errno != EINTR) return false;
      }
    }

    bool terminate_and_reap_child(pid_t child, int &status) {
      const auto deadline = std::chrono::steady_clock::now() + child_reap_timeout;
      while (true) {
        const auto waited = waitpid(child, &status, WNOHANG);
        if (waited == child || (waited < 0 && errno == ECHILD)) return true;
        if (waited == 0) break;
        if (errno != EINTR) return false;
        if (std::chrono::steady_clock::now() >= deadline) return false;
      }

      const bool termination_requested = kill(child, SIGKILL) == 0 || errno == ESRCH;
      // Always make a bounded reap attempt. The child may have exited between
      // waitpid() and kill(), and no cleanup error may turn into a blocking
      // wait on the machine host.
      const bool reaped = reap_child_until(child, status, deadline);
      return termination_requested && reaped;
    }
#endif

    bool safe_text(const std::string &value, std::size_t maximum, bool allow_empty = true) {
      if ((!allow_empty && value.empty()) || value.size() > maximum) return false;
      return std::none_of(value.begin(), value.end(), [](unsigned char character) {
        return character < 0x20 || character == 0x7f;
      });
    }

    bool steam_provider(const std::string &runner, const std::string &service) {
      const auto is_steam = [](const std::string &value) {
        return value.size() == 5 &&
               (value[0] == 's' || value[0] == 'S') &&
               (value[1] == 't' || value[1] == 'T') &&
               (value[2] == 'e' || value[2] == 'E') &&
               (value[3] == 'a' || value[3] == 'A') &&
               (value[4] == 'm' || value[4] == 'M');
      };
      return is_steam(runner) || is_steam(service);
    }

    template<typename T>
    bool exact_keys(const json &value, std::initializer_list<T> expected) {
      if (!value.is_object() || value.size() != expected.size()) return false;
      return std::all_of(expected.begin(), expected.end(), [&](const auto *key) {
        return value.contains(key);
      });
    }

    bool unsigned_number(const json &value, std::uint64_t &result) {
      if (!value.is_number_unsigned()) return false;
      result = value.get<std::uint64_t>();
      return true;
    }

    bool signed_number(const json &value, std::int64_t &result) {
      if (value.is_number_unsigned()) {
        const auto number = value.get<std::uint64_t>();
        if (number > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
          return false;
        }
        result = static_cast<std::int64_t>(number);
        return true;
      }
      if (!value.is_number_integer()) return false;
      result = value.get<std::int64_t>();
      return true;
    }

    bool safe_string_field(const json &object, const char *key, std::string &result,
                           std::size_t maximum, bool allow_empty = true) {
      const auto &value = object.at(key);
      if (!value.is_string()) return false;
      result = value.get<std::string>();
      return safe_text(result, maximum, allow_empty);
    }

    bool valid_header(const json &root, std::string_view provider) {
      return root.at("schema").is_string() && root.at("schema").get<std::string>() == schema_name &&
             root.at("version").is_number_unsigned() && root.at("version").get<std::uint64_t>() == schema_version &&
             root.at("provider").is_string() && root.at("provider").get<std::string>() == provider &&
             root.at("games").is_array() && root.at("games").size() <= max_games;
    }

    std::string artwork_revision(std::initializer_list<fs::path> paths) {
      std::uint64_t hash = 1469598103934665603ULL;
      bool found = false;
      for (const auto &path : paths) {
        std::error_code ec;
        if (path.empty() || !fs::is_regular_file(path, ec)) continue;
        const auto size = fs::file_size(path, ec);
        if (ec) continue;
        const auto time = fs::last_write_time(path, ec);
        if (ec) continue;
        found = true;
        const auto value = path.generic_string() + ":" + std::to_string(size) + ":" +
                           std::to_string(time.time_since_epoch().count());
        for (const unsigned char byte : value) hash = (hash ^ byte) * 1099511628211ULL;
      }
      return found ? std::to_string(hash) : std::string {};
    }

    bool decode_artwork_revision(const json &entry, std::string &revision) {
      if (!entry.is_object()) return false;
      if (!entry.contains("artwork_revision")) return true;
      if (!safe_string_field(entry, "artwork_revision", revision, 20)) return false;
      return std::all_of(revision.begin(), revision.end(), [](unsigned char c) { return c >= '0' && c <= '9'; });
    }

    json steam_game_json(const steam::game_t &game) {
      return {
        {"app_id", game.app_id},
        {"artwork_revision", artwork_revision({game.artwork_path, game.boxart_path, game.icon_path})},
        {"name", game.name},
        {"app_type", game.app_type},
        {"installed", game.installed},
        {"last_played", game.last_played},
        {"playtime_minutes", game.playtime_minutes},
        {"state_flags", game.state_flags},
        {"last_updated", game.last_updated},
      };
    }

    json lutris_game_json(const lutris::game_t &game) {
      return {
        {"id", game.id},
        {"artwork_revision", artwork_revision({game.artwork_path, game.icon_path})},
        {"name", game.name},
        {"slug", game.slug},
        {"runner", game.runner},
        {"platform", game.platform},
        {"service", game.service},
        {"service_id", game.service_id},
        {"last_played", game.last_played},
        {"playtime_seconds", game.playtime_seconds},
      };
    }

    std::optional<json> parse_payload(std::string_view payload) {
      if (payload.empty() || payload.size() > max_payload_bytes) return std::nullopt;
      try {
        return json::parse(payload.begin(), payload.end());
      } catch (const json::exception &) {
        return std::nullopt;
      }
    }

    std::optional<std::string> bounded_dump(const json &root) {
      try {
        auto payload = root.dump();
        if (payload.size() >= max_payload_bytes) return std::nullopt;
        payload.push_back('\n');
        return payload;
      } catch (const json::exception &) {
        return std::nullopt;
      }
    }
  }  // namespace

  std::optional<std::string> encode_steam_catalog(const steam_catalog_t &catalog) {
    if (catalog.games.size() > max_games) return std::nullopt;
    json root {
      {"schema", schema_name},
      {"version", schema_version},
      {"provider", "steam"},
      {"available", catalog.available},
      {"games", json::array()},
    };
    for (const auto &game : catalog.games) root["games"].push_back(steam_game_json(game));
    const auto payload = bounded_dump(root);
    return payload && decode_steam_catalog(*payload) ? payload : std::nullopt;
  }

  std::optional<std::string> encode_lutris_catalog(const lutris_catalog_t &catalog) {
    if (catalog.games.size() > max_games) return std::nullopt;
    json root {
      {"schema", schema_name},
      {"version", schema_version},
      {"provider", "lutris"},
      {"database_available", catalog.database_available},
      {"executable_available", catalog.executable_available},
      {"games", json::array()},
    };
    for (const auto &game : catalog.games) root["games"].push_back(lutris_game_json(game));
    const auto payload = bounded_dump(root);
    return payload && decode_lutris_catalog(*payload) ? payload : std::nullopt;
  }

  std::optional<steam_catalog_t> decode_steam_catalog(std::string_view payload) {
    const auto parsed = parse_payload(payload);
    if (!parsed || !exact_keys(*parsed, {"schema", "version", "provider", "available", "games"}) ||
        !valid_header(*parsed, "steam") || !parsed->at("available").is_boolean()) return std::nullopt;

    steam_catalog_t result;
    result.available = parsed->at("available").get<bool>();
    if (!result.available && !parsed->at("games").empty()) return std::nullopt;
    std::set<std::uint32_t> seen;
    for (auto entry : parsed->at("games")) {
      std::string revision;
      if (!decode_artwork_revision(entry, revision)) return std::nullopt;
      entry.erase("artwork_revision");
      if (!exact_keys(entry, {"app_id", "name", "app_type", "installed",
                              "last_played", "playtime_minutes", "state_flags", "last_updated"})) return std::nullopt;
      steam::game_t game;
      game.session_artwork_revision = revision;
      std::uint64_t number = 0;
      if (!unsigned_number(entry.at("app_id"), number) || number == 0 || number > UINT32_MAX ||
          !seen.insert(static_cast<std::uint32_t>(number)).second) return std::nullopt;
      game.app_id = static_cast<std::uint32_t>(number);
      game.stable_id = "steam:" + std::to_string(game.app_id);
      if (!safe_string_field(entry, "name", game.name, max_name_bytes) ||
          !safe_string_field(entry, "app_type", game.app_type, max_label_bytes) ||
          !entry.at("installed").is_boolean()) return std::nullopt;
      game.installed = entry.at("installed").get<bool>();
      if (!unsigned_number(entry.at("last_played"), game.last_played) ||
          !unsigned_number(entry.at("playtime_minutes"), game.playtime_minutes) ||
          !unsigned_number(entry.at("state_flags"), number) || number > UINT32_MAX) return std::nullopt;
      game.state_flags = static_cast<std::uint32_t>(number);
      if (!unsigned_number(entry.at("last_updated"), game.last_updated)) return std::nullopt;
      // All paths, local artwork, and direct-launch fields remain empty by
      // design. Only the dropped-UID scanner ever reads those user files.
      result.games.push_back(std::move(game));
    }
    return result;
  }

  std::optional<lutris_catalog_t> decode_lutris_catalog(std::string_view payload) {
    const auto parsed = parse_payload(payload);
    if (!parsed || !exact_keys(*parsed, {"schema", "version", "provider", "database_available",
                                        "executable_available", "games"}) ||
        !valid_header(*parsed, "lutris") || !parsed->at("database_available").is_boolean() ||
        !parsed->at("executable_available").is_boolean()) return std::nullopt;

    lutris_catalog_t result;
    result.database_available = parsed->at("database_available").get<bool>();
    result.executable_available = parsed->at("executable_available").get<bool>();
    if (!result.database_available && !parsed->at("games").empty()) return std::nullopt;
    std::set<std::int64_t> seen;
    for (auto entry : parsed->at("games")) {
      std::string revision;
      if (!decode_artwork_revision(entry, revision)) return std::nullopt;
      entry.erase("artwork_revision");
      if (!exact_keys(entry, {"id", "name", "slug", "runner", "platform", "service",
                              "service_id", "last_played", "playtime_seconds"})) return std::nullopt;
      lutris::game_t game;
      game.session_artwork_revision = revision;
      if (!signed_number(entry.at("id"), game.id) || game.id <= 0 || !seen.insert(game.id).second ||
          !safe_string_field(entry, "name", game.name, max_name_bytes) ||
          !safe_string_field(entry, "slug", game.slug, max_label_bytes) ||
          !safe_string_field(entry, "runner", game.runner, max_label_bytes) ||
          !safe_string_field(entry, "platform", game.platform, max_label_bytes) ||
          !safe_string_field(entry, "service", game.service, max_label_bytes) ||
          !safe_string_field(entry, "service_id", game.service_id, max_label_bytes) ||
          !signed_number(entry.at("last_played"), game.last_played) || game.last_played < 0 ||
          !entry.at("playtime_seconds").is_number()) return std::nullopt;
      game.playtime_seconds = entry.at("playtime_seconds").get<double>();
      if (!std::isfinite(game.playtime_seconds) || game.playtime_seconds < 0.0 || game.playtime_seconds > 1.0e12) {
        return std::nullopt;
      }
      if (steam_provider(game.runner, game.service)) {
        if (game.service_id.empty() || !std::all_of(game.service_id.begin(), game.service_id.end(), [](unsigned char c) {
              return c >= '0' && c <= '9';
            })) return std::nullopt;
        game.stable_id = "steam:" + game.service_id;
      } else {
        game.stable_id = "lutris:" + std::to_string(game.id);
      }
      // Every path remains empty: the privileged host has no reason to open
      // it, and launch is delegated by numeric Lutris ID.
      result.games.push_back(std::move(game));
    }
    return result;
  }

#if defined(__linux__)
  namespace detail {
    std::optional<std::string> capture_command(const fs::path &executable, std::string_view verb,
                                               capture_limits_t limits) {
      // Provider scans and artwork belong to the desktop session. Avoid
      // spawning rejected broker requests on every greeter poll. The broker
      // still independently validates the authoritative session identity.
      const auto *machine_host = std::getenv("ARTLIGHT_MACHINE_HOST");
      const auto *session_role = std::getenv("ARTLIGHT_SESSION_ROLE");
      if (machine_host && *machine_host && (!session_role || std::string_view {session_role} != "desktop")) {
        return std::nullopt;
      }

      if (!executable.is_absolute() || verb.empty() || verb.size() > 64 || limits.timeout.count() <= 0 || limits.maximum_bytes == 0 || limits.maximum_bytes > max_payload_bytes) {
        return std::nullopt;
      }
      std::string verb_string {verb};
      if (!safe_text(verb_string, 64, false)) return std::nullopt;

      int output_pipe[2] {-1, -1};
      if (pipe2(output_pipe, O_CLOEXEC) != 0) return std::nullopt;
      const int read_flags = fcntl(output_pipe[0], F_GETFL);
      if (read_flags < 0 || fcntl(output_pipe[0], F_SETFL, read_flags | O_NONBLOCK) != 0) {
        close(output_pipe[0]);
        close(output_pipe[1]);
        return std::nullopt;
      }

      posix_spawn_file_actions_t actions;
      if (posix_spawn_file_actions_init(&actions) != 0) {
        close(output_pipe[0]);
        close(output_pipe[1]);
        return std::nullopt;
      }
      bool actions_ok = posix_spawn_file_actions_adddup2(&actions, output_pipe[1], STDOUT_FILENO) == 0 &&
                        posix_spawn_file_actions_addclose(&actions, output_pipe[0]) == 0 &&
                        posix_spawn_file_actions_addclose(&actions, output_pipe[1]) == 0;
      std::string executable_string = executable.string();
      char *arguments[] = {executable_string.data(), verb_string.data(), nullptr};
      pid_t child = -1;
      const int spawn_error = actions_ok ?
        posix_spawn(&child, executable_string.c_str(), &actions, nullptr, arguments, environ) : EINVAL;
      posix_spawn_file_actions_destroy(&actions);
      close(output_pipe[1]);
      if (spawn_error != 0) {
        close(output_pipe[0]);
        return std::nullopt;
      }

      const auto deadline = std::chrono::steady_clock::now() + limits.timeout;
      std::string output;
      output.reserve(std::min<std::size_t>(limits.maximum_bytes, 64U * 1024U));
      bool eof = false;
      bool child_done = false;
      bool failed = false;
      int status = 0;
      while (!failed && !(eof && child_done)) {
        char buffer[8192];
        while (!eof) {
          const auto count = read(output_pipe[0], buffer, sizeof(buffer));
          if (count > 0) {
            if (output.size() + static_cast<std::size_t>(count) > limits.maximum_bytes) {
              failed = true;
              break;
            }
            output.append(buffer, static_cast<std::size_t>(count));
          } else if (count == 0) {
            eof = true;
            break;
          } else if (errno == EINTR) {
            continue;
          } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break;
          } else {
            failed = true;
            break;
          }
        }
        if (!child_done) {
          const auto waited = waitpid(child, &status, WNOHANG);
          if (waited == child) child_done = true;
          else if (waited < 0 && errno != EINTR) failed = true;
        }
        const auto now = std::chrono::steady_clock::now();
        if (!(eof && child_done) && now >= deadline) {
          failed = true;
          break;
        }
        if (!failed && !(eof && child_done)) {
          const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now);
          const int wait_ms = static_cast<int>(std::clamp<std::int64_t>(remaining.count(), 1, 100));
          pollfd descriptor {output_pipe[0], static_cast<short>(eof ? 0 : POLLIN | POLLHUP), 0};
          const auto polled = poll(eof ? nullptr : &descriptor, eof ? 0 : 1, wait_ms);
          if (polled < 0 && errno != EINTR) failed = true;
        }
      }
      close(output_pipe[0]);
      if (failed) {
        if (!child_done) {
          // Failure cleanup has its own short monotonic deadline because the
          // command deadline may already have elapsed. Never let an abnormal
          // child or wait implementation stall the privileged host forever.
          (void) terminate_and_reap_child(child, status);
        }
        return std::nullopt;
      }
      return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? std::optional<std::string>(std::move(output)) : std::nullopt;
    }
  }  // namespace detail

  std::optional<steam_catalog_t> scan_steam_session() {
    const auto payload = detail::capture_command(
      "/usr/libexec/vibeshine/artlight-session-exec", "provider-steam-scan",
      {std::chrono::duration_cast<std::chrono::milliseconds>(command_timeout), max_payload_bytes});
    return payload ? decode_steam_catalog(*payload) : std::nullopt;
  }

  std::optional<lutris_catalog_t> scan_lutris_session() {
    const auto payload = detail::capture_command(
      "/usr/libexec/vibeshine/artlight-session-exec", "provider-lutris-scan",
      {std::chrono::duration_cast<std::chrono::milliseconds>(command_timeout), max_payload_bytes});
    return payload ? decode_lutris_catalog(*payload) : std::nullopt;
  }
#endif
}  // namespace platf::provider_scan