diff --git a/apps/ll-cli/src/main.cpp b/apps/ll-cli/src/main.cpp index 1fa7afa4b..0fa04e527 100644 --- a/apps/ll-cli/src/main.cpp +++ b/apps/ll-cli/src/main.cpp @@ -5,6 +5,8 @@ */ #include "configure.h" #include "linglong/api/dbus/v1/dbus_peer.h" +#include "linglong/api/types/v1/Generators.hpp" +#include "linglong/api/types/v1/PackageInfoV2.hpp" #include "linglong/cli/cli.h" #include "linglong/cli/cli_printer.h" #include "linglong/cli/dbus_notifier.h" @@ -28,15 +30,29 @@ #include #include +#include #include #include +#include #include #include +#include + +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include using namespace linglong::utils::error; using namespace linglong::package; using namespace linglong::cli; @@ -138,6 +154,45 @@ int lockCheck() noexcept return lock_info.l_pid; } +static int waitForRepoLockRelease(std::chrono::seconds timeout) +{ + using namespace std::chrono_literals; + auto start = std::chrono::steady_clock::now(); + int lastPid = -1; + bool printed = false; + while (true) { + int lockOwner = lockCheck(); + if (lockOwner == -1) { + return -1; + } + if (lockOwner == 0) { + if (printed) { + std::fprintf(stderr, "\r\33[K"); + std::fflush(stderr); + } + return 0; + } + if (lockOwner != lastPid) { + std::fprintf(stderr, + "Repository is being operated by process %d, waiting for lock...\n", + lockOwner); + std::fflush(stderr); + lastPid = lockOwner; + printed = true; + } + auto waited = std::chrono::steady_clock::now() - start; + if (waited >= timeout) { + std::fprintf(stderr, + "Timed out waiting for repository lock held by PID %d.\n" + "Please retry later or run 'systemctl restart ll-package-manager'.\n", + lockOwner); + std::fflush(stderr); + return 1; + } + std::this_thread::sleep_for(1s); + } +} + // Validator for string inputs CLI::Validator validatorString{ [](const std::string ¶meter) { @@ -326,9 +381,6 @@ void addUninstallCommand(CLI::App &commandParser, cliUninstall->add_option("--module", uninstallOptions.module, _("Uninstall a specify module")) ->type_name("MODULE") ->check(validatorString); - cliUninstall->add_flag("--force", - uninstallOptions.forceOpt, - _("Force uninstall base or runtime")); // below options are used for compatibility with old ll-cli const auto &pruneDescription = std::string{ _("Remove all unused modules") }; @@ -355,9 +407,6 @@ void addUpgradeCommand(CLI::App &commandParser, _("Specify the application ID. If it not be specified, all " "applications will be upgraded")) ->check(validatorString); - cliUpgrade->add_flag("--deps-only", - upgradeOptions.depsOnly, - _("Only upgrade dependencies of application")); } // Function to add the search subcommand @@ -582,7 +631,6 @@ void addInspectCommand(CLI::App &commandParser, cliInspect->require_subcommand(1); - // 创建 inspect dir 子命令 auto *cliInspectDir = cliInspect->add_subcommand( "dir", _("Display the data(bundle) directory of the installed(running) application")); @@ -634,22 +682,775 @@ linglong::utils::error::Result initOSTreeRepo() return repo; } + +// ===== begin: ll-cli config helpers ===== +using json = nlohmann::json; + +static std::string configUsageLines() +{ + return std::string{ + _(" ll-cli config set-extensions [--global | --app | --base ] ext1,ext2\n" + " ll-cli config add-extensions [--global | --app | --base ] ext1,ext2\n" + " ll-cli config set-env [--global | --app | --base ] KEY=VAL [KEY=VAL ...]\n" + " ll-cli config unset-env [--global | --app | --base ] KEY [KEY ...]\n" + " ll-cli config add-fs [--global | --app | --base ] --host PATH --target PATH " + "[--mode ro|rw] [--persist]\n" + " ll-cli config rm-fs [--global | --app | --base ] (--target PATH | --index N)\n" + " ll-cli config add-fs-allow [--global | --app | --base ] --host PATH --target PATH " + "[--mode ro|rw] [--persist]\n" + " ll-cli config rm-fs-allow [--global | --app | --base ] (--target PATH | --index N)\n" + " ll-cli config clear-fs-allow [--global | --app | --base ]\n" + " ll-cli config set-command [--global | --app | --base ] [--entrypoint P] [--cwd D] " + "[--args-prefix \"...\"] [--args-suffix \"...\"] [KEY=VAL ...]\n" + " ll-cli config unset-command [--global | --app | --base ] \n" + " ll-cli config enable-permission [--global | --app | --base ] --category NAME PERM [PERM ...]\n" + " ll-cli config disable-permission [--global | --app | --base ] --category NAME PERM [PERM ...]\n" + " ll-cli config add-udev-rule [--global | --app | --base ] --name NAME --file PATH\n" + " ll-cli config rm-udev-rule [--global | --app | --base ] --name NAME\n" + " ll-cli config allow-config-home --global APPID [APPID ...]\n" + " ll-cli config deny-config-home --global APPID [APPID ...]\n") }; +} + +static std::string configShortHelp() +{ + return std::string{ _("Configuration commands:\n" + " config Manage ll-cli configuration (see `ll-cli config --help`)\n") }; +} + +static std::string configFooterMessage() +{ + return std::string{ _("If you found any problems during use,\n" + "You can report bugs to the linyaps team under this project: " + "https://github.com/OpenAtom-Linyaps/linyaps/issues") }; +} + +[[maybe_unused]] static void printConfigUsage(FILE *stream = stderr) +{ + auto usageLines = configUsageLines(); + std::fprintf(stream, "%s\n%s", _("Usage:"), usageLines.c_str()); +} + +enum class Scope { Global, App, Base }; + +static std::filesystem::path getBaseConfigDir() +{ + const char *xdg = ::getenv("XDG_CONFIG_HOME"); + if (xdg && xdg[0]) { + return std::filesystem::path(xdg) / "linglong"; + } + const char *home = ::getenv("HOME"); + if (home && home[0]) { + return std::filesystem::path(home) / ".config" / "linglong"; + } + return {}; +} + +static std::filesystem::path getSystemConfigDir() +{ + return std::filesystem::path(LINGLONG_DATA_DIR) / "config"; +} + +static std::filesystem::path buildConfigPath(const std::filesystem::path &base, + Scope scope, + const std::string &appId, + const std::string &baseId) +{ + if (base.empty()) { + return {}; + } + switch (scope) { + case Scope::Global: + return base / "config.json"; + case Scope::App: + return base / "apps" / appId / "config.json"; + case Scope::Base: + return base / "base" / baseId / "config.json"; + } + return {}; +} + +static std::filesystem::path getConfigPath(Scope scope, + const std::string &appId, + const std::string &baseId) +{ + return buildConfigPath(getBaseConfigDir(), scope, appId, baseId); +} + +static std::vector getConfigSearchPaths(Scope scope, + const std::string &appId, + const std::string &baseId) +{ + std::vector paths; + std::unordered_set seen; + auto addPath = [&](const std::filesystem::path &candidate) { + if (candidate.empty()) { + return; + } + auto normalized = candidate.lexically_normal(); + auto key = normalized.string(); + if (!key.empty() && seen.insert(key).second) { + paths.emplace_back(std::move(normalized)); + } + }; + + addPath(buildConfigPath(getBaseConfigDir(), scope, appId, baseId)); + addPath(buildConfigPath(getSystemConfigDir(), scope, appId, baseId)); + + return paths; +} + +static bool ensureParentDir(const std::filesystem::path &p) +{ + std::error_code ec; + auto parent = p.parent_path(); + if (parent.empty()) { + return true; + } + return std::filesystem::create_directories(parent, ec) || std::filesystem::exists(parent); +} + +static std::optional readJsonIfExists(const std::filesystem::path &p, bool *existed = nullptr) +{ + try { + std::error_code ec; + if (!std::filesystem::exists(p, ec)) { + if (existed) { + *existed = false; + } + return json::object(); + } + std::ifstream in(p); + if (!in.is_open()) { + return std::nullopt; + } + if (existed) { + *existed = true; + } + json j; + in >> j; + return j.is_null() ? json::object() : j; + } catch (...) { + return std::nullopt; + } +} + +static bool writeJsonAtomic(const std::filesystem::path &p, const json &j) +{ + try { + if (!ensureParentDir(p)) { + return false; + } + auto tmp = p; + tmp += ".tmp"; + { + std::ofstream out(tmp); + if (!out.is_open()) { + return false; + } + out << j.dump(2) << "\n"; + } + std::error_code ec; + std::filesystem::rename(tmp, p, ec); + return !ec; + } catch (...) { + return false; + } +} + +static std::vector splitCsv(const std::string &s) +{ + std::vector out; + std::stringstream ss(s); + std::string tok; + while (std::getline(ss, tok, ',')) { + if (!tok.empty()) { + out.push_back(tok); + } + } + return out; +} + +static std::string trim(const std::string &s) +{ + const char *ws = " \t\r\n"; + size_t b = s.find_first_not_of(ws); + if (b == std::string::npos) { + return ""; + } + size_t e = s.find_last_not_of(ws); + return s.substr(b, e - b + 1); +} + +static void jsonSetExtensions(json &root, const std::vector &exts, bool overwrite) +{ + auto &arr = root["extensions"]; + if (!arr.is_array() || overwrite) { + arr = json::array(); + } + std::unordered_set exist; + if (arr.is_array()) { + for (auto &e : arr) { + if (e.is_string()) { + exist.insert(e.get()); + } + } + } + for (auto &s : exts) { + if (!s.empty() && !exist.count(s)) { + arr.push_back(s); + exist.insert(s); + } + } +} + +static void jsonSetEnv(json &root, const std::vector &kvs) +{ + auto &obj = root["env"]; + if (!obj.is_object()) { + obj = json::object(); + } + for (auto &kv : kvs) { + auto pos = kv.find('='); + if (pos == std::string::npos) { + continue; + } + auto k = trim(kv.substr(0, pos)); + auto v = kv.substr(pos + 1); + if (k.empty()) { + continue; + } + obj[k] = v; + } +} + +static void jsonUnsetEnv(json &root, const std::vector &keys) +{ + if (!root.contains("env") || !root["env"].is_object()) { + return; + } + for (auto &k : keys) { + root["env"].erase(k); + } +} + +struct FsArg { + std::string host, target, mode; + bool persist = false; +}; + +static void jsonAddFsTo(json &root, const FsArg &fs, const char *field) +{ + auto &arr = root[field]; + if (!arr.is_array()) { + arr = json::array(); + } + for (auto &e : arr) { + if (!e.is_object()) { + continue; + } + if (e.value("host", "") == fs.host && e.value("target", "") == fs.target) { + return; + } + } + json o = json::object(); + o["host"] = fs.host; + o["target"] = fs.target; + o["mode"] = (fs.mode == "rw" ? "rw" : "ro"); + if (fs.persist) { + o["persist"] = true; + } + arr.push_back(std::move(o)); +} + +static void jsonAddFs(json &root, const FsArg &fs) +{ + jsonAddFsTo(root, fs, "filesystem"); +} + +static void jsonAddFsAllow(json &root, const FsArg &fs) +{ + jsonAddFsTo(root, fs, "filesystem_allow_only"); +} + +static bool jsonRmFsByTargetFrom(json &root, const std::string &target, const char *field) +{ + if (!root.contains(field) || !root[field].is_array()) { + return false; + } + auto &arr = root[field]; + auto old = arr.size(); + arr.erase(std::remove_if(arr.begin(), arr.end(), [&](const json &e) { + return e.is_object() && e.value("target", "") == target; + }), + arr.end()); + return arr.size() != old; +} + +static bool jsonRmFsByTarget(json &root, const std::string &target) +{ + return jsonRmFsByTargetFrom(root, target, "filesystem"); +} + +static bool jsonRmFsAllowByTarget(json &root, const std::string &target) +{ + return jsonRmFsByTargetFrom(root, target, "filesystem_allow_only"); +} + +static bool jsonRmFsByIndexFrom(json &root, size_t idx, const char *field) +{ + if (!root.contains(field) || !root[field].is_array()) { + return false; + } + auto &arr = root[field]; + if (idx >= arr.size()) { + return false; + } + arr.erase(arr.begin() + idx); + return true; +} + +static bool jsonRmFsByIndex(json &root, size_t idx) +{ + return jsonRmFsByIndexFrom(root, idx, "filesystem"); +} + +static bool jsonRmFsAllowByIndex(json &root, size_t idx) +{ + return jsonRmFsByIndexFrom(root, idx, "filesystem_allow_only"); +} + +static void jsonClearFsAllow(json &root) +{ + root["filesystem_allow_only"] = json::array(); +} + +struct CmdSetArg { + std::string cmd; + std::optional entrypoint; + std::optional cwd; + std::vector argsPrefix; + std::vector argsSuffix; + std::vector envKVs; +}; + +static void jsonSetCommand(json &root, const CmdSetArg &a) +{ + if (a.cmd.empty()) { + return; + } + auto &cmds = root["commands"]; + if (!cmds.is_object()) { + cmds = json::object(); + } + auto &node = cmds[a.cmd]; + if (!node.is_object()) { + node = json::object(); + } + if (a.entrypoint) { + node["entrypoint"] = *a.entrypoint; + } + if (a.cwd) { + node["cwd"] = *a.cwd; + } + if (!a.argsPrefix.empty()) { + auto &arr = node["args_prefix"]; + arr = json::array(); + for (auto &s : a.argsPrefix) { + arr.push_back(s); + } + } + if (!a.argsSuffix.empty()) { + auto &arr = node["args_suffix"]; + arr = json::array(); + for (auto &s : a.argsSuffix) { + arr.push_back(s); + } + } + if (!a.envKVs.empty()) { + auto &env = node["env"]; + if (!env.is_object()) { + env = json::object(); + } + for (auto &kv : a.envKVs) { + auto pos = kv.find('='); + if (pos == std::string::npos) { + continue; + } + auto k = trim(kv.substr(0, pos)); + auto v = kv.substr(pos + 1); + if (!k.empty()) { + env[k] = v; + } + } + } +} + +static void jsonUnsetCommand(json &root, const std::string &cmd) +{ + if (!root.contains("commands") || !root["commands"].is_object()) { + return; + } + root["commands"].erase(cmd); +} + +static bool jsonAddUdevRule(json &root, const std::string &name, const std::string &content) +{ + if (name.empty() || content.empty()) { + return false; + } + auto &arr = root["udev_rules"]; + if (!arr.is_array()) { + arr = json::array(); + } + for (auto &item : arr) { + if (!item.is_object()) { + continue; + } + if (item.value("name", "") == name) { + item["content"] = content; + return true; + } + } + arr.push_back(json{ { "name", name }, { "content", content } }); + return true; +} + +static bool jsonRmUdevRule(json &root, const std::string &name) +{ + if (!root.contains("udev_rules") || !root["udev_rules"].is_array()) { + return false; + } + auto &arr = root["udev_rules"]; + auto old = arr.size(); + arr.erase(std::remove_if(arr.begin(), arr.end(), [&](const json &item) { + return item.is_object() && item.value("name", "") == name; + }), + arr.end()); + return arr.size() != old; +} + +static std::optional readTextFile(const std::filesystem::path &path) +{ + try { + std::ifstream in(path); + if (!in.is_open()) { + return std::nullopt; + } + std::stringstream buffer; + buffer << in.rdbuf(); + return buffer.str(); + } catch (...) { + return std::nullopt; + } +} + +static json &ensureConfigWhitelist(json &root, const std::string &key) +{ + auto &arr = root[key]; + if (!arr.is_array()) { + arr = json::array(); + } + return arr; +} + +static void jsonAllowConfigAccessImpl(json &root, + const std::vector &apps, + const std::string &key) +{ + auto &arr = ensureConfigWhitelist(root, key); + std::unordered_set existing; + for (const auto &entry : arr) { + if (entry.is_string()) { + existing.insert(entry.get()); + } + } + for (const auto &app : apps) { + if (!app.empty() && existing.insert(app).second) { + arr.push_back(app); + } + } +} + +static bool jsonDenyConfigAccessImpl(json &root, + const std::vector &apps, + const std::string &key) +{ + if (!root.contains(key) || !root[key].is_array()) { + return false; + } + auto &arr = root[key]; + auto old = arr.size(); + std::unordered_set targets(apps.begin(), apps.end()); + arr.erase(std::remove_if(arr.begin(), arr.end(), [&](const json &entry) { + return entry.is_string() && targets.count(entry.get()) > 0; + }), + arr.end()); + return arr.size() != old; +} + +static void jsonAllowConfigAccess(json &root, const std::vector &apps) +{ + jsonAllowConfigAccessImpl(root, apps, "config_access_whitelist"); +} + +static bool jsonDenyConfigAccess(json &root, const std::vector &apps) +{ + return jsonDenyConfigAccessImpl(root, apps, "config_access_whitelist"); +} + +static void jsonAllowHostRoot(json &root, const std::vector &apps) +{ + jsonAllowConfigAccessImpl(root, apps, "host_root_whitelist"); +} + +static bool jsonDenyHostRoot(json &root, const std::vector &apps) +{ + return jsonDenyConfigAccessImpl(root, apps, "host_root_whitelist"); +} + +static const std::map> &permissionDictionary() +{ + static const std::map> dict = { + { "filesystem", { "host", "host-os", "host-etc", "home" } }, + { "sockets", { "cups", "pcsc" } }, + { "portals", { "background", "notifications", "microphone", "speaker", "camera", "location" } }, + { "devices", { "usb", "usb-hid", "udev" } }, + }; + return dict; +} + +static std::string canonicalPermissionCategory(std::string raw) +{ + std::transform(raw.begin(), raw.end(), raw.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (raw == "filesystem" || raw == "filesystems") { + return "filesystem"; + } + if (raw == "socket" || raw == "sockets") { + return "sockets"; + } + if (raw == "portal" || raw == "portals") { + return "portals"; + } + if (raw == "device" || raw == "devices") { + return "devices"; + } + return {}; +} + +static bool isValidPermissionName(const std::string &category, const std::string &name) +{ + auto it = permissionDictionary().find(category); + if (it == permissionDictionary().end()) { + return false; + } + return std::find(it->second.begin(), it->second.end(), name) != it->second.end(); +} + +static json &ensurePermissionsCategory(json &root, const std::string &category) +{ + auto &perms = root["permissions"]; + if (!perms.is_object()) { + perms = json::object(); + } + auto &node = perms[category]; + if (!node.is_object()) { + node = json::object(); + } + return node; +} + +static void jsonSetPermission(json &root, + const std::string &category, + const std::vector &names, + bool enabled) +{ + auto canon = canonicalPermissionCategory(category); + if (canon.empty()) { + return; + } + auto &node = ensurePermissionsCategory(root, canon); + for (const auto &name : names) { + node[name] = enabled; + } +} +// ===== end: ll-cli config helpers ===== + +struct ConfigScopeOptions { + bool global = false; + std::string appId; + std::string baseId; +}; + +static void addConfigScopeOptions(CLI::App *cmd, ConfigScopeOptions &opts) +{ + auto *globalFlag = cmd->add_flag("--global", opts.global, _("Operate on global configuration")); + auto *baseOpt = cmd->add_option("--base", opts.baseId, _("Operate on base configuration")); + auto *appOpt = cmd->add_option("--app", opts.appId, _("Operate on application configuration")) + ->type_name("APPID"); + baseOpt->excludes(globalFlag); + appOpt->excludes(globalFlag); + appOpt->excludes(baseOpt); +} + +static bool resolveScopeOptions(const ConfigScopeOptions &opts, + Scope &scope, + std::string &appId, + std::string &baseId, + std::string &error) +{ + int count = (opts.global ? 1 : 0) + (!opts.appId.empty() ? 1 : 0) + (!opts.baseId.empty() ? 1 : 0); + if (count != 1) { + error = "specify exactly one of --global, --base or --app "; + return false; + } + if (opts.global) { + scope = Scope::Global; + } else if (!opts.baseId.empty()) { + scope = Scope::Base; + baseId = opts.baseId; + } else { + scope = Scope::App; + appId = opts.appId; + } + return true; +} + +static std::optional +loadCliConfigFromPackage(Scope scope, const std::string &appId, const std::string &baseId) +{ + Q_UNUSED(scope); + Q_UNUSED(appId); + Q_UNUSED(baseId); + return std::nullopt; +} + +static std::optional openConfig(Scope scope, + const std::string &appId, + const std::string &baseId) +{ + auto userPath = getConfigPath(scope, appId, baseId); + if (userPath.empty()) { + fprintf(stderr, "invalid config path\n"); + return std::nullopt; + } + bool userExists = false; + auto userJson = readJsonIfExists(userPath, &userExists); + if (!userJson) { + fprintf(stderr, "failed to read %s\n", userPath.string().c_str()); + return std::nullopt; + } + if (!userExists) { + auto searchPaths = getConfigSearchPaths(scope, appId, baseId); + for (size_t idx = 1; idx < searchPaths.size(); ++idx) { + bool existed = false; + auto fallback = readJsonIfExists(searchPaths[idx], &existed); + if (!fallback || !existed) { + continue; + } + return fallback; + } + if (auto packaged = loadCliConfigFromPackage(scope, appId, baseId)) { + return packaged; + } + } + return userJson; +} + +static bool saveConfig(Scope scope, + const std::string &appId, + const std::string &baseId, + const json &j) +{ + auto path = getConfigPath(scope, appId, baseId); + if (path.empty()) { + return false; + } + if (!writeJsonAtomic(path, j)) { + fprintf(stderr, "failed to write %s\n", path.string().c_str()); + return false; + } + printf(_("Written %s\n"), path.string().c_str()); + return true; +} + +class ConfigAwareFormatter : public CLI::Formatter { +public: + ConfigAwareFormatter(std::string shortSection, std::string fullSection, std::string footerMessage) + : shortSection_(std::move(shortSection)) + , fullSection_(std::move(fullSection)) + , footerMessage_(std::move(footerMessage)) + {} + + std::string make_help(const CLI::App *app, std::string name, CLI::AppFormatMode mode) const override + { + std::string result = Formatter::make_help(app, std::move(name), mode); + + const std::string §ion = (mode == CLI::AppFormatMode::All) ? fullSection_ : shortSection_; + if (!section.empty()) { + if (!result.empty() && result.back() != '\n') { + result.push_back('\n'); + } + result += section; + if (!section.empty() && result.back() != '\n') { + result.push_back('\n'); + } + } + + if (!footerMessage_.empty()) { + if (!result.empty() && result.back() != '\n') { + result.push_back('\n'); + } + result += footerMessage_; + if (result.back() != '\n') { + result.push_back('\n'); + } + } + + return result; + } + +private: + std::string shortSection_; + std::string fullSection_; + std::string footerMessage_; +}; + int runCliApplication(int argc, char **mainArgv) { CLI::App commandParser{ _( "linyaps CLI\n" "A CLI program to run application and manage application and runtime\n") }; + auto shortConfigHelp = configShortHelp(); + auto fullConfigHelp = shortConfigHelp + configUsageLines(); + commandParser.formatter(std::make_shared(shortConfigHelp, + fullConfigHelp, + configFooterMessage())); + commandParser.option_defaults()->group(_("Options")); + if (auto formatter = commandParser.get_formatter()) { + formatter->label("OPTIONS", _("OPTIONS")); + formatter->label("SUBCOMMAND", _("SUBCOMMAND")); + formatter->label("SUBCOMMANDS", _("SUBCOMMANDS")); + formatter->label("POSITIONALS", _("POSITIONALS")); + formatter->label("Usage", _("Usage")); + formatter->label("REQUIRED", _("REQUIRED")); + } auto argv = commandParser.ensure_utf8(mainArgv); if (argc == 1) { std::cout << commandParser.help() << std::endl; return 0; } - commandParser.get_help_ptr()->description(_("Print this help message and exit")); - commandParser.set_help_all_flag("--help-all", _("Expand all help")); + if (auto *helpOption = commandParser.get_help_ptr()) { + helpOption->description(_("Print this help message and exit")); + helpOption->group(_("Options")); + } + if (auto *helpAllOption = commandParser.set_help_all_flag("--help-all", _("Expand all help"))) { + helpAllOption->group(_("Options")); + } commandParser.usage(_("Usage: ll-cli [OPTIONS] [SUBCOMMAND]")); - commandParser.footer(_(R"(If you found any problems during use, -You can report bugs to the linyaps team under this project: https://github.com/OpenAtom-Linyaps/linyaps/issues)")); + commandParser.footer(""); // group empty will hide command constexpr auto CliHiddenGroup = ""; @@ -694,6 +1495,412 @@ You can report bugs to the linyaps team under this project: https://github.com/O auto *CliSearchGroup = _("Finding applications and runtimes"); auto *CliRepoGroup = _("Managing remote repositories"); + bool configHandled = false; + int configResult = 0; + + auto *configCmd = commandParser.add_subcommand("config", _("Manage ll-cli configuration")); + configCmd->require_subcommand(); + configCmd->group(_("Configuration")); + + auto resolveScopeOrThrow = [&](const ConfigScopeOptions &opts) { + Scope scope = Scope::Global; + std::string appId, baseId, error; + if (!resolveScopeOptions(opts, scope, appId, baseId, error)) { + throw CLI::ValidationError("scope", error); + } + return std::make_tuple(scope, appId, baseId); + }; + + auto handleConfigResult = [&](bool ok) { + configHandled = true; + configResult = ok ? 0 : 1; + }; + + // config set-extensions / add-extensions + auto addExtensionsCommand = [&](const char *name, bool overwrite) { + auto *sub = configCmd->add_subcommand(name, _("Manage default extensions")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto exts = std::make_shared(); + sub->add_option("extensions", + *exts, + _("Comma separated extensions, e.g. ext1,ext2"))->required(); + sub->callback([&, scopeOpts, exts, overwrite]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonSetExtensions(*j, splitCsv(*exts), overwrite); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + }; + addExtensionsCommand("set-extensions", true); + addExtensionsCommand("add-extensions", false); + + // config set-env + { + auto *sub = configCmd->add_subcommand("set-env", + _("Set environment variables for target scope")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto kvs = std::make_shared>(); + sub->add_option("env", *kvs, _("KEY=VALUE entries"))->required()->expected(1, -1); + sub->callback([&, scopeOpts, kvs]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonSetEnv(*j, *kvs); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + // config unset-env + { + auto *sub = configCmd->add_subcommand("unset-env", + _("Unset environment variables for target scope")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto keys = std::make_shared>(); + sub->add_option("keys", *keys, _("Environment variable keys"))->required()->expected(1, -1); + sub->callback([&, scopeOpts, keys]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonUnsetEnv(*j, *keys); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + // config add-fs / add-fs-allow + auto addFsCommand = [&](const char *name, auto inserter) { + auto *sub = configCmd->add_subcommand(name, _("Add filesystem entry")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto fs = std::make_shared(); + fs->mode = "ro"; + sub->add_option("--host", fs->host, _("Host path to mount"))->required(); + sub->add_option("--target", fs->target, _("Target path inside container"))->required(); + sub->add_option("--mode", fs->mode, _("Mount mode (ro|rw)")) + ->check(CLI::IsMember({ "ro", "rw" })) + ->default_str("ro"); + sub->add_flag("--persist", fs->persist, _("Persist mount under sandbox storage")); + sub->callback([&, scopeOpts, fs, inserter]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + inserter(*j, *fs); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + }; + addFsCommand("add-fs", jsonAddFs); + addFsCommand("add-fs-allow", jsonAddFsAllow); + + // config rm-fs / rm-fs-allow + auto addRemoveFsCommand = [&](const char *name, auto removeTarget, auto removeIndex) { + auto *sub = configCmd->add_subcommand(name, _("Remove filesystem entry")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto target = std::make_shared>(); + auto indexStr = std::make_shared>(); + sub->add_option("--target", *target, _("Target path inside container")); + sub->add_option("--index", *indexStr, _("Index of entry in list")); + sub->callback([&, scopeOpts, target, indexStr, removeTarget, removeIndex]() { + if (!*target && !*indexStr) { + throw CLI::ValidationError("target/index", + "either --target or --index must be provided"); + } + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + bool ok = false; + if (*target) { + ok = removeTarget(*j, **target); + } + if (!ok && *indexStr) { + size_t parsedIndex = 0; + try { + parsedIndex = static_cast(std::stoul(indexStr->value())); + } catch (const std::exception &) { + fprintf(stderr, "Invalid index value: %s\n", indexStr->value().c_str()); + handleConfigResult(false); + return; + } + ok = removeIndex(*j, parsedIndex); + } + if (!ok) { + fprintf(stderr, "no filesystem entry removed\n"); + handleConfigResult(false); + return; + } + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + }; + addRemoveFsCommand("rm-fs", jsonRmFsByTarget, jsonRmFsByIndex); + addRemoveFsCommand("rm-fs-allow", jsonRmFsAllowByTarget, jsonRmFsAllowByIndex); + + // config clear-fs-allow + { + auto *sub = configCmd->add_subcommand("clear-fs-allow", + _("Clear filesystem allowlist")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + sub->callback([&, scopeOpts]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonClearFsAllow(*j); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + // config set-command + { + auto *sub = configCmd->add_subcommand("set-command", + _("Set per-command overrides (env, args etc.)")); + sub->allow_extras(); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto arg = std::make_shared(); + sub->add_option("command", arg->cmd, _("Command name"))->required(); + + auto entrypoint = std::make_shared(); + auto cwd = std::make_shared(); + auto argsPrefixRaw = std::make_shared(); + auto argsSuffixRaw = std::make_shared(); + auto envPairs = std::make_shared>(); + + sub->add_option("--entrypoint", *entrypoint, _("Override entrypoint")); + sub->add_option("--cwd", *cwd, _("Working directory")); + sub->add_option("--args-prefix", *argsPrefixRaw, _("Arguments prepended before command")); + sub->add_option("--args-suffix", *argsSuffixRaw, _("Arguments appended after command")); + sub->add_option("--env", *envPairs, _("Environment entries (KEY=VAL)"))->expected(0, -1); + + sub->callback([&, sub, scopeOpts, arg, entrypoint, cwd, argsPrefixRaw, argsSuffixRaw, envPairs]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + arg->entrypoint.reset(); + arg->cwd.reset(); + arg->argsPrefix.clear(); + arg->argsSuffix.clear(); + arg->envKVs.clear(); + + if (!entrypoint->empty()) { + arg->entrypoint = *entrypoint; + } + if (!cwd->empty()) { + arg->cwd = *cwd; + } + if (!argsPrefixRaw->empty()) { + std::stringstream ss(*argsPrefixRaw); + std::string tok; + while (ss >> tok) { + arg->argsPrefix.push_back(tok); + } + } + if (!argsSuffixRaw->empty()) { + std::stringstream ss(*argsSuffixRaw); + std::string tok; + while (ss >> tok) { + arg->argsSuffix.push_back(tok); + } + } + arg->envKVs.insert(arg->envKVs.end(), envPairs->begin(), envPairs->end()); + auto extras = sub->remaining_for_passthrough(); + arg->envKVs.insert(arg->envKVs.end(), extras.begin(), extras.end()); + + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonSetCommand(*j, *arg); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + // config unset-command + { + auto *sub = configCmd->add_subcommand("unset-command", + _("Remove per-command overrides")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto cmd = std::make_shared(); + sub->add_option("command", *cmd, _("Command name"))->required(); + sub->callback([&, scopeOpts, cmd]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonUnsetCommand(*j, *cmd); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + auto addPermissionCommand = [&](const char *name, bool enable) { + auto *sub = configCmd->add_subcommand( + name, + enable ? _("Enable sandbox permission preset") : _("Disable sandbox permission preset")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto category = std::make_shared(); + auto perms = std::make_shared>(); + sub->add_option("--category", + *category, + _("Permission category (filesystem|sockets|portals|devices)"))->required(); + sub->add_option("names", + *perms, + _("Permission names (repeat to toggle multiple entries)"))->required()->expected(1, -1); + sub->callback([&, scopeOpts, category, perms, enable]() { + auto canon = canonicalPermissionCategory(*category); + if (canon.empty()) { + throw CLI::ValidationError("category", "unknown permission category: " + *category); + } + for (const auto &perm : *perms) { + if (!isValidPermissionName(canon, perm)) { + throw CLI::ValidationError( + "permission", "invalid permission '" + perm + "' for " + canon); + } + } + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonSetPermission(*j, canon, *perms, enable); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + }; + + addPermissionCommand("enable-permission", true); + addPermissionCommand("disable-permission", false); + + // config add-udev-rule + { + auto *sub = configCmd->add_subcommand("add-udev-rule", + _("Embed a custom udev rule into configuration")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto name = std::make_shared(); + auto filePath = std::make_shared(); + sub->add_option("--name", *name, _("Rule filename (e.g. 99-custom.rules)"))->required(); + sub->add_option("--file", *filePath, _("Path to the rule file"))->required(); + sub->callback([&, scopeOpts, name, filePath]() { + auto content = readTextFile(*filePath); + if (!content) { + throw CLI::RuntimeError("failed to read rule file: " + *filePath, 1); + } + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + jsonAddUdevRule(*j, *name, *content); + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + // config rm-udev-rule + { + auto *sub = + configCmd->add_subcommand("rm-udev-rule", _("Remove a previously embedded udev rule")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto name = std::make_shared(); + sub->add_option("--name", *name, _("Rule filename to remove"))->required(); + sub->callback([&, scopeOpts, name]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + if (!jsonRmUdevRule(*j, *name)) { + throw CLI::RuntimeError("rule not found: " + *name, 1); + } + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + } + + auto addConfigHomeCommand = [&](const char *name, bool allow) { + auto *sub = configCmd->add_subcommand( + name, + allow ? _("Allow apps to access host ~/.config/linglong") : + _("Remove apps from config directory whitelist")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto apps = std::make_shared>(); + sub->add_option("apps", *apps, _("Application IDs"))->required()->expected(1, -1); + sub->callback([&, scopeOpts, apps, allow]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + if (allow) { + jsonAllowConfigAccess(*j, *apps); + } else { + if (!jsonDenyConfigAccess(*j, *apps)) { + throw CLI::RuntimeError(_("No entries were removed"), 1); + } + } + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + }; + + addConfigHomeCommand("allow-config-home", true); + addConfigHomeCommand("deny-config-home", false); + + auto addHostRootCommand = [&](const char *name, bool allow) { + auto *sub = configCmd->add_subcommand( + name, + allow ? _("Allow apps to access host root (/run/host/rootfs)") : + _("Remove apps from host root whitelist")); + auto scopeOpts = std::make_shared(); + addConfigScopeOptions(sub, *scopeOpts); + auto apps = std::make_shared>(); + sub->add_option("apps", *apps, _("Application IDs"))->required()->expected(1, -1); + sub->callback([&, scopeOpts, apps, allow]() { + auto [scope, appId, baseId] = resolveScopeOrThrow(*scopeOpts); + auto j = openConfig(scope, appId, baseId); + if (!j) { + handleConfigResult(false); + return; + } + if (allow) { + jsonAllowHostRoot(*j, *apps); + } else { + if (!jsonDenyHostRoot(*j, *apps)) { + throw CLI::RuntimeError(_("No entries were removed"), 1); + } + } + handleConfigResult(saveConfig(scope, appId, baseId, *j)); + }); + }; + + addHostRootCommand("allow-host-root", true); + addHostRootCommand("deny-host-root", false); + // add all subcommands using the new functions addRunCommand(commandParser, runOptions, CliAppManagingGroup); addPsCommand(commandParser, CliAppManagingGroup); @@ -713,6 +1920,10 @@ You can report bugs to the linyaps team under this project: https://github.com/O auto res = transformOldExec(argc, argv); CLI11_PARSE(commandParser, std::move(res)); + if (configCmd->parsed()) { + return configResult; + } + // print version if --version flag is set if (*versionFlag) { if (*jsonFlag) { @@ -727,25 +1938,13 @@ You can report bugs to the linyaps team under this project: https://github.com/O linglong::utils::log::setLogLevel(linglong::utils::log::LogLevel::Debug); } - // check lock - while (true) { - auto lockOwner = lockCheck(); - if (lockOwner == -1) { + // ensure repo lock is available before D-Bus requests + auto waitResult = waitForRepoLockRelease(std::chrono::seconds{ 60 }); + if (waitResult != 0) { + if (waitResult < 0) { qCritical() << "lock check failed"; - return -1; } - - if (lockOwner > 0) { - qInfo() << "\r\33[K" - << "\033[?25l" - << "repository is being operated by another process, waiting for" << lockOwner - << "\033[?25h"; - using namespace std::chrono_literals; - std::this_thread::sleep_for(1s); - continue; - } - - break; + return waitResult; } // connect to package manager diff --git a/docs/pages/en/guide/lessons/basic-notes.md b/docs/pages/en/guide/lessons/basic-notes.md index e203c8072..0b1b38bd5 100755 --- a/docs/pages/en/guide/lessons/basic-notes.md +++ b/docs/pages/en/guide/lessons/basic-notes.md @@ -127,6 +127,71 @@ StartupNotify=true Terminal=false ``` +## Managing sandbox permissions with configuration files + +`ll-cli config` now understands Flatseal-style permission toggles through JSON configuration files. User-level settings live in `~/.config/linglong/config.json`, application overrides live in `~/.config/linglong/apps//config.json`, and base overrides live under `~/.config/linglong/base//config.json`. Settings can also be defined system-wide inside `/var/lib/linglong/config/`. + +Use the new helpers to enable or disable presets: + +``` +ll-cli config enable-permission --global --category filesystem host home +ll-cli config disable-permission --app org.deepin.calculator --category sockets cups +``` + +Internally the files contain a `permissions` object. Example: + +```json +{ + "permissions": { + "filesystem": { "host": true, "home": true, "host-os": true }, + "sockets": { "cups": true }, + "portals": { "notifications": true, "background": false } + } +} +``` + +The recognised categories and names are: + +| Category | Names | Effect (when enabled) | +| ---------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| filesystem | `host`, `host-os`, `host-etc`, `home` | Share full host tree, OS resources, `/etc`, or the host home directory (with privates) | +| sockets | `cups`, `pcsc` | Bind `/run/cups` (printing) or `/run/pcscd` (smart cards) from the host | +| portals | `background`, `notifications`, `microphone`, `speaker`, `camera`, `location` | Exported to the container as `LINGLONG_PORTAL_` environment variables | +| devices | `usb`, `usb-hid`, `udev` | Share `/dev/bus/usb`, `/dev/hidraw*`, and `/run/udev` plus host udev rules (`LINGLONG_UDEV_RULES_DIR`) | + +If a category is omitted the default matches the previous behaviour (host root, host OS assets, and the host home directory are shared). The JSON can also be edited manually when integrating with GUI tools. When `devices.udev` is enabled the host `/etc/udev/rules.d` and `/lib/udev/rules.d` directories are mapped read-only to `/run/host-udev-rules` inside the sandbox, and the location is exposed via the `LINGLONG_UDEV_RULES_DIR` variable. + +Custom udev rules can be embedded directly into the configuration: + +``` +ll-cli config add-udev-rule --global --name 99-my-device.rules --file ./my-device.rules +ll-cli config rm-udev-rule --global --name 99-my-device.rules +``` + +The rule content is stored in JSON and synchronized into `/run/host-udev-rules/custom/` when the sandbox starts, so it works together with the `devices.udev` preset. + +### Restrict access to `~/.config/linglong` + +To prevent rogue applications from rewriting the host configuration, containers now hide `~/.config/linglong` by default. Only application IDs in the whitelist can see or modify the host directory. Maintain the whitelist via the CLI (the option must operate on `--global`): + +``` +ll-cli config allow-config-home --global org.deepin.calculator +ll-cli config deny-config-home --global org.deepin.calculator +``` + +Entries are stored under `config_access_whitelist`. Applications that are not listed receive a private copy of `~/.config/linglong` even when `filesystem.home` is enabled. + +### Restrict access to `/run/host/rootfs` + +The full host filesystem is exposed under `/run/host/rootfs`, but it is now hidden unless an app is explicitly whitelisted: + +``` +ll-cli config allow-host-root --global org.deepin.calculator +ll-cli config deny-host-root --global org.deepin.calculator +``` + +The entries live in `host_root_whitelist` and follow the same matching rules as the config whitelist, allowing you to pair them with `filesystem.host` permissions when necessary. + ## Linyaps Application Build Project `linglong.yaml` Specification Like other traditional package management suites, manually creating a Linyaps application build project requires setting up a build rule file `linglong.yaml`. In the build rules, it is divided into `global fields` and `custom fields` according to usage. \* In the case, all space symbols and placeholders in the `linglong.yaml` body are valid characters. Please do not delete or change the format diff --git a/docs/pages/guide/lessons/basic-notes.md b/docs/pages/guide/lessons/basic-notes.md index 6ee4658c6..6bd6efa4a 100755 --- a/docs/pages/guide/lessons/basic-notes.md +++ b/docs/pages/guide/lessons/basic-notes.md @@ -126,6 +126,71 @@ StartupNotify=true Terminal=false ``` +## 通过配置文件管理沙箱权限 + +`ll-cli config` 已经支持 Flatseal 风格的权限开关。用户级别的设置位于 `~/.config/linglong/config.json`,应用以及基础环境的覆盖配置分别位于 `~/.config/linglong/apps//config.json` 与 `~/.config/linglong/base//config.json`,也可以在 `/var/lib/linglong/config/` 中提供系统级默认值。 + +常用命令如下: + +``` +ll-cli config enable-permission --global --category filesystem host home +ll-cli config disable-permission --app org.deepin.calculator --category sockets cups +``` + +配置文件中的 `permissions` 字段示例: + +```json +{ + "permissions": { + "filesystem": { "host": true, "home": true, "host-os": true }, + "sockets": { "cups": true }, + "portals": { "notifications": true, "background": false } + } +} +``` + +支持的类别与名称如下: + +| 类别 | 名称 | 含义(启用时) | +| ---------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| filesystem | `host`、`host-os`、`host-etc`、`home` | 共享完整宿主机目录、只读系统资源、`/etc` 或宿主机家目录(自动隔离敏感目录) | +| sockets | `cups`、`pcsc` | 映射宿主机 `/run/cups`(打印)或 `/run/pcscd`(智能卡) | +| portals | `background`、`notifications`、`microphone`、`speaker`、`camera`、`location` | 通过环境变量 `LINGLONG_PORTAL_` 传入容器,供桌面门户读取 | +| devices | `usb`、`usb-hid`、`udev` | 共享 `/dev/bus/usb`、`/dev/hidraw*` 以及 `/run/udev`/udev 规则(导出 `LINGLONG_UDEV_RULES_DIR`) | + +如果某个类别没有出现在配置文件中,会采用旧版本相同的默认值(仍然共享主机根目录、系统资产以及宿主家目录)。也可以直接手动编辑 JSON 文件,方便与图形化工具联动。启用 `devices.udev` 后,宿主的 `/etc/udev/rules.d` 与 `/lib/udev/rules.d` 会以只读方式映射到容器的 `/run/host-udev-rules`,并通过 `LINGLONG_UDEV_RULES_DIR` 环境变量告知应用。 + +自定义 udev 规则可通过: + +``` +ll-cli config add-udev-rule --global --name 99-my-device.rules --file ./my-device.rules +ll-cli config rm-udev-rule --global --name 99-my-device.rules +``` + +`add-udev-rule` 会把文件内容直接写入配置,容器启动时会自动同步到 `/run/host-udev-rules/custom/` 目录,便于与 `devices.udev` 配合。 + +### 限制对 `~/.config/linglong` 的访问 + +为防止应用在容器内恶意修改宿主的 linyaps 配置,默认情况下容器会将 `~/.config/linglong` 隐藏到沙箱内部的私有目录。只有被加入白名单的应用才可以访问和修改该目录。使用下面的命令维护白名单(必须作用于 `--global` 配置): + +``` +ll-cli config allow-config-home --global org.deepin.calculator +ll-cli config deny-config-home --global org.deepin.calculator +``` + +白名单支持多个 APPID,也可以直接在 `config_access_whitelist` 数组里手动编辑。没有在白名单中的应用即使启用了 `filesystem.home` 依旧会看到一个与宿主隔离的 `~/.config/linglong`。 + +### 限制对宿主根目录 `/run/host/rootfs` 的访问 + +宿主根文件系统默认同样被重映射,只有加入白名单的应用可以看到真实的 `/run/host/rootfs`: + +``` +ll-cli config allow-host-root --global org.deepin.calculator +ll-cli config deny-host-root --global org.deepin.calculator +``` + +白名单存储在 `host_root_whitelist`,匹配语义与配置目录白名单一致,可直接协同 `filesystem.host` 等权限使用。 + ## 玲珑应用构建工程 `linglong.yaml` 规范 正如其他传统包管理套件一样,手动创建一个玲珑应用构建工程需要设置构建规则文件 `linglong.yaml`,在构建规则中,则根据用途划分为 `全局字段` 及 `定制化字段`。\* 案例中 `linglong.yaml` 正文内所有空格符号、占位符均为有效字符,请勿删除或变更格式 diff --git a/libs/linglong/src/linglong/builder/linglong_builder.cpp b/libs/linglong/src/linglong/builder/linglong_builder.cpp index 5265ffe20..ff0c9efe4 100644 --- a/libs/linglong/src/linglong/builder/linglong_builder.cpp +++ b/libs/linglong/src/linglong/builder/linglong_builder.cpp @@ -1878,13 +1878,6 @@ utils::error::Result Builder::run(std::vector modules, .bindXDGRuntime() .bindUserGroup() .bindRemovableStorageMounts() - .bindHostRoot() - .bindHostStatics() - .bindHome(homeEnv) - .enablePrivateDir() - .mapPrivate(std::string{ homeEnv } + "/.ssh", true) - .mapPrivate(std::string{ homeEnv } + "/.gnupg", true) - .bindIPC() .forwardDefaultEnv() .addExtraMounts(applicationMounts) .enableSelfAdjustingMount() diff --git a/libs/linglong/src/linglong/cli/cli.cpp b/libs/linglong/src/linglong/cli/cli.cpp index d510e0bc7..0a1b521b5 100644 --- a/libs/linglong/src/linglong/cli/cli.cpp +++ b/libs/linglong/src/linglong/cli/cli.cpp @@ -601,12 +601,6 @@ int Cli::run(const RunOptions &options) break; } - auto *homeEnv = ::getenv("HOME"); - if (homeEnv == nullptr) { - qCritical() << "Couldn't get HOME env."; - return -1; - } - runContext.enableSecurityContext(runtime::getDefaultSecurityContexts()); linglong::generator::ContainerCfgBuilder cfgBuilder; @@ -620,13 +614,6 @@ int Cli::run(const RunOptions &options) .bindXDGRuntime() .bindUserGroup() .bindRemovableStorageMounts() - .bindHostRoot() - .bindHostStatics() - .bindHome(homeEnv) - .enablePrivateDir() - .mapPrivate(std::string{ homeEnv } + "/.ssh", true) - .mapPrivate(std::string{ homeEnv } + "/.gnupg", true) - .bindIPC() .forwardDefaultEnv() .enableSelfAdjustingMount(); diff --git a/libs/linglong/src/linglong/package_manager/package_manager.cpp b/libs/linglong/src/linglong/package_manager/package_manager.cpp index 85845c9ff..f847e9546 100644 --- a/libs/linglong/src/linglong/package_manager/package_manager.cpp +++ b/libs/linglong/src/linglong/package_manager/package_manager.cpp @@ -47,6 +47,7 @@ #include #include +#include #include #include @@ -128,20 +129,6 @@ utils::error::Result PackageManager::isRefBusy(const package::Reference &r { LINGLONG_TRACE(fmt::format("check if ref[{}] is used by some apps", ref.toString())); - auto ret = lockRepo(); - if (!ret) { - return LINGLONG_ERR( - QStringLiteral("failed to lock repo, underlying data will not be removed: %1") - .arg(ret.error().message().c_str())); - } - - auto unlock = utils::finally::finally([this] { - auto ret = unlockRepo(); - if (!ret) { - qCritical() << "failed to unlock repo:" << ret.error().message(); - } - }); - auto running = getAllRunningContainers(); if (!running) { return LINGLONG_ERR(QStringLiteral("failed to get running containers: %1") @@ -235,7 +222,7 @@ PackageManager::getAllRunningContainers() noexcept [[nodiscard]] utils::error::Result PackageManager::lockRepo() noexcept { - LINGLONG_TRACE("lock whole repo") + LINGLONG_TRACE("lock whole repo"); lockFd = ::open(repoLockPath, O_RDWR | O_CREAT, 0644); if (lockFd == -1) { return LINGLONG_ERR(QStringLiteral("failed to create lock file %1: %2") @@ -250,12 +237,13 @@ PackageManager::getAllRunningContainers() noexcept QStringLiteral("failed to lock %1: %2").arg(repoLockPath).arg(::strerror(errno))); } + lockStart = std::chrono::steady_clock::now(); return LINGLONG_OK; } [[nodiscard]] utils::error::Result PackageManager::unlockRepo() noexcept { - LINGLONG_TRACE("unlock whole repo") + LINGLONG_TRACE("unlock whole repo"); if (lockFd == -1) { return LINGLONG_OK; @@ -270,6 +258,14 @@ PackageManager::getAllRunningContainers() noexcept ::close(lockFd); lockFd = -1; + if (lockStart != std::chrono::steady_clock::time_point{}) { + auto elapsed = std::chrono::steady_clock::now() - lockStart; + lockStart = {}; + auto seconds = std::chrono::duration_cast(elapsed).count(); + if (seconds > 5) { + qInfo() << "repository lock held for" << seconds << "seconds"; + } + } return LINGLONG_OK; } @@ -310,7 +306,7 @@ utils::error::Result PackageManager::switchAppVersion(const package::Refer const package::Reference &newRef, bool removeOldRef) noexcept { - LINGLONG_TRACE("remove old reference after install") + LINGLONG_TRACE("remove old reference after install"); LogI("switch app version from {} to {}", oldRef.toString(), newRef.toString()); auto res = applyApp(newRef); @@ -340,16 +336,7 @@ utils::error::Result PackageManager::switchAppVersion(const package::Refer void PackageManager::deferredUninstall() noexcept { - if (auto ret = lockRepo(); !ret) { - qCritical() << "failed to lock repo:" << ret.error().message(); - return; - } - auto unlock = utils::finally::finally([this] { - auto ret = unlockRepo(); - if (!ret) { - qCritical() << "failed to unlock repo:" << ret.error().message(); - } - }); + const auto lockStart = std::chrono::steady_clock::now(); // query layers which have been mark 'deleted' auto uninstalled = this->repo.listLocalBy(linglong::repo::repoCacheQuery{ .deleted = true }); @@ -396,6 +383,27 @@ void PackageManager::deferredUninstall() noexcept } // begin to uninstall + auto tryLock = [this, &lockStart]() -> bool { + if (auto ret = lockRepo(); !ret) { + auto elapsed = std::chrono::steady_clock::now() - lockStart; + qCritical() << "failed to lock repo:" << ret.error().message() + << "elapsed" << std::chrono::duration_cast(elapsed).count() + << "s"; + return false; + } + return true; + }; + + if (!tryLock()) { + return; + } + auto unlock = utils::finally::finally([this]() { + auto ret = unlockRepo(); + if (!ret) { + qCritical() << "failed to unlock repo:" << ret.error().message(); + } + }); + for (const auto &[ref, items] : uninstalledLayers) { auto pkgRef = package::Reference::parse(ref); if (!pkgRef) { diff --git a/libs/linglong/src/linglong/package_manager/package_manager.h b/libs/linglong/src/linglong/package_manager/package_manager.h index 6bf2088c3..1e26536cb 100644 --- a/libs/linglong/src/linglong/package_manager/package_manager.h +++ b/libs/linglong/src/linglong/package_manager/package_manager.h @@ -24,6 +24,7 @@ #include #include +#include namespace linglong::service { @@ -172,6 +173,7 @@ public int lockFd{ -1 }; linglong::runtime::ContainerBuilder &containerBuilder; + std::chrono::steady_clock::time_point lockStart; }; } // namespace linglong::service diff --git a/libs/linglong/src/linglong/runtime/run_context.cpp b/libs/linglong/src/linglong/runtime/run_context.cpp index fb7e9a207..a691349ed 100644 --- a/libs/linglong/src/linglong/runtime/run_context.cpp +++ b/libs/linglong/src/linglong/runtime/run_context.cpp @@ -3,6 +3,9 @@ */ #include "run_context.h" +#include "configure.h" + +#include "linglong/api/types/v1/Generators.hpp" #include "linglong/common/display.h" #include "linglong/extension/extension.h" @@ -11,8 +14,827 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + namespace linglong::runtime { +static std::vector configBasesUserFirst() +{ + namespace fs = std::filesystem; + std::vector bases; + std::unordered_set seen; + + auto addBase = [&](const fs::path &candidate) { + if (candidate.empty()) { + return; + } + auto normalized = candidate.lexically_normal(); + auto key = normalized.string(); + if (key.empty()) { + return; + } + if (seen.insert(key).second) { + bases.emplace_back(std::move(normalized)); + } + }; + + if (const char *xdgConfigHome = ::getenv("XDG_CONFIG_HOME"); + xdgConfigHome && xdgConfigHome[0] != '\0') { + addBase(fs::path(xdgConfigHome) / "linglong"); + } else if (const char *homeEnv = ::getenv("HOME"); homeEnv && homeEnv[0] != '\0') { + addBase(fs::path(homeEnv) / ".config" / "linglong"); + } + + addBase(fs::path(LINGLONG_DATA_DIR) / "config"); + return bases; +} + +static std::vector configBasesFallbackFirst() +{ + auto bases = configBasesUserFirst(); + if (bases.size() <= 1) { + return bases; + } + std::rotate(bases.begin(), std::prev(bases.end()), bases.end()); + return bases; +} + +static std::vector loadExtensionsFromConfig(const std::string &appId) +{ + namespace fs = std::filesystem; + std::vector result; + std::unordered_set seen; + + auto addExtension = [&](std::string ext) { + if (seen.insert(ext).second) { + result.emplace_back(std::move(ext)); + } + }; + + // Parse extensions from a JSON config file and collect unique entries + auto parseExtensions = [&](const fs::path &p) { + try { + if (!fs::exists(p)) { + return; + } + std::ifstream in(p); + if (!in.is_open()) { + return; + } + nlohmann::json j; + in >> j; + if (!j.contains("extensions") || !j.at("extensions").is_array()) { + return; + } + for (const auto &elem : j.at("extensions")) { + if (elem.is_string()) { + addExtension(elem.get()); + } + } + } catch (...) { + // ignore parse failures or missing files + } + }; + + for (const auto &base : configBasesUserFirst()) { + parseExtensions(base / "config.json"); + if (!appId.empty()) { + parseExtensions(base / "apps" / appId / "config.json"); + } + } + + return result; +} + +static std::vector loadExtensionsFromBase(const std::string &baseId) +{ + namespace fs = std::filesystem; + std::vector result; + std::unordered_set seen; + + if (baseId.empty()) { + return result; + } + + for (const auto &root : configBasesUserFirst()) { + fs::path cfgPath = root / "base" / baseId / "config.json"; + try { + if (!fs::exists(cfgPath)) { + continue; + } + std::ifstream in(cfgPath); + if (!in.is_open()) { + continue; + } + nlohmann::json j; + in >> j; + if (!j.contains("extensions") || !j.at("extensions").is_array()) { + continue; + } + for (const auto &elem : j.at("extensions")) { + if (elem.is_string()) { + std::string ext = elem.get(); + if (seen.insert(ext).second) { + result.emplace_back(std::move(ext)); + } + } + } + } catch (...) { + // ignore parse errors + } + } + return result; +} + +// ===== begin: config helpers for env/mount/commands (Global->Base->App merge) ===== +using json = nlohmann::json; + +static json loadMergedJsonWithBase(const std::string &appId, const std::string &baseId) +{ + namespace fs = std::filesystem; + json merged = json::object(); + + auto readIfExists = [](const fs::path &p) -> std::optional { + try { + if (!fs::exists(p)) { + return std::nullopt; + } + std::ifstream in(p); + if (!in.is_open()) { + return std::nullopt; + } + json j; + in >> j; + return j; + } catch (...) { + return std::nullopt; + } + }; + + for (const auto &root : configBasesFallbackFirst()) { + if (auto g = readIfExists(root / "config.json")) { + merged.merge_patch(*g); + } + if (!baseId.empty()) { + if (auto b = readIfExists(root / "base" / baseId / "config.json")) { + merged.merge_patch(*b); + } + } + if (!appId.empty()) { + if (auto a = readIfExists(root / "apps" / appId / "config.json")) { + merged.merge_patch(*a); + } + } + } + return merged; +} + +static std::string expandUserHome(const std::string &path) +{ + if (path == "~" || path.rfind("~/", 0) == 0) { + const char *home = ::getenv("HOME"); + if (home && home[0]) { + return path == "~" ? std::string(home) : (std::string(home) + path.substr(1)); + } + } + return path; +} + +static void collectEnvFromJson(const json &j, std::vector &out) +{ + if (!j.contains("env") || !j.at("env").is_object()) { + return; + } + for (auto it = j.at("env").begin(); it != j.at("env").end(); ++it) { + const std::string key = it.key(); + std::string val = it.value().is_string() ? it.value().get() : std::string(); + if (val.find('$') != std::string::npos) { + qWarning() << "ignore env with variable expansion:" << QString::fromStdString(key); + continue; + } + if (!key.empty() && key.back() == '+') { + out.emplace_back(key.substr(0, key.size() - 1) + "+=" + val); + } else { + out.emplace_back(key + "=" + val); + } + } +} + +static std::unordered_set getPermissionSet(const json &root, const char *category) +{ + std::unordered_set enabled; + auto it = root.find("permissions"); + if (it == root.end() || !it->is_object()) { + return enabled; + } + auto cat = it->find(category); + if (cat == it->end()) { + return enabled; + } + if (cat->is_array()) { + for (const auto &entry : *cat) { + if (entry.is_string()) { + enabled.insert(entry.get()); + } + } + } else if (cat->is_object()) { + for (auto iter = cat->begin(); iter != cat->end(); ++iter) { + bool value = iter.value().is_boolean() ? iter.value().get() : false; + if (value) { + enabled.insert(iter.key()); + } + } + } else if (cat->is_string()) { + enabled.insert(cat->get()); + } + return enabled; +} + +static bool addReadonlyMount(generator::ContainerCfgBuilder &builder, + const std::filesystem::path &source, + const std::string &destination) +{ + std::error_code ec; + if (!std::filesystem::exists(source, ec)) { + if (ec) { + LogW("skip permission mount {} -> {}: {}", source.string(), destination, ec.message()); + } + return false; + } + + ocppi::runtime::config::types::Mount mount{ + .destination = destination, + .options = std::vector{ "rbind", "ro" }, + .source = source.string(), + .type = "bind", + }; + builder.addExtraMount(std::move(mount)); + return true; +} + +static void applyFilesystemPermissions(generator::ContainerCfgBuilder &builder, + const std::unordered_set &enabled, + bool allowLinglongConfig, + bool allowHostRoot) +{ + bool wantsHost = enabled.count("host") > 0; + bool wantsHostOS = enabled.count("host-os") > 0; + bool wantsHostEtc = enabled.count("host-etc") > 0; + bool wantsHome = enabled.count("home") > 0; + + if (!wantsHost && !wantsHostOS && !wantsHostEtc && !wantsHome) { + wantsHost = true; + wantsHostOS = true; + wantsHome = true; + } + + if (wantsHost && allowHostRoot) { + builder.bindHostRoot(); + } + if (wantsHostOS) { + builder.bindHostStatics(); + if (!wantsHost) { + addReadonlyMount(builder, "/usr", "/run/host-os/usr"); + addReadonlyMount(builder, "/lib", "/run/host-os/lib"); + addReadonlyMount(builder, "/lib64", "/run/host-os/lib64"); + } + } + if (wantsHostEtc) { + if (!addReadonlyMount(builder, "/etc", "/run/host-etc")) { + LogW("host-etc permission requested but /etc is not accessible"); + } + } + if (wantsHome) { + const char *home = ::getenv("HOME"); + if (!home || home[0] == '\0') { + LogW("HOME is not set, skip home permission"); + } else { + builder.bindHome(home) + .enablePrivateDir() + .mapPrivate(std::string{ home } + "/.ssh", true) + .mapPrivate(std::string{ home } + "/.gnupg", true); + if (!allowLinglongConfig) { + builder.mapPrivate(std::string{ home } + "/.config/linglong", true); + } + } + } +} + +static void applySocketPermissions(generator::ContainerCfgBuilder &builder, + const std::unordered_set &enabled) +{ + auto mountRwDir = [&](const std::filesystem::path &source, const std::string &destination) { + std::error_code ec; + if (!std::filesystem::exists(source, ec)) { + if (ec) { + LogW("skip socket mount {} -> {}: {}", source.string(), destination, ec.message()); + } + return; + } + ocppi::runtime::config::types::Mount mount{ + .destination = destination, + .options = std::vector{ "rbind" }, + .source = source.string(), + .type = "bind", + }; + builder.addExtraMount(std::move(mount)); + }; + + if (enabled.count("pcsc") > 0) { + mountRwDir("/run/pcscd", "/run/pcscd"); + } + if (enabled.count("cups") > 0) { + mountRwDir("/run/cups", "/run/cups"); + mountRwDir("/var/run/cups", "/var/run/cups"); + } +} + +static void applyPortalPermissions(const std::unordered_set &enabled, + std::map &environment) +{ + const std::vector known = { + "background", "notifications", "microphone", "speaker", "camera", "location" + }; + for (const auto &name : known) { + std::string key = name; + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { + if (c == '-') { + return static_cast('_'); + } + return static_cast(std::toupper(c)); + }); + auto envKey = "LINGLONG_PORTAL_" + key; + environment[envKey] = enabled.count(name) > 0 ? "1" : "0"; + } +} + +static bool isConfigWhitelistMatch(const json &entry, const std::string &appId) +{ + if (!entry.is_string() || appId.empty()) { + return false; + } + auto val = entry.get(); + if (val == "*" || val == appId) { + return true; + } + return false; +} + +static bool allowHostConfigAccess(const json &root, const std::string &appId) +{ + if (appId.empty()) { + return false; + } + auto it = root.find("config_access_whitelist"); + if (it == root.end()) { + return false; + } + if (it->is_boolean()) { + return it->get(); + } + if (it->is_string()) { + return isConfigWhitelistMatch(*it, appId); + } + if (!it->is_array()) { + return false; + } + for (const auto &entry : *it) { + if (isConfigWhitelistMatch(entry, appId)) { + return true; + } + } + return false; +} + +static bool allowHostRootAccess(const json &root, const std::string &appId) +{ + auto it = root.find("host_root_whitelist"); + if (it == root.end()) { + return false; + } + if (it->is_boolean()) { + return it->get(); + } + if (it->is_string()) { + return isConfigWhitelistMatch(*it, appId); + } + if (!it->is_array()) { + return false; + } + for (const auto &entry : *it) { + if (isConfigWhitelistMatch(entry, appId)) { + return true; + } + } + return false; +} + +static bool bindPath(generator::ContainerCfgBuilder &builder, + const std::filesystem::path &source, + const std::string &destination, + bool recursive, + bool readOnly) +{ + std::error_code ec; + if (!std::filesystem::exists(source, ec)) { + if (ec) { + LogW("skip device mount {} -> {}: {}", source.string(), destination, ec.message()); + } + return false; + } + std::vector options; + options.push_back(recursive ? "rbind" : "bind"); + if (readOnly) { + options.push_back("ro"); + } + ocppi::runtime::config::types::Mount mount{ + .destination = destination, + .options = options, + .source = source.string(), + .type = "bind", + }; + builder.addExtraMount(std::move(mount)); + return true; +} + +static void bindHidrawNodes(generator::ContainerCfgBuilder &builder) +{ + std::error_code ec; + const std::filesystem::path devDir = "/dev"; + if (!std::filesystem::exists(devDir, ec)) { + return; + } + for (const auto &entry : std::filesystem::directory_iterator(devDir, ec)) { + if (ec) { + break; + } + auto name = entry.path().filename().string(); + if (name.rfind("hidraw", 0) != 0) { + continue; + } + bindPath(builder, entry.path(), entry.path().string(), false, false); + } +} + +static std::vector> collectCustomUdevRules(const json &root) +{ + std::vector> rules; + auto it = root.find("udev_rules"); + if (it == root.end() || !it->is_array()) { + return rules; + } + for (const auto &entry : *it) { + if (!entry.is_object()) { + continue; + } + auto nameIt = entry.find("name"); + auto contentIt = entry.find("content"); + if (nameIt == entry.end() || contentIt == entry.end()) { + continue; + } + if (!nameIt->is_string() || !contentIt->is_string()) { + continue; + } + auto name = nameIt->get(); + auto content = contentIt->get(); + if (!name.empty() && !content.empty()) { + rules.emplace_back(std::move(name), std::move(content)); + } + } + return rules; +} + +static std::string sanitizeUdevRuleName(std::string raw) +{ + if (raw.empty()) { + return {}; + } + for (auto &ch : raw) { + unsigned char c = static_cast(ch); + if (!std::isalnum(c) && ch != '-' && ch != '_' && ch != '.') { + ch = '_'; + } + } + if (raw.size() < 6 || raw.substr(raw.size() - 6) != ".rules") { + if (!raw.empty() && raw.back() != '.') { + raw += ".rules"; + } else { + raw += "rules"; + } + } + return raw; +} + +static std::filesystem::path prepareCustomUdevRulesDir() +{ + auto uid = ::getuid(); + std::filesystem::path dir = std::filesystem::path("/run/linglong/custom-udev") / std::to_string(uid); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + LogW("failed to prepare custom udev dir {}: {}", dir.string(), ec.message()); + return {}; + } + return dir; +} + +static bool syncCustomUdevRules(const json &root, std::filesystem::path &outDir) +{ + auto rules = collectCustomUdevRules(root); + if (rules.empty()) { + return false; + } + auto dir = prepareCustomUdevRulesDir(); + if (dir.empty()) { + return false; + } + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(dir, ec)) { + if (ec) { + break; + } + std::filesystem::remove_all(entry.path(), ec); + } + for (const auto &[name, content] : rules) { + auto sanitized = sanitizeUdevRuleName(name); + if (sanitized.empty()) { + continue; + } + std::ofstream out(dir / sanitized, std::ios::trunc); + if (!out.is_open()) { + LogW("failed to write custom udev rule {}", sanitized); + continue; + } + out << content; + } + outDir = dir; + return true; +} + +static void applyDevicePermissions(generator::ContainerCfgBuilder &builder, + std::map &environment, + const std::unordered_set &enabled, + const json &mergedCfg) +{ + if (enabled.empty()) { + return; + } + + if (enabled.count("usb") > 0) { + bindPath(builder, "/dev/bus/usb", "/dev/bus/usb", true, false); + } + if (enabled.count("usb-hid") > 0) { + bindHidrawNodes(builder); + } + if (enabled.count("udev") > 0) { + bindPath(builder, "/run/udev", "/run/udev", true, false); + const std::filesystem::path hostRulesBase = "/run/host-udev-rules"; + bindPath(builder, + "/etc/udev/rules.d", + (hostRulesBase / "etc").string(), + true, + true); + bindPath(builder, + "/lib/udev/rules.d", + (hostRulesBase / "lib").string(), + true, + true); + std::filesystem::path customDir; + if (syncCustomUdevRules(mergedCfg, customDir)) { + bindPath(builder, + customDir, + (hostRulesBase / "custom").string(), + true, + true); + } + environment["LINGLONG_UDEV_RULES_DIR"] = hostRulesBase.string(); + } +} + +static std::optional appendEnvWithMergedPath( + linglong::generator::ContainerCfgBuilder &builder, + const std::vector &envKVs, + const std::map &baseEnv, + const std::optional ¤tPath, + const char *warnContext) +{ + if (envKVs.empty()) { + return currentPath; + } + + std::map envToAppend; + std::string systemPath; + if (auto sysPath = ::getenv("PATH")) { + systemPath = sysPath; + } + auto basePathIt = baseEnv.find("PATH"); + + auto appendPath = [&](const std::string &add) { + if (auto it = envToAppend.find("PATH"); it != envToAppend.end()) { + it->second += ":" + add; + return; + } + if (currentPath) { + envToAppend["PATH"] = currentPath->empty() ? add : *currentPath + ":" + add; + return; + } + if (basePathIt != baseEnv.end()) { + envToAppend["PATH"] = + basePathIt->second.empty() ? add : basePathIt->second + ":" + add; + return; + } + if (!systemPath.empty()) { + envToAppend["PATH"] = systemPath + ":" + add; + return; + } + envToAppend["PATH"] = add; + }; + + for (const auto &kv : envKVs) { + auto pos = kv.find("+="); + if (pos != std::string::npos) { + auto key = kv.substr(0, pos); + auto add = kv.substr(pos + 2); + if (key == "PATH") { + appendPath(add); + } else { + if (warnContext && warnContext[0]) { + qWarning() << "ignore '+=' env for key" << warnContext << ":" + << QString::fromStdString(key); + } else { + qWarning() << "ignore '+=' env for key:" << QString::fromStdString(key); + } + } + continue; + } + auto eq = kv.find('='); + if (eq == std::string::npos) { + continue; + } + envToAppend[kv.substr(0, eq)] = kv.substr(eq + 1); + } + + if (!envToAppend.empty()) { + builder.appendEnv(envToAppend); + if (auto it = envToAppend.find("PATH"); it != envToAppend.end()) { + return it->second; + } + } + + return currentPath; +} + +static std::vector +parseFilesystemMounts(const std::string &appId, const json &arr) +{ + using Mount = ocppi::runtime::config::types::Mount; + std::vector mounts; + if (!arr.is_array()) { + return mounts; + } + for (const auto &e : arr) { + if (!e.is_object()) { + continue; + } + std::string host = e.value("host", ""); + std::string target = e.value("target", ""); + std::string mode = e.value("mode", "ro"); + bool persist = e.value("persist", false); + + if (host.empty() || target.empty()) { + continue; + } + if (host.find('$') != std::string::npos || target.find('$') != std::string::npos) { + qWarning() << "ignore mount with variable expansion:" << QString::fromStdString(host) + << "->" << QString::fromStdString(target); + continue; + } + + host = expandUserHome(host); + if (persist) { + const char *home = ::getenv("HOME"); + if (home && home[0] && !appId.empty()) { + std::filesystem::path p(home); + p /= ".var/app"; + p /= appId; + p /= std::filesystem::path(host).filename(); + host = p.string(); + } + + std::error_code ec; + std::filesystem::path hostPath(host); + std::filesystem::create_directories(hostPath, ec); + if (ec || !std::filesystem::is_directory(hostPath, ec)) { + qWarning() << "failed to prepare persist directory for" + << QString::fromStdString(host) << ":" << ec.message().c_str(); + continue; + } + } + + Mount m; + m.type = "bind"; + m.source = host; + m.destination = target; + m.options = { { (mode == "rw" ? "rw" : "ro"), "rbind" } }; + mounts.emplace_back(std::move(m)); + } + + return mounts; +} + +static void collectMountsFromJson(const std::string &appId, + const json &j, + std::vector &out) +{ + if (!j.contains("filesystem") || !j.at("filesystem").is_array()) { + return; + } + auto mounts = parseFilesystemMounts(appId, j.at("filesystem")); + std::move(mounts.begin(), mounts.end(), std::back_inserter(out)); +} + +struct CommandSettings { + std::vector envKVs; + std::vector mounts; + std::vector argsPrefix; + std::vector argsSuffix; + std::optional entrypoint; + std::optional cwd; +}; + +static const json *pickCommandNode(const json &merged, const std::string &execName) +{ + if (!merged.contains("commands") || !merged.at("commands").is_object()) { + return nullptr; + } + const auto &cmds = merged.at("commands"); + if (auto it = cmds.find(execName); it != cmds.end() && it->is_object()) { + return &(*it); + } + if (auto it = cmds.find("*"); it != cmds.end() && it->is_object()) { + return &(*it); + } + return nullptr; +} + +static void loadStrVec(const json &node, const char *key, std::vector &out) +{ + if (!node.contains(key) || !node.at(key).is_array()) { + return; + } + for (const auto &v : node.at(key)) { + if (v.is_string()) { + out.emplace_back(v.get()); + } + } +} + +static CommandSettings parseCommandSettings(const std::string &appId, const json &node) +{ + CommandSettings cs; + if (node.contains("env") && node.at("env").is_object()) { + for (auto it = node.at("env").begin(); it != node.at("env").end(); ++it) { + const std::string key = it.key(); + std::string val = it.value().is_string() ? it.value().get() : std::string(); + if (val.find('$') != std::string::npos) { + qWarning() << "ignore env with variable expansion in command settings:" + << QString::fromStdString(key); + continue; + } + if (!key.empty() && key.back() == '+') { + cs.envKVs.emplace_back(key.substr(0, key.size() - 1) + "+=" + val); + } else { + cs.envKVs.emplace_back(key + "=" + val); + } + } + } + if (node.contains("filesystem") && node.at("filesystem").is_array()) { + collectMountsFromJson(appId, node, cs.mounts); + } + loadStrVec(node, "args_prefix", cs.argsPrefix); + loadStrVec(node, "args_suffix", cs.argsSuffix); + if (node.contains("entrypoint") && node.at("entrypoint").is_string()) { + cs.entrypoint = node.at("entrypoint").get(); + } + if (node.contains("cwd") && node.at("cwd").is_string()) { + cs.cwd = node.at("cwd").get(); + } + return cs; +} +// ===== end: config helpers ===== + + RuntimeLayer::RuntimeLayer(package::Reference ref, RunContext &context) : reference(std::move(ref)) , runContext(context) @@ -59,7 +881,7 @@ utils::error::Result RuntimeLayer::ge auto &repo = runContext.get().getRepo(); auto item = repo.getLayerItem(reference); if (!item) { - return LINGLONG_ERR("no cached item found: " + reference.toString(), item); + return LINGLONG_ERR("no cached item found: " + reference.toString()); } cachedItem = std::move(item).value(); } @@ -82,11 +904,13 @@ utils::error::Result RunContext::resolve(const linglong::package::Referenc { LINGLONG_TRACE("resolve RunContext from runnable " + runnable.toString()); + filesystemPolicyCache.reset(); + containerID = runtime::genContainerID(runnable); auto item = repo.getLayerItem(runnable); if (!item) { - return LINGLONG_ERR("no cached item found: " + runnable.toString(), item); + return LINGLONG_ERR("no cached item found: " + runnable.toString()); } const auto &info = item->info; @@ -155,8 +979,46 @@ utils::error::Result RunContext::resolve(const linglong::package::Referenc } // 手动解析多个扩展 + // 先从命令行选项或配置文件获取扩展列表 + // 先从命令行选项或应用/全局配置获取扩展列表 + std::vector extRefs; if (options.extensionRefs && !options.extensionRefs->empty()) { - auto manualExtensionDef = makeManualExtensionDefine(*options.extensionRefs); + extRefs = *options.extensionRefs; + } else { + extRefs = loadExtensionsFromConfig(runnable.id); + } + + // 如果未获取到扩展列表,则尝试根据 base 层加载 + if (extRefs.empty()) { + // 获取 baseId + std::string baseId; + + // 1. 优先使用 ResolveOptions::baseRef(如果提供) + if (options.baseRef && !options.baseRef->empty()) { + // 假设存在 FuzzyReference::parse,可解析出 id 部分 + auto baseRef = linglong::package::FuzzyReference::parse(*options.baseRef); + if (baseRef) { + baseId = baseRef->id; + } + } + + // 2. 否则从当前运行包信息中获取 + if (baseId.empty()) { + auto item = repo.getLayerItem(runnable); + if (item && !item->info.base.empty()) { + baseId = item->info.base; + } + } + + // 3. 若 baseId 非空,则读取 base 配置 + if (!baseId.empty()) { + extRefs = loadExtensionsFromBase(baseId); + } + } + + // 若 extRefs 非空,继续使用原有的手动解析逻辑 + if (!extRefs.empty()) { + auto manualExtensionDef = makeManualExtensionDefine(extRefs); if (!manualExtensionDef) { return LINGLONG_ERR(manualExtensionDef); } @@ -177,6 +1039,8 @@ utils::error::Result RunContext::resolve(const api::types::v1::BuilderProj { LINGLONG_TRACE("resolve RunContext from builder project " + target.package.id); + filesystemPolicyCache.reset(); + auto targetRef = package::Reference::fromBuilderProject(target); if (!targetRef) { return LINGLONG_ERR(targetRef); @@ -230,8 +1094,7 @@ utils::error::Result RunContext::resolve(const api::types::v1::BuilderProj auto layer = runtimeLayer->getCachedItem(); if (!layer) { - return LINGLONG_ERR("no cached item found: " + runtimeLayer->getReference().toString(), - layer); + return LINGLONG_ERR("no cached item found: " + runtimeLayer->getReference().toString()); } auto fuzzyRef = package::FuzzyReference::parse(layer->info.base); @@ -358,7 +1221,7 @@ utils::error::Result RunContext::resolveExtension(RuntimeLayer &layer) auto item = layer.getCachedItem(); if (!item) { - return LINGLONG_ERR("no cached item found: " + layer.getReference().toString(), item); + return LINGLONG_ERR("no cached item found: " + layer.getReference().toString()); } const auto &info = item->info; @@ -573,10 +1436,75 @@ RunContext::fillContextCfg(linglong::generator::ContainerCfgBuilder &builder) return res; } + builder.bindIPC(); + + // === begin: merge Global->Base->App config === + std::string currentAppId; + if (appLayer) currentAppId = appLayer->getReference().id; + else if (!targetId.empty()) currentAppId = targetId; + + std::string currentBaseId; + if (baseLayer) currentBaseId = baseLayer->getReference().id; + + auto mergedCfg = loadMergedJsonWithBase(currentAppId, currentBaseId); + bool allowConfigDir = allowHostConfigAccess(mergedCfg, currentAppId); + std::optional mergedPath; + + // 1) common env + { + std::vector envKVs; + collectEnvFromJson(mergedCfg, envKVs); + mergedPath = appendEnvWithMergedPath(builder, envKVs, environment, mergedPath, ""); + } + + // 2) common filesystem + { + const auto &fsPolicy = filesystemPolicy(); + if (fsPolicy.allowListConfigured) { + if (!fsPolicy.allowList.empty()) { + auto allowList = fsPolicy.allowList; + builder.addExtraMounts(std::move(allowList)); + } + } else if (!fsPolicy.extra.empty()) { + auto extraMounts = fsPolicy.extra; + builder.addExtraMounts(std::move(extraMounts)); + } + } + // === end: merge Global->Base->App config === + + bool allowHostRoot = allowHostRootAccess(mergedCfg, currentAppId); + applyFilesystemPermissions( + builder, getPermissionSet(mergedCfg, "filesystem"), allowConfigDir, allowHostRoot); + applySocketPermissions(builder, getPermissionSet(mergedCfg, "sockets")); + applyPortalPermissions(getPermissionSet(mergedCfg, "portals"), environment); + applyDevicePermissions(builder, environment, getPermissionSet(mergedCfg, "devices"), mergedCfg); + if (!environment.empty()) { + if (auto it = environment.find("PATH"); it != environment.end()) { + mergedPath = it->second; + } builder.appendEnv(environment); } + // === begin: command-level settings (highest priority) === + { + std::string execName = currentAppId; + if (!execName.empty()) { + if (const json *node = pickCommandNode(mergedCfg, execName)) { + CommandSettings cs = parseCommandSettings(currentAppId, *node); + + if (!cs.envKVs.empty()) { + mergedPath = appendEnvWithMergedPath( + builder, cs.envKVs, environment, mergedPath, "in command settings"); + } + + if (!cs.mounts.empty()) builder.addExtraMounts(cs.mounts); + // TODO: when builder exposes API for entrypoint/cwd/args, apply here as well. + } + } + } + // === end: command-level settings === + detectDisplaySystem(builder); for (auto ctx = securityContexts.begin(); ctx != securityContexts.end(); ++ctx) { @@ -727,6 +1655,45 @@ utils::error::Result RunContext::getBaseLayerPath() const return std::filesystem::path{ layerDir->absolutePath().toStdString() }; } +std::string RunContext::currentAppId() const +{ + if (appLayer) { + return appLayer->getReference().id; + } + return targetId; +} + +const RunContext::FilesystemPolicy &RunContext::filesystemPolicy() const +{ + if (!filesystemPolicyCache) { + FilesystemPolicy policy; + + auto appId = currentAppId(); + std::string baseId; + if (baseLayer) { + baseId = baseLayer->getReference().id; + } + + auto mergedCfg = loadMergedJsonWithBase(appId, baseId); + if (auto it = mergedCfg.find("filesystem_allow_only"); it != mergedCfg.end()) { + policy.allowListConfigured = true; + if (it->is_array()) { + policy.allowList = parseFilesystemMounts(appId, *it); + } + } + + if (!policy.allowListConfigured) { + if (auto it = mergedCfg.find("filesystem"); it != mergedCfg.end()) { + policy.extra = parseFilesystemMounts(appId, *it); + } + } + + filesystemPolicyCache = std::move(policy); + } + + return *filesystemPolicyCache; +} + utils::error::Result RunContext::getRuntimeLayerPath() const { LINGLONG_TRACE("get runtime layer path"); diff --git a/libs/linglong/src/linglong/runtime/run_context.h b/libs/linglong/src/linglong/runtime/run_context.h index af52fa543..92779568c 100644 --- a/libs/linglong/src/linglong/runtime/run_context.h +++ b/libs/linglong/src/linglong/runtime/run_context.h @@ -16,6 +16,9 @@ #include #include +#include +#include +#include namespace linglong::runtime { @@ -65,6 +68,13 @@ struct ResolveOptions class RunContext { public: + struct FilesystemPolicy + { + bool allowListConfigured{ false }; + std::vector allowList; + std::vector extra; + }; + RunContext(repo::OSTreeRepo &r) : repo(r) { @@ -83,6 +93,9 @@ class RunContext repo::OSTreeRepo &getRepo() const { return repo; } + std::string currentAppId() const; + const FilesystemPolicy &filesystemPolicy() const; + const std::string &getContainerId() const { return containerID; } const std::optional &getBaseLayer() const { return baseLayer; } @@ -128,6 +141,7 @@ class RunContext std::string containerID; std::filesystem::path bundle; std::map environment; + mutable std::optional filesystemPolicyCache; }; } // namespace linglong::runtime