From 7303e4767dc874157ae28b27b79b52dc360da30b Mon Sep 17 00:00:00 2001 From: Mohamed Date: Tue, 25 Aug 2026 23:04:42 +0300 Subject: [PATCH 1/4] MDEV-40786 Make mariadb-plugin a package manager Add search, install, and uninstall subcommands to mariadb-plugin with input validation and case normalization, while keeping legacy ENABLE and DISABLE syntax functional for backward compatibility. Bake the installation layout into the binary at compile time (RPM, DEB, or tarball), as it cannot be derived from the path alone since RPM and DEB use the same directory, and verify the executable path at runtime via argv[0] and mysys path helpers to ensure package managers and tarball directories target the correct installation. Delegate install and uninstall to the system package manager on RPM and DEB installations, so that plugin files stay owned by it. The plugin name maps to the uniform package name mariadb-plugin-. Commands run through fork and execvp with an argument vector, never a shell, and inherit the standard streams, so the package manager prompts the user itself and its exit code is passed through. RPM packages are named differently, so they get the uniform name as a Provides, derived from the plugin component in cmake/plugin.cmake. DEB packages already carry it. On RPM, uninstall resolves the real package name with rpm --whatprovides, because dnf 5 does not accept Provides names for removal. Search lists the plugins the installation can install: the plugin name, installed or available, and the description, filtered by an optional search term. The distribution's package index is queried for everything providing mariadb-plugin-* (repoquery for dnf, apt-cache, zypper search plus info --provides) and each output is parsed into the same uniform format, so the user always sees plugin names, not the distribution's package names. Add a --dry-run option that prints the commands install and uninstall would run instead of running them, with the package name resolved, and without requiring root. It is long only, as -n is taken by the legacy --no-defaults option. Test the command line handling in main.mariadb-plugin: unknown commands, argument counts, plugin name validation and that the deprecated ENABLE|DISABLE syntax still reaches the old code path. Installing and removing packages is not testable there, as the tool acts only when it runs from the location it was installed to. --- client/CMakeLists.txt | 5 + client/mysql_plugin.c | 1089 ++++++++++++++++++++++++- cmake/plugin.cmake | 7 + mysql-test/main/mariadb-plugin.result | 38 + mysql-test/main/mariadb-plugin.test | 77 ++ 5 files changed, 1211 insertions(+), 5 deletions(-) create mode 100644 mysql-test/main/mariadb-plugin.result create mode 100644 mysql-test/main/mariadb-plugin.test diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 1a56e55d5ac3c..db6fa588f5473 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -76,6 +76,11 @@ TARGET_LINK_LIBRARIES(mariadb-show ${CLIENT_LIB}) MYSQL_ADD_EXECUTABLE(mariadb-plugin mysql_plugin.c) TARGET_LINK_LIBRARIES(mariadb-plugin ${CLIENT_LIB}) +# The tool needs to know how MariaDB was installed to pick the way plugins +# are installed. The install layout is only known when building, so it is +# passed to the tool, together with the directories it has to verify. +SET_TARGET_PROPERTIES(mariadb-plugin PROPERTIES COMPILE_DEFINITIONS + "INSTALL_LAYOUT_${INSTALL_LAYOUT};INSTALL_PLUGINDIR=${INSTALL_PLUGINDIR};INSTALL_BINDIRABS=${INSTALL_BINDIRABS}") MYSQL_ADD_EXECUTABLE(mariadb-binlog mysqlbinlog.cc mysqlbinlog-engine.cc) TARGET_LINK_LIBRARIES(mariadb-binlog ${CLIENT_LIB} mysys_ssl) diff --git a/client/mysql_plugin.c b/client/mysql_plugin.c index 00870fa72e026..1ec342c5641d9 100644 --- a/client/mysql_plugin.c +++ b/client/mysql_plugin.c @@ -24,9 +24,36 @@ #include #include +#define STR(s) _STR(s) +#define _STR(s) #s + +/* + The build system defines INSTALL_LAYOUT_RPM or INSTALL_LAYOUT_DEB for the + packaged builds, and neither of them for a binary tarball. +*/ +#if defined(INSTALL_LAYOUT_RPM) +#define INSTALL_METHOD_NAME "rpm" +#elif defined(INSTALL_LAYOUT_DEB) +#define INSTALL_METHOD_NAME "deb" +#else +#define INSTALL_METHOD_NAME "tarball" +#endif + +/* + On rpm and deb installations install/uninstall delegate to the system + package manager. Tarball installations manage plugin files themselves, + so none of the delegation code applies (and neither do its unix-only + process primitives). +*/ +#if defined(INSTALL_LAYOUT_RPM) || defined(INSTALL_LAYOUT_DEB) +#define PKG_DELEGATION 1 +#include +#endif + /* Global variables. */ static uint my_end_arg= 0; static uint opt_verbose=0; +static my_bool opt_dry_run= 0; static uint opt_no_defaults= 0; static uint opt_print_defaults= 0; static char *opt_datadir=0, *opt_basedir=0, @@ -58,6 +85,9 @@ static struct my_option my_long_options[] = {"plugin-ini", 'i', "Read plugin information from configuration file " "specified instead of from /.ini.", 0, 0, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, + {"dry-run", 0, "Print the commands that install and uninstall would run, " + "without running them.", + &opt_dry_run, &opt_dry_run, 0, GET_BOOL, NO_ARG, 0, 0, 0, 0, 0, 0}, {"no-defaults", 'n', "Do not read values from configuration file.", 0, 0, 0, GET_NO_ARG, NO_ARG, 0, 0, 0, 0, 0, 0}, {"print-defaults", 'P', "Show default values from configuration file.", @@ -87,6 +117,14 @@ static int find_plugin(char *tp_path); static int build_bootstrap_file(char *operation, char *bootstrap); static int dump_bootstrap_file(char *bootstrap_file); static int bootstrap_server(char *server_path, char *bootstrap_file); +static void usage(void); +static int run_new_command(int argc, char **argv); +static int validate_plugin_name(const char *name); +static int is_legacy_syntax(int argc, char **argv); +static int detect_install_method(char *basedir, size_t basedir_size); +static int do_search(const char *name, const char *basedir); +static int do_install(const char *name, const char *basedir); +static int do_uninstall(const char *name, const char *basedir); int main(int argc,char *argv[]) @@ -100,6 +138,20 @@ int main(int argc,char *argv[]) sf_leaking_memory=1; /* don't report memory leaks on early exits */ plugin_data.name= 0; /* initialize name */ + /* + The new package-manager style commands (search|install|uninstall) are + handled in run_new_command(). The legacy " ENABLE|DISABLE" + syntax is recognized by scanning the raw arguments, before any option + parsing, and continues through the original code path below unchanged, + for backward compatibility. + */ + if (!is_legacy_syntax(argc, argv)) + { + error= run_new_command(argc, argv); + my_end(my_end_arg); + exit(error); + } + /* The following operations comprise the method for enabling or disabling a plugin. We begin by processing the command options then check the @@ -416,11 +468,14 @@ static int get_default_values() static void usage(void) { print_version(); - puts("Copyright (c) 2011, 2015, Oracle and/or its affiliates. " - "All rights reserved.\n"); - puts("Enable or disable plugins."); - printf("\nUsage: %s [options] ENABLE|DISABLE\n\nOptions:\n", - my_progname); + puts(ORACLE_WELCOME_COPYRIGHT_NOTICE("2011")); + puts("Manage MariaDB plugins across package managers and binary distributions."); + printf("\nUsage:\n"); + printf(" %s search []\n", my_progname); + printf(" %s install \n", my_progname); + printf(" %s uninstall \n\n", my_progname); + printf("Legacy syntax (deprecated, kept for backward compatibility):\n"); + printf(" %s [options] ENABLE|DISABLE\n\nOptions:\n", my_progname); my_print_help(my_long_options); puts("\n"); } @@ -1239,3 +1294,1027 @@ static int bootstrap_server(char *server_path, char *bootstrap_file) return error; } + + +/** + Detect the legacy " ENABLE|DISABLE" command line syntax. + + The check is done on the raw arguments, before any option parsing, so + that legacy invocations take the original code path unchanged. + + @param[in] argc The number of arguments. + @param[in] argv The arguments. + + @retval int legacy syntax = 1, new syntax = 0 +*/ + +static int is_legacy_syntax(int argc, char **argv) +{ + int i; + + for (i= 1; i < argc; i++) + { + /* + Whichever keyword comes first decides, so that "search enable" is a + search for the word enable, while "myplugin ENABLE" stays the + deprecated syntax. + */ + if (strcmp(argv[i], "search") == 0 || + strcmp(argv[i], "install") == 0 || + strcmp(argv[i], "uninstall") == 0) + return 0; + if (strcasecmp(argv[i], "ENABLE") == 0 || + strcasecmp(argv[i], "DISABLE") == 0) + return 1; + } + return 0; +} + + +/** + Check that a plugin name contains only safe characters. + + The name is later used to construct package names and file paths, so + only lower case alphanumerics, '_' and '-' are accepted. The name is + expected to be normalized to lower case before this check. + + @param[in] name The normalized plugin name. + + @retval int error = 1, success = 0 +*/ + +static int validate_plugin_name(const char *name) +{ + const char *p; + + if (*name == '\0') + { + fprintf(stderr, "ERROR: plugin name cannot be empty.\n"); + return 1; + } + for (p= name; *p; p++) + { + if (!isalnum((unsigned char) *p) && *p != '_' && *p != '-') + { + fprintf(stderr, "ERROR: invalid character '%c' in plugin name. " + "Use only [a-z0-9_-].\n", *p); + return 1; + } + } + return 0; +} + +/** + Verify that the tool is part of the installation it was built for. + + The installation method is known at build time, so only the location has + to be checked. It is taken from argv[0] and not from the server, as one + machine can have several server installations. + + @param[out] basedir The base directory, empty for rpm and deb, + where the package manager owns the files. + @param[in] basedir_size The size of the basedir buffer. + + @retval int error = 1, success = 0 +*/ + +static int detect_install_method(char *basedir, size_t basedir_size) +{ + char self_path[FN_REFLEN], real_path[FN_REFLEN], real_dir[FN_REFLEN]; + size_t length; +#if !defined(INSTALL_LAYOUT_RPM) && !defined(INSTALL_LAYOUT_DEB) + char plugin_dir[FN_REFLEN]; + char *slash; +#endif + + /* + my_path() searches PATH when argv[0] is a bare program name. The path + is resolved afterwards, so that a symbolic link, like the one for the + old mysql_plugin name, does not hide where the tool is installed. + */ + my_path(self_path, my_progname, ""); + safe_strcat(self_path, sizeof(self_path), base_name(my_progname)); + if (my_realpath(real_path, self_path, MYF(0))) + safe_strcpy(real_path, sizeof(real_path), self_path); + + dirname_part(real_dir, real_path, &length); + + length= strlen(real_dir); + while (length > 1 && (real_dir[length - 1] == FN_LIBCHAR || + real_dir[length - 1] == FN_LIBCHAR2)) + real_dir[--length]= '\0'; + +#if defined(INSTALL_LAYOUT_RPM) || defined(INSTALL_LAYOUT_DEB) + if (strcmp(real_dir, STR(INSTALL_BINDIRABS)) != 0) + { + fprintf(stderr, "ERROR: this is a %s build, but it runs from '%s' " + "instead of '%s', so it is not part of a %s installation.\n", + INSTALL_METHOD_NAME, real_dir, STR(INSTALL_BINDIRABS), + INSTALL_METHOD_NAME); + return 1; + } + basedir[0]= '\0'; +#else + /* The base directory is one level above the directory of the tool. */ + safe_strcpy(basedir, basedir_size, real_dir); + slash= strrchr(basedir, FN_LIBCHAR); + if (!slash) + slash= strrchr(basedir, FN_LIBCHAR2); + if (!slash) + { + fprintf(stderr, "ERROR: cannot determine the MariaDB base directory " + "from '%s'.\n", real_dir); + return 1; + } + *slash= '\0'; + + safe_strcpy(plugin_dir, sizeof(plugin_dir), basedir); + safe_strcat(plugin_dir, sizeof(plugin_dir), "/" STR(INSTALL_PLUGINDIR)); + if (!file_exists(plugin_dir)) + { + fprintf(stderr, "ERROR: '%s' does not look like a MariaDB installation, " + "'%s' not found.\n", basedir, plugin_dir); + return 1; + } +#endif + return 0; +} + + +#ifdef PKG_DELEGATION + +/** + Pick the package manager to delegate to. + + On deb installations it is always apt-get (the script-stable interface, + unlike apt). On rpm installations dnf and zypper manage the same rpm + database, so whichever is present is usable; dnf is tried first. + + @retval const char* the program name, or NULL with an error printed +*/ + +static const char *get_package_manager(void) +{ +#if defined(INSTALL_LAYOUT_DEB) + return "apt-get"; +#else + char dir[FN_REFLEN]; + + if (find_file_in_path(dir, "dnf")) + return "dnf"; + if (find_file_in_path(dir, "zypper")) + return "zypper"; + fprintf(stderr, "ERROR: no package manager found: neither dnf nor zypper " + "is in PATH.\n"); + return NULL; +#endif +} + + +/** + Refuse to continue without root privileges. + + The package manager would fail anyway, but only after a repository + refresh, with an error that does not mention this tool. + + @param[in] verb The command name, for the error message. + + @retval int error = 1, success = 0 +*/ + +static int check_root(const char *verb) +{ + if (!opt_dry_run && geteuid() != 0) + { + fprintf(stderr, "ERROR: '%s' requires root privileges. " + "Run as root or with sudo.\n", verb); + return 1; + } + return 0; +} + + +/** + Run a command and wait for it to finish. + + The command is executed directly, not through a shell, so the arguments + cannot be reinterpreted. The child inherits the standard streams: the + package manager talks to the user directly, including its own + confirmation prompts and progress output. + + @param[in] cmd_argv NULL-terminated argument vector. + + @retval int the command exit code, 127 if it could not be run +*/ + +static int run_argv(char **cmd_argv) +{ + pid_t pid; + int status; + + /* + --dry-run only stops the commands that change the system. The queries + that read the package database still run, so that what is printed is + what would really be executed, package names resolved and all. + */ + if (opt_dry_run) + { + int i; + for (i= 0; cmd_argv[i]; i++) + printf("%s%s", i ? " " : "", cmd_argv[i]); + printf("\n"); + return 0; + } + + fflush(stdout); + fflush(stderr); + if ((pid= fork()) < 0) + { + fprintf(stderr, "ERROR: cannot fork: %s.\n", strerror(errno)); + return 127; + } + if (pid == 0) + { + execvp(cmd_argv[0], cmd_argv); + fprintf(stderr, "ERROR: cannot run '%s': %s.\n", cmd_argv[0], + strerror(errno)); + _exit(127); + } + while (waitpid(pid, &status, 0) < 0) + { + if (errno != EINTR) + { + fprintf(stderr, "ERROR: cannot wait for '%s': %s.\n", cmd_argv[0], + strerror(errno)); + return 127; + } + } + if (WIFSIGNALED(status)) + { + fprintf(stderr, "ERROR: '%s' was terminated by signal %d.\n", + cmd_argv[0], WTERMSIG(status)); + return 127; + } + return WEXITSTATUS(status); +} + + +/** + Run a command and capture its standard output. + + Standard error stays on the terminal, unless quiet_stderr is set, for + commands whose failure is an expected answer and not an error. The pipe + is read to the end, so that the child never blocks writing. + + @param[in] cmd_argv NULL-terminated argument vector. + @param[out] out Initialized string, replaced by the output. + @param[in] quiet_stderr Discard the command's standard error. + + @retval int the command exit code, 127 if it could not be run +*/ + +static int run_argv_capture(char **cmd_argv, DYNAMIC_STRING *out, + int quiet_stderr) +{ + char buf[4096]; + int fds[2]; + pid_t pid; + int status; + ssize_t n; + my_bool oom= FALSE; + + dynstr_set(out, ""); + if (pipe(fds)) + { + fprintf(stderr, "ERROR: cannot create a pipe: %s.\n", strerror(errno)); + return 127; + } + fflush(stdout); + fflush(stderr); + if ((pid= fork()) < 0) + { + fprintf(stderr, "ERROR: cannot fork: %s.\n", strerror(errno)); + close(fds[0]); + close(fds[1]); + return 127; + } + if (pid == 0) + { + dup2(fds[1], STDOUT_FILENO); + if (quiet_stderr) + { + int devnull= open("/dev/null", O_WRONLY); + if (devnull >= 0) + dup2(devnull, fileno(stderr)); + } + close(fds[0]); + close(fds[1]); + execvp(cmd_argv[0], cmd_argv); + fprintf(stderr, "ERROR: cannot run '%s': %s.\n", cmd_argv[0], + strerror(errno)); + _exit(127); + } + close(fds[1]); + while ((n= read(fds[0], buf, sizeof(buf)))) + { + if (n < 0) + { + if (errno == EINTR) + continue; + break; + } + /* keep reading after a failed append, so the child can still finish */ + if (!oom) + oom= dynstr_append_mem(out, buf, (size_t) n); + } + close(fds[0]); + while (waitpid(pid, &status, 0) < 0) + { + if (errno != EINTR) + { + fprintf(stderr, "ERROR: cannot wait for '%s': %s.\n", cmd_argv[0], + strerror(errno)); + return 127; + } + } + if (oom) + { + fprintf(stderr, "ERROR: out of memory reading the output of '%s'.\n", + cmd_argv[0]); + return 127; + } + if (WIFSIGNALED(status)) + return 127; + return WEXITSTATUS(status); +} + + +#define PLUGIN_PREFIX "mariadb-plugin-" +#define PLUGIN_PREFIX_LEN (sizeof(PLUGIN_PREFIX) - 1) +#define PACKAGE_NAME_SIZE (PLUGIN_PREFIX_LEN + NAME_CHAR_LEN + 1) + + +/** + Build the distribution-independent package name (D2): mariadb-plugin- + followed by the plugin name, which is already validated and lowercased. + + @param[out] to Buffer for the package name. + @param[in] size Size of the buffer. + @param[in] name The normalized plugin name. +*/ + +static void build_package_name(char *to, size_t size, const char *name) +{ + safe_strcpy(to, size, PLUGIN_PREFIX); + safe_strcat(to, size, name); +} + + +/* + Search: every backend answers the same two questions - which packages + provide mariadb-plugin-* and which of them are installed - through a + different query. The results are normalized into plugin_list and + printed in one format, so the user sees plugin names as install expects + them, never the distribution's own package names. +*/ + +struct plugin_entry +{ + char name[NAME_CHAR_LEN + 1]; /* uniform name, prefix stripped */ + char package[NAME_CHAR_LEN + 1]; /* real package name, rpm only */ + char description[160]; + int installed; +}; + +static DYNAMIC_ARRAY plugin_list; +static DYNAMIC_STRING search_output; + +#define PLUGIN_AT(i) (dynamic_element(&plugin_list, (i), struct plugin_entry *)) + + +static struct plugin_entry *find_plugin_entry(const char *name) +{ + size_t i; + + for (i= 0; i < plugin_list.elements; i++) + if (strcmp(PLUGIN_AT(i)->name, name) == 0) + return PLUGIN_AT(i); + return NULL; +} + + +/** + Add a plugin to the result list, or return the existing entry with the + same uniform name. The prefix is stripped from the stored name. + + The list reallocates, so the entry is only valid until the next one. + + @param[in] package_name The uniform package name, mariadb-plugin-x. + + @retval struct plugin_entry* the entry, NULL when out of memory +*/ + +static struct plugin_entry *add_plugin_entry(const char *package_name) +{ + struct plugin_entry e, *found; + const char *name= package_name + PLUGIN_PREFIX_LEN; + + if ((found= find_plugin_entry(name))) + return found; + bzero(&e, sizeof(e)); + safe_strcpy(e.name, sizeof(e.name), name); + if (insert_dynamic(&plugin_list, &e)) + return NULL; + return PLUGIN_AT(plugin_list.elements - 1); +} + + +static int cmp_plugin_entries(const void *a, const void *b) +{ + return strcmp(((const struct plugin_entry *) a)->name, + ((const struct plugin_entry *) b)->name); +} + + +/** + Print the collected plugins that match the search term. + + @param[in] term Substring to match against plugin names, "" for all. + + @retval int no matches = 1, matches printed = 0 +*/ + +static int print_search_results(const char *term) +{ + size_t i, width= 0, matches= 0; + + sort_dynamic(&plugin_list, cmp_plugin_entries); + for (i= 0; i < plugin_list.elements; i++) + { + if (*term && !strstr(PLUGIN_AT(i)->name, term)) + continue; + matches++; + if (strlen(PLUGIN_AT(i)->name) > width) + width= strlen(PLUGIN_AT(i)->name); + } + if (!matches) + { + if (*term) + printf("No plugins matching '%s' found.\n", term); + else + printf("No plugins found.\n"); + return 1; + } + for (i= 0; i < plugin_list.elements; i++) + { + struct plugin_entry *e= PLUGIN_AT(i); + + if (*term && !strstr(e->name, term)) + continue; + printf("%-*s %-9s %s\n", (int) width, e->name, + e->installed ? "installed" : "available", e->description); + } + return 0; +} + + +#ifndef INSTALL_LAYOUT_DEB + +static struct plugin_entry *find_plugin_by_package(const char *package) +{ + size_t i; + + for (i= 0; i < plugin_list.elements; i++) + if (strcmp(PLUGIN_AT(i)->package, package) == 0) + return PLUGIN_AT(i); + return NULL; +} + + +/** + Parse dnf repoquery output in the format + @@@| + + into the result list. The uniform name is one of the capabilities, so + no name mapping is needed in the tool. + + @param[in] output The captured repoquery output, modified in place. + @param[in] installed Mark the found plugins as installed. +*/ + +static void parse_dnf_records(char *output, int installed) +{ + struct plugin_entry *e= NULL; + char *line, *next, *sep; + char package[NAME_CHAR_LEN + 1], summary[160]; + + package[0]= summary[0]= '\0'; + for (line= output; line && *line; line= next) + { + if ((next= strchr(line, '\n'))) + *next++= '\0'; + if (strncmp(line, "@@@", 3) == 0) + { + line+= 3; + if ((sep= strchr(line, '|'))) + *sep++= '\0'; + safe_strcpy(package, sizeof(package), line); + safe_strcpy(summary, sizeof(summary), sep ? sep : ""); + continue; + } + /* a capability line; the version part after the name is irrelevant */ + if ((sep= strchr(line, ' '))) + *sep= '\0'; + if (strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) != 0 || + !package[0]) + continue; + if (!(e= add_plugin_entry(line))) + return; + safe_strcpy(e->package, sizeof(e->package), package); + if (!e->description[0]) + safe_strcpy(e->description, sizeof(e->description), summary); + if (installed) + e->installed= 1; + } +} + + +static int search_dnf(void) +{ + char *repo_argv[]= { + (char *) "dnf", (char *) "-q", (char *) "repoquery", + (char *) "--whatprovides", (char *) PLUGIN_PREFIX "*", + (char *) "--qf", (char *) "@@@%{name}|%{summary}\\n%{provides}\\n", 0 }; + char *inst_argv[]= { + (char *) "dnf", (char *) "-q", (char *) "repoquery", + (char *) "--installed", (char *) "--whatprovides", + (char *) PLUGIN_PREFIX "*", + (char *) "--qf", (char *) "@@@%{name}|%{summary}\\n%{provides}\\n", 0 }; + int error; + + if ((error= run_argv_capture(repo_argv, &search_output, 0))) + return error; + parse_dnf_records(search_output.str, 0); + + /* same query against the installed packages only, for the status */ + if (run_argv_capture(inst_argv, &search_output, 0) == 0) + parse_dnf_records(search_output.str, 1); + return 0; +} + + +/** + Parse "zypper --xmlout search --provides" solvable lines, e.g. + . + zypper never reports which capability matched, so the uniform names are + filled in afterwards by search_zypper_names(). + + @param[in] output The captured zypper output, modified in place. +*/ + +static void parse_zypper_solvables(char *output) +{ + struct plugin_entry e; + char *line, *next, *val, *end; + + for (line= output; line && *line; line= next) + { + if ((next= strchr(line, '\n'))) + *next++= '\0'; + if (!strstr(line, "package; + cmd_argv[n]= 0; + error= run_argv_capture(cmd_argv, &search_output, 0); + my_free(cmd_argv); + if (error) + return 1; + + /* nothing is added below, so the entry a header selects stays valid */ + for (line= search_output.str; line && *line; line= next) + { + if ((next= strchr(line, '\n'))) + *next++= '\0'; + if (strncmp(line, "Name", 4) == 0 && (sep= strchr(line, ':'))) + { + for (sep++; *sep == ' '; sep++) ; + e= find_plugin_by_package(sep); + continue; + } + for (cap= line; *cap == ' '; cap++) ; + if (cap == line || !e || + strncmp(cap, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) != 0) + continue; + if ((sep= strchr(cap, ' '))) + *sep= '\0'; + safe_strcpy(e->name, sizeof(e->name), cap + PLUGIN_PREFIX_LEN); + } + + /* drop packages whose uniform name never showed up */ + for (i= 0; i < plugin_list.elements; ) + { + if (PLUGIN_AT(i)->name[0]) + i++; + else + delete_dynamic_element(&plugin_list, i); + } + return 0; +} + + +static int search_zypper(void) +{ + char *cmd_argv[7]; + int error; + + cmd_argv[0]= (char *) "zypper"; + cmd_argv[1]= (char *) "-n"; + cmd_argv[2]= (char *) "--xmlout"; + cmd_argv[3]= (char *) "search"; + cmd_argv[4]= (char *) "--provides"; + cmd_argv[5]= (char *) PLUGIN_PREFIX "*"; + cmd_argv[6]= 0; + /* zypper exits with 104 when nothing matches: an answer, not an error */ + error= run_argv_capture(cmd_argv, &search_output, 0); + if (error && error != 104) + return error; + parse_zypper_solvables(search_output.str); + if (plugin_list.elements && search_zypper_names()) + return 1; + return 0; +} + +#else /* INSTALL_LAYOUT_DEB */ + +/** + Parse "apt-cache search" output, " - " per line, + into the result list. deb package names are already the uniform names. + + @param[in] output The captured apt-cache output, modified in place. +*/ + +static void parse_apt_records(char *output) +{ + struct plugin_entry *e; + char *line, *next, *sep; + + for (line= output; line && *line; line= next) + { + if ((next= strchr(line, '\n'))) + *next++= '\0'; + if ((sep= strstr(line, " - "))) + *sep= '\0'; + if (strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) != 0) + continue; + if (!(e= add_plugin_entry(line))) + return; + if (sep && !e->description[0]) + safe_strcpy(e->description, sizeof(e->description), sep + 3); + } +} + + +static int search_apt(void) +{ + char *cmd_argv[6]; + char *line, *next, *sep; + struct plugin_entry *e; + int error; + + cmd_argv[0]= (char *) "apt-cache"; + cmd_argv[1]= (char *) "search"; + cmd_argv[2]= (char *) "--names-only"; + cmd_argv[3]= (char *) "^" PLUGIN_PREFIX; + cmd_argv[4]= 0; + if ((error= run_argv_capture(cmd_argv, &search_output, 0))) + return error; + parse_apt_records(search_output.str); + + /* + dpkg-query prints "no packages found" on stderr and exits nonzero + when nothing is installed, which is an answer here, not an error. + */ + cmd_argv[0]= (char *) "dpkg-query"; + cmd_argv[1]= (char *) "-W"; + cmd_argv[2]= (char *) "-f=${Package} ${db:Status-Status}\n"; + cmd_argv[3]= (char *) PLUGIN_PREFIX "*"; + cmd_argv[4]= 0; + if (run_argv_capture(cmd_argv, &search_output, 1)) + return 0; + for (line= search_output.str; line && *line; line= next) + { + if ((next= strchr(line, '\n'))) + *next++= '\0'; + if (!(sep= strchr(line, ' '))) + continue; + *sep++= '\0'; + if (strcmp(sep, "installed") == 0 && + strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) == 0 && + (e= add_plugin_entry(line))) + e->installed= 1; + } + return 0; +} + +#endif /* INSTALL_LAYOUT_DEB */ + +#endif /* PKG_DELEGATION */ + + +/** + Search for plugins. + + On rpm and deb installations the distribution's package index is the + plugin metadata: it is queried for everything providing mariadb-plugin-* + and the result is shown uniformly as plugin names, never as the + distribution's own package names. Needs no root. + + @param[in] term Substring to match, empty to list all plugins. + @param[in] basedir The base directory, empty for packaged installations. + + @retval int error or no matches = 1, matches printed = 0 +*/ + +static int do_search(const char *term, const char *basedir) +{ +#ifdef PKG_DELEGATION + int error; + + if (my_init_dynamic_array(PSI_NOT_INSTRUMENTED, &plugin_list, + sizeof(struct plugin_entry), 32, 32, MYF(MY_WME))) + return 1; + if (init_dynamic_string(&search_output, "", 16 * 1024, 16 * 1024)) + { + delete_dynamic(&plugin_list); + return 1; + } +#ifdef INSTALL_LAYOUT_DEB + error= search_apt(); +#else + { + const char *pm= get_package_manager(); + error= pm ? (strcmp(pm, "dnf") == 0 ? search_dnf() : search_zypper()) : 1; + } +#endif + if (!error) + error= print_search_results(term); + dynstr_free(&search_output); + delete_dynamic(&plugin_list); + return error ? 1 : 0; +#else + printf("search: not available for %s installations yet, the plugin " + "index does not exist\n", INSTALL_METHOD_NAME); + return 1; +#endif +} + + +/** + Install a plugin. + + On rpm and deb installations the work is delegated to the system package + manager, which resolves the uniform package name through its own real + package names (via Provides on rpm). Its exit code is passed through. + + @param[in] name The normalized plugin name. + @param[in] basedir The base directory, empty for packaged installations. + + @retval int error = nonzero, success = 0 +*/ + +static int do_install(const char *name, const char *basedir) +{ +#ifdef PKG_DELEGATION + char package[PACKAGE_NAME_SIZE]; + const char *pm; + char *cmd_argv[4]; + + if (check_root("install")) + return 1; + if (!(pm= get_package_manager())) + return 1; + + build_package_name(package, sizeof(package), name); + cmd_argv[0]= (char *) pm; + cmd_argv[1]= (char *) "install"; + cmd_argv[2]= package; + cmd_argv[3]= 0; + return run_argv(cmd_argv); +#else + printf("install: '%s' (%s installation%s%s) not implemented yet\n", name, + INSTALL_METHOD_NAME, *basedir ? ", basedir=" : "", basedir); + return 0; +#endif +} + + +/** + Uninstall a plugin. + + On deb installations the packages carry the uniform name, so it is passed + to apt-get directly. On rpm installations the uniform name is only a + Provides alias of the real package name, and dnf 5 does not resolve + "remove" arguments through Provides (dnf 4 and zypper do), so the alias + is first translated by querying the rpm database. This also gives a + clear error when the plugin is not installed. + + @param[in] name The normalized plugin name. + @param[in] basedir The base directory, empty for packaged installations. + + @retval int error = nonzero, success = 0 +*/ + +static int do_uninstall(const char *name, const char *basedir) +{ +#ifdef PKG_DELEGATION + char package[PACKAGE_NAME_SIZE]; + const char *pm; + const char *target; + char *cmd_argv[7]; + int error; +#ifdef INSTALL_LAYOUT_RPM + DYNAMIC_STRING providers; + char *nl; +#endif + + if (check_root("uninstall")) + return 1; + if (!(pm= get_package_manager())) + return 1; + + build_package_name(package, sizeof(package), name); + target= package; + +#ifdef INSTALL_LAYOUT_RPM + if (init_dynamic_string(&providers, "", 256, 256)) + return 1; + cmd_argv[0]= (char *) "rpm"; + cmd_argv[1]= (char *) "-q"; + cmd_argv[2]= (char *) "--whatprovides"; + cmd_argv[3]= package; + cmd_argv[4]= (char *) "--qf"; + cmd_argv[5]= (char *) "%{NAME}\n"; + cmd_argv[6]= 0; + if (run_argv_capture(cmd_argv, &providers, 0) || !providers.length) + { + fprintf(stderr, "ERROR: plugin '%s' is not installed.\n", name); + dynstr_free(&providers); + return 1; + } + if (!(nl= strchr(providers.str, '\n'))) + nl= strend(providers.str); + if (nl[0] && nl[1]) + { + fprintf(stderr, "ERROR: several packages provide '%s':\n%s" + "Remove the right one with the package manager directly.\n", + package, providers.str); + dynstr_free(&providers); + return 1; + } + *nl= '\0'; + target= providers.str; +#endif + + cmd_argv[0]= (char *) pm; + cmd_argv[1]= (char *) "remove"; + cmd_argv[2]= (char *) target; + cmd_argv[3]= 0; + error= run_argv(cmd_argv); +#ifdef INSTALL_LAYOUT_RPM + dynstr_free(&providers); +#endif + return error; +#else + printf("uninstall: '%s' (%s installation%s%s) not implemented yet\n", name, + INSTALL_METHOD_NAME, *basedir ? ", basedir=" : "", basedir); + return 0; +#endif +} + + +/** + Run the new package-manager style commands. + + Parses the options (--help, --version, etc. are handled by + handle_options), then validates the verb and the plugin name and + dispatches to the appropriate command handler. The plugin name is + normalized to lower case before validation. + + @param[in] argc The number of arguments. + @param[in] argv The arguments. + + @retval int error = 1, success = 0 +*/ + +static int run_new_command(int argc, char **argv) +{ + char name[NAME_CHAR_LEN + 1]; + char basedir[FN_REFLEN]; + const char *verb; + size_t i, len; + int error, is_search; + + if ((error= handle_options(&argc, &argv, my_long_options, get_one_option))) + return 1; + + if (argc < 1) + { + usage(); + return 1; + } + + verb= argv[0]; + if (strcmp(verb, "search") != 0 && strcmp(verb, "install") != 0 && + strcmp(verb, "uninstall") != 0) + { + fprintf(stderr, "ERROR: unknown command '%s'.\n", verb); + usage(); + return 1; + } + + /* the search term is optional: without it every plugin is listed */ + is_search= strcmp(verb, "search") == 0; + if (is_search ? argc > 2 : argc != 2) + { + fprintf(stderr, is_search ? + "ERROR: '%s' takes at most one search term.\n" : + "ERROR: '%s' requires exactly one plugin name.\n", verb); + usage(); + return 1; + } + + name[0]= '\0'; + if (argc == 2) + { + len= strlen(argv[1]); + if (len > NAME_CHAR_LEN) + { + fprintf(stderr, "ERROR: plugin name is too long (max %d characters).\n", + NAME_CHAR_LEN); + return 1; + } + for (i= 0; i <= len; i++) + name[i]= (char) tolower((unsigned char) argv[1][i]); + + if (validate_plugin_name(name)) + return 1; + } + + if (detect_install_method(basedir, sizeof(basedir))) + return 1; + + if (is_search) + return do_search(name, basedir); + if (strcmp(verb, "install") == 0) + return do_install(name, basedir); + return do_uninstall(name, basedir); +} diff --git a/cmake/plugin.cmake b/cmake/plugin.cmake index 07c9220525558..a59fadcf4dfef 100644 --- a/cmake/plugin.cmake +++ b/cmake/plugin.cmake @@ -278,6 +278,13 @@ MACRO(MYSQL_ADD_PLUGIN) IF (NOT ARG_CLIENT) SET(CPACK_RPM_${ARG_COMPONENT}_PACKAGE_REQUIRES "MariaDB-server${ver}" PARENT_SCOPE) ENDIF() + + # rpm packages have their own names, but plugins are installed + # everywhere by the deb-style name, mariadb-plugin- + STRING(REGEX REPLACE "-engine(-|$)" "\\1" plugin_package "${ARG_COMPONENT}") + SET(CPACK_RPM_${ARG_COMPONENT}_PACKAGE_PROVIDES + "mariadb-plugin-${plugin_package}" PARENT_SCOPE) + SET(CPACK_RPM_${ARG_COMPONENT}_USER_FILELIST ${ignored} PARENT_SCOPE) IF (ARG_VERSION) SET(CPACK_RPM_${ARG_COMPONENT}_PACKAGE_VERSION ${SERVER_VERSION}_${ARG_VERSION} PARENT_SCOPE) diff --git a/mysql-test/main/mariadb-plugin.result b/mysql-test/main/mariadb-plugin.result new file mode 100644 index 0000000000000..1f4db80f195ae --- /dev/null +++ b/mysql-test/main/mariadb-plugin.result @@ -0,0 +1,38 @@ +# +# Unknown command +# +ERROR: unknown command 'nosuchcommand'. +# +# A plugin name is required, and only one +# +ERROR: 'install' requires exactly one plugin name. +ERROR: 'uninstall' requires exactly one plugin name. +ERROR: 'install' requires exactly one plugin name. +# +# search takes an optional term, but not more than one +# +ERROR: 'search' takes at most one search term. +# +# Plugin names are validated, as they become package names +# +ERROR: invalid character ';' in plugin name. Use only [a-z0-9_-]. +ERROR: invalid character ' ' in plugin name. Use only [a-z0-9_-]. +ERROR: invalid character '.' in plugin name. Use only [a-z0-9_-]. +ERROR: plugin name cannot be empty. +ERROR: plugin name is too long (max 64 characters). +# +# A command word wins over the deprecated keywords, so that a plugin +# can be searched for by a name containing them +# +ERROR: 'search' takes at most one search term. +ERROR: 'install' requires exactly one plugin name. +# +# --dry-run is accepted as an option +# +# +# The deprecated syntax still reaches the old code path, which +# asks for its own options +# +ERROR: Missing --basedir option. +ERROR: Missing --basedir option. +ERROR: Missing --basedir option. diff --git a/mysql-test/main/mariadb-plugin.test b/mysql-test/main/mariadb-plugin.test new file mode 100644 index 0000000000000..2e785ca5162f5 --- /dev/null +++ b/mysql-test/main/mariadb-plugin.test @@ -0,0 +1,77 @@ +# +# MDEV-40786 mariadb-plugin as a plugin package manager. +# +# Only the command line handling is tested here, as it is the same for +# every installation method. Installing, removing and searching for +# plugins is not testable in mtr: the tool acts only when it runs from +# the location it was installed to, never from a build tree, so those +# commands are covered by the install and upgrade builders instead. +# +# Errors are printed on stderr, the usage text on stdout, so only stderr +# is kept below to leave paths and version strings out of the result. +# + +--echo # +--echo # Unknown command +--echo # +--error 1 +--exec $MYSQL_PLUGIN nosuchcommand foo 2>&1 > /dev/null + +--echo # +--echo # A plugin name is required, and only one +--echo # +--error 1 +--exec $MYSQL_PLUGIN install 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN uninstall 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install foo bar 2>&1 > /dev/null + +--echo # +--echo # search takes an optional term, but not more than one +--echo # +--error 1 +--exec $MYSQL_PLUGIN search foo bar 2>&1 > /dev/null + +--echo # +--echo # Plugin names are validated, as they become package names +--echo # +--error 1 +--exec $MYSQL_PLUGIN install "a;b" 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install "a b" 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install "../etc" 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install "" 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2>&1 > /dev/null + +--echo # +--echo # A command word wins over the deprecated keywords, so that a plugin +--echo # can be searched for by a name containing them +--echo # +--error 1 +--exec $MYSQL_PLUGIN search enable extra 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install disable extra 2>&1 > /dev/null + +--echo # +--echo # --dry-run is accepted as an option +--echo # +--exec $MYSQL_PLUGIN --dry-run --help > /dev/null 2>&1 + +# +# --no-defaults keeps my_print_defaults out of it: it is looked up in +# hardcoded system paths, so whether it is found depends on the machine. +# +--echo # +--echo # The deprecated syntax still reaches the old code path, which +--echo # asks for its own options +--echo # +--error 1 +--exec $MYSQL_PLUGIN --no-defaults foo ENABLE 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN --no-defaults foo DISABLE 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN --no-defaults foo enable 2>&1 > /dev/null From a20f13e89c2c19d7f7d6f29ff8c3b7fefd7010fa Mon Sep 17 00:00:00 2001 From: Mohamed Date: Thu, 3 Sep 2026 11:43:21 +0300 Subject: [PATCH 2/4] MDEV-40786 Tarball phase Uninstall removes a plugin from a tarball installation using its manifest, /.mariadb-plugin/.list: remove the listed files and empty recorded directories, then the manifest itself. Paths are validated before deletion, and failed file removals keep the manifest so uninstall can be retried. Install reads the plugin tarball itself and checks entries before extraction: links, devices, absolute paths and ".." are refused, setuid bits dropped, existing files never overwritten, and the CPack top directory stripped when it is named like the archive. Files are recorded in the manifest for rollback and uninstall. --file=PATH supplies a local tarball and --sha256=HEX verifies it. Installation prints activation instructions without editing server configuration. Add index-based downloads with system libcurl and --base-url. Verify downloaded archives against the index's SHA-256. Keep local and downloaded archives in private temporary files for verification and extraction. Resolve file operations through checked parent directories, fix command routing, reject legacy --dry-run, and propagate native search errors. Improve libcurl build integration. Add tarball search using the same repository index and compatibility checks as install. List matching plugin names with descriptions and installed status from local manifests, without downloading archives or changing the installation. Retain author and license metadata. Reject invalid or ambiguous metadata before printing search results. Validate manifest identity before uninstall and reject non-regular manifests without blocking, while preserving partial-install rollback. Tests cover tarball search filtering, compatible-build selection, metadata validation, installed status, manifest safety, rollback, metadata-only network access and CLI regressions. --- client/CMakeLists.txt | 6 +- client/mysql_plugin.c | 2224 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 2024 insertions(+), 206 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index db6fa588f5473..93916d5f870b3 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -75,7 +75,11 @@ MYSQL_ADD_EXECUTABLE(mariadb-show mysqlshow.c) TARGET_LINK_LIBRARIES(mariadb-show ${CLIENT_LIB}) MYSQL_ADD_EXECUTABLE(mariadb-plugin mysql_plugin.c) -TARGET_LINK_LIBRARIES(mariadb-plugin ${CLIENT_LIB}) +TARGET_LINK_LIBRARIES(mariadb-plugin ${CLIENT_LIB} mysys_ssl) +IF(NOT INSTALL_LAYOUT MATCHES "^(RPM|DEB)$") + FIND_PACKAGE(CURL REQUIRED) + TARGET_LINK_LIBRARIES(mariadb-plugin CURL::libcurl) +ENDIF() # The tool needs to know how MariaDB was installed to pick the way plugins # are installed. The install layout is only known when building, so it is # passed to the tool, together with the directories it has to verify. diff --git a/client/mysql_plugin.c b/client/mysql_plugin.c index 1ec342c5641d9..ee5d2a34819b5 100644 --- a/client/mysql_plugin.c +++ b/client/mysql_plugin.c @@ -48,12 +48,25 @@ #if defined(INSTALL_LAYOUT_RPM) || defined(INSTALL_LAYOUT_DEB) #define PKG_DELEGATION 1 #include +#elif defined(_WIN32) +#include #endif +#ifndef PKG_DELEGATION +#include +#include +#include +#endif + +#define KV_LINE_SIZE 1024 + /* Global variables. */ static uint my_end_arg= 0; static uint opt_verbose=0; static my_bool opt_dry_run= 0; +static char *opt_file= 0; +static char *opt_sha256= 0; +static char *opt_base_url= 0; static uint opt_no_defaults= 0; static uint opt_print_defaults= 0; static char *opt_datadir=0, *opt_basedir=0, @@ -88,6 +101,15 @@ static struct my_option my_long_options[] = {"dry-run", 0, "Print the commands that install and uninstall would run, " "without running them.", &opt_dry_run, &opt_dry_run, 0, GET_BOOL, NO_ARG, 0, 0, 0, 0, 0, 0}, + {"file", 0, "Install the plugin from this local tarball instead of " + "downloading it.", + &opt_file, &opt_file, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, + {"base-url", 0, "Plugin repository URL. Overrides the default download " + "location for tarball installations.", + &opt_base_url, &opt_base_url, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, + {"sha256", 0, "Expected SHA-256 checksum of the tarball, as published " + "beside it. Refuse to install if it does not match.", + &opt_sha256, &opt_sha256, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, {"no-defaults", 'n', "Do not read values from configuration file.", 0, 0, 0, GET_NO_ARG, NO_ARG, 0, 0, 0, 0, 0, 0}, {"print-defaults", 'P', "Show default values from configuration file.", @@ -122,9 +144,14 @@ static int run_new_command(int argc, char **argv); static int validate_plugin_name(const char *name); static int is_legacy_syntax(int argc, char **argv); static int detect_install_method(char *basedir, size_t basedir_size); -static int do_search(const char *name, const char *basedir); +static int do_search(const char *term, const char *basedir); +#ifndef PKG_DELEGATION +static int search_tarball(const char *basedir); +#endif static int do_install(const char *name, const char *basedir); static int do_uninstall(const char *name, const char *basedir); +static my_bool get_one_option(const struct my_option *, const char *, + const char *); int main(int argc,char *argv[]) @@ -138,13 +165,12 @@ int main(int argc,char *argv[]) sf_leaking_memory=1; /* don't report memory leaks on early exits */ plugin_data.name= 0; /* initialize name */ - /* - The new package-manager style commands (search|install|uninstall) are - handled in run_new_command(). The legacy " ENABLE|DISABLE" - syntax is recognized by scanning the raw arguments, before any option - parsing, and continues through the original code path below unchanged, - for backward compatibility. - */ + /* Route only positional arguments, never values belonging to options. */ + if (handle_options(&argc, &argv, my_long_options, get_one_option)) + { + my_end(my_end_arg); + return 1; + } if (!is_legacy_syntax(argc, argv)) { error= run_new_command(argc, argv); @@ -152,22 +178,17 @@ int main(int argc,char *argv[]) exit(error); } - /* - The following operations comprise the method for enabling or disabling - a plugin. We begin by processing the command options then check the - directories specified for --datadir, --basedir, --plugin-dir, and - --plugin-ini (if specified). If the directories are Ok, we then look - for the mysqld executable and the plugin soname. Finally, we build a - bootstrap command file for use in bootstraping the server. - - If any step fails, the method issues an error message and the tool exits. - - 1) Parse, execute, and verify command options. - 2) Check access to directories. - 3) Look for mysqld executable. - 4) Look for the plugin. - 5) Build a bootstrap file with commands to enable or disable plugin. + if (opt_dry_run) + { + fprintf(stderr, "ERROR: --dry-run is not supported with ENABLE/DISABLE.\n"); + my_end(my_end_arg); + return 1; + } + /* + Parse and validate legacy options, check that configured paths exist, + locate mysqld and the plugin library, then write the bootstrap SQL. + Stop if any step fails. */ if ((error= process_options(argc, argv, operation)) || (error= check_access()) || @@ -595,7 +616,8 @@ get_one_option(const struct my_option *opt, @param[in] filename File to locate. - @retval int file not found = 1, file found = 0 + @retval 1 The path could be stat'ed. + @retval 0 The stat call failed. */ static int file_exists(char * filename) @@ -898,27 +920,20 @@ static int check_options(int argc, char **argv, char *operation) /** - Parse, execute, and verify command options. - - This method handles all of the option processing including the optional - features for displaying data (--print-defaults, --help ,etc.) that do not - result in an attempt to ENABLE or DISABLE of a plugin. + Read defaults unless disabled and validate the already-parsed legacy inputs. + Handle informational options without enabling or disabling a plugin. - @param[in] arc Count of arguments + @param[in] argc Count of arguments @param[in] argv Array of arguments @param[out] operation Operation (ENABLE or DISABLE) - @retval int error = 1, success = 0, exit program = -1 + @return 0 on success, an option error code, or -1 to stop processing. */ static int process_options(int argc, char *argv[], char *operation) { int error= 0; - /* Parse and execute command-line options */ - if ((error= handle_options(&argc, &argv, my_long_options, get_one_option))) - return error; - /* If the print defaults option used, exit. */ if (opt_print_defaults) return -1; @@ -1299,8 +1314,7 @@ static int bootstrap_server(char *server_path, char *bootstrap_file) /** Detect the legacy " ENABLE|DISABLE" command line syntax. - The check is done on the raw arguments, before any option parsing, so - that legacy invocations take the original code path unchanged. + Options have already been parsed; only positional arguments remain. @param[in] argc The number of arguments. @param[in] argv The arguments. @@ -1312,7 +1326,7 @@ static int is_legacy_syntax(int argc, char **argv) { int i; - for (i= 1; i < argc; i++) + for (i= 0; i < argc; i++) { /* Whichever keyword comes first decides, so that "search enable" is a @@ -1335,7 +1349,7 @@ static int is_legacy_syntax(int argc, char **argv) Check that a plugin name contains only safe characters. The name is later used to construct package names and file paths, so - only lower case alphanumerics, '_' and '-' are accepted. The name is + only lower case ASCII letters, digits, '_' and '-' are accepted. The name is expected to be normalized to lower case before this check. @param[in] name The normalized plugin name. @@ -1354,7 +1368,8 @@ static int validate_plugin_name(const char *name) } for (p= name; *p; p++) { - if (!isalnum((unsigned char) *p) && *p != '_' && *p != '-') + if (!(*p >= 'a' && *p <= 'z') && !(*p >= '0' && *p <= '9') && + *p != '_' && *p != '-') { fprintf(stderr, "ERROR: invalid character '%c' in plugin name. " "Use only [a-z0-9_-].\n", *p); @@ -1365,7 +1380,7 @@ static int validate_plugin_name(const char *name) } /** - Verify that the tool is part of the installation it was built for. + Check the argv[0]-derived location against the compiled install layout. The installation method is known at build time, so only the location has to be checked. It is taken from argv[0] and not from the server, as one @@ -1393,9 +1408,14 @@ static int detect_install_method(char *basedir, size_t basedir_size) old mysql_plugin name, does not hide where the tool is installed. */ my_path(self_path, my_progname, ""); - safe_strcat(self_path, sizeof(self_path), base_name(my_progname)); - if (my_realpath(real_path, self_path, MYF(0))) - safe_strcpy(real_path, sizeof(real_path), self_path); + if (!self_path[0] || + safe_strcat(self_path, sizeof(self_path), base_name(my_progname)) || + my_realpath(real_path, self_path, MYF(0))) + { + fprintf(stderr, "ERROR: cannot resolve the location of '%s'.\n", + my_progname); + return 1; + } dirname_part(real_dir, real_path, &length); @@ -1441,6 +1461,109 @@ static int detect_install_method(char *basedir, size_t basedir_size) } +/* + Search results use bare plugin names for native and tarball repositories. + Native package names are retained separately for installed-state queries. +*/ + +struct plugin_entry +{ + char name[NAME_CHAR_LEN + 1]; /* uniform name, prefix stripped */ + char package[NAME_CHAR_LEN + 1]; /* real package name, rpm only */ + char description[KV_LINE_SIZE]; + int installed; +}; + +static DYNAMIC_ARRAY plugin_list; + +#define PLUGIN_AT(i) (dynamic_element(&plugin_list, (i), struct plugin_entry *)) + + +static struct plugin_entry *find_plugin_entry(const char *name) +{ + size_t i; + + for (i= 0; i < plugin_list.elements; i++) + if (strcmp(PLUGIN_AT(i)->name, name) == 0) + return PLUGIN_AT(i); + return NULL; +} + + +/** + Add a plugin to the result list, or return the existing entry with the + same normalized name. + + The list reallocates, so the entry is only valid until the next one. + + @param[in] name The normalized plugin name, without the package prefix. + + @retval struct plugin_entry* the entry, NULL when out of memory +*/ + +static struct plugin_entry *add_plugin_entry(const char *name) +{ + struct plugin_entry e, *found; + + if ((found= find_plugin_entry(name))) + return found; + bzero(&e, sizeof(e)); + safe_strcpy(e.name, sizeof(e.name), name); + if (insert_dynamic(&plugin_list, &e)) + return NULL; + return PLUGIN_AT(plugin_list.elements - 1); +} + + +static int cmp_plugin_entries(const void *a, const void *b) +{ + return strcmp(((const struct plugin_entry *) a)->name, + ((const struct plugin_entry *) b)->name); +} + + +/** + Print the collected plugins that match the search term. + + @param[in] term Substring to match against plugin names, "" for all. + + @retval int no matches = 1, matches printed = 0 +*/ + +static int print_search_results(const char *term) +{ + size_t i, width= 0, matches= 0; + + sort_dynamic(&plugin_list, cmp_plugin_entries); + for (i= 0; i < plugin_list.elements; i++) + { + if (*term && !strstr(PLUGIN_AT(i)->name, term)) + continue; + matches++; + if (strlen(PLUGIN_AT(i)->name) > width) + width= strlen(PLUGIN_AT(i)->name); + } + if (!matches) + { + if (*term) + printf("No plugins matching '%s' found.\n", term); + else + printf("No plugins found.\n"); + return 1; + } + for (i= 0; i < plugin_list.elements; i++) + { + struct plugin_entry *e= PLUGIN_AT(i); + + if (*term && !strstr(e->name, term)) + continue; + printf("%-*s %-9s %s\n", (int) width, e->name, + e->installed ? "installed" : "available", e->description); + } + return 0; +} + + #ifdef PKG_DELEGATION /** @@ -1459,7 +1582,6 @@ static const char *get_package_manager(void) return "apt-get"; #else char dir[FN_REFLEN]; - if (find_file_in_path(dir, "dnf")) return "dnf"; if (find_file_in_path(dir, "zypper")) @@ -1472,10 +1594,7 @@ static const char *get_package_manager(void) /** - Refuse to continue without root privileges. - - The package manager would fail anyway, but only after a repository - refresh, with an error that does not mention this tool. + Require root for native package changes, but allow non-root dry-runs. @param[in] verb The command name, for the error message. @@ -1497,8 +1616,8 @@ static int check_root(const char *verb) /** Run a command and wait for it to finish. - The command is executed directly, not through a shell, so the arguments - cannot be reinterpreted. The child inherits the standard streams: the + Execute directly without shell expansion. The package manager still + interprets its own arguments. The child inherits the standard streams: the package manager talks to the user directly, including its own confirmation prompts and progress output. @@ -1601,6 +1720,9 @@ static int run_argv_capture(char **cmd_argv, DYNAMIC_STRING *out, if (pid == 0) { dup2(fds[1], STDOUT_FILENO); + /* Metadata parsers consume stable field labels, not translated output. */ + if (setenv("LC_ALL", "C", 1)) + _exit(127); if (quiet_stderr) { int devnull= open("/dev/null", O_WRONLY); @@ -1643,6 +1765,11 @@ static int run_argv_capture(char **cmd_argv, DYNAMIC_STRING *out, cmd_argv[0]); return 127; } + if (n < 0) + { + fprintf(stderr, "ERROR: cannot read the output of '%s'.\n", cmd_argv[0]); + return 127; + } if (WIFSIGNALED(status)) return 127; return WEXITSTATUS(status); @@ -1655,7 +1782,7 @@ static int run_argv_capture(char **cmd_argv, DYNAMIC_STRING *out, /** - Build the distribution-independent package name (D2): mariadb-plugin- + Build the distribution-independent package name: mariadb-plugin- followed by the plugin name, which is already validated and lowercased. @param[out] to Buffer for the package name. @@ -1670,113 +1797,8 @@ static void build_package_name(char *to, size_t size, const char *name) } -/* - Search: every backend answers the same two questions - which packages - provide mariadb-plugin-* and which of them are installed - through a - different query. The results are normalized into plugin_list and - printed in one format, so the user sees plugin names as install expects - them, never the distribution's own package names. -*/ - -struct plugin_entry -{ - char name[NAME_CHAR_LEN + 1]; /* uniform name, prefix stripped */ - char package[NAME_CHAR_LEN + 1]; /* real package name, rpm only */ - char description[160]; - int installed; -}; - -static DYNAMIC_ARRAY plugin_list; static DYNAMIC_STRING search_output; -#define PLUGIN_AT(i) (dynamic_element(&plugin_list, (i), struct plugin_entry *)) - - -static struct plugin_entry *find_plugin_entry(const char *name) -{ - size_t i; - - for (i= 0; i < plugin_list.elements; i++) - if (strcmp(PLUGIN_AT(i)->name, name) == 0) - return PLUGIN_AT(i); - return NULL; -} - - -/** - Add a plugin to the result list, or return the existing entry with the - same uniform name. The prefix is stripped from the stored name. - - The list reallocates, so the entry is only valid until the next one. - - @param[in] package_name The uniform package name, mariadb-plugin-x. - - @retval struct plugin_entry* the entry, NULL when out of memory -*/ - -static struct plugin_entry *add_plugin_entry(const char *package_name) -{ - struct plugin_entry e, *found; - const char *name= package_name + PLUGIN_PREFIX_LEN; - - if ((found= find_plugin_entry(name))) - return found; - bzero(&e, sizeof(e)); - safe_strcpy(e.name, sizeof(e.name), name); - if (insert_dynamic(&plugin_list, &e)) - return NULL; - return PLUGIN_AT(plugin_list.elements - 1); -} - - -static int cmp_plugin_entries(const void *a, const void *b) -{ - return strcmp(((const struct plugin_entry *) a)->name, - ((const struct plugin_entry *) b)->name); -} - - -/** - Print the collected plugins that match the search term. - - @param[in] term Substring to match against plugin names, "" for all. - - @retval int no matches = 1, matches printed = 0 -*/ - -static int print_search_results(const char *term) -{ - size_t i, width= 0, matches= 0; - - sort_dynamic(&plugin_list, cmp_plugin_entries); - for (i= 0; i < plugin_list.elements; i++) - { - if (*term && !strstr(PLUGIN_AT(i)->name, term)) - continue; - matches++; - if (strlen(PLUGIN_AT(i)->name) > width) - width= strlen(PLUGIN_AT(i)->name); - } - if (!matches) - { - if (*term) - printf("No plugins matching '%s' found.\n", term); - else - printf("No plugins found.\n"); - return 1; - } - for (i= 0; i < plugin_list.elements; i++) - { - struct plugin_entry *e= PLUGIN_AT(i); - - if (*term && !strstr(e->name, term)) - continue; - printf("%-*s %-9s %s\n", (int) width, e->name, - e->installed ? "installed" : "available", e->description); - } - return 0; -} - #ifndef INSTALL_LAYOUT_DEB @@ -1802,11 +1824,11 @@ static struct plugin_entry *find_plugin_by_package(const char *package) @param[in] installed Mark the found plugins as installed. */ -static void parse_dnf_records(char *output, int installed) +static int parse_dnf_records(char *output, int installed) { struct plugin_entry *e= NULL; char *line, *next, *sep; - char package[NAME_CHAR_LEN + 1], summary[160]; + char package[NAME_CHAR_LEN + 1], summary[KV_LINE_SIZE]; package[0]= summary[0]= '\0'; for (line= output; line && *line; line= next) @@ -1828,14 +1850,15 @@ static void parse_dnf_records(char *output, int installed) if (strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) != 0 || !package[0]) continue; - if (!(e= add_plugin_entry(line))) - return; + if (!(e= add_plugin_entry(line + PLUGIN_PREFIX_LEN))) + return 1; safe_strcpy(e->package, sizeof(e->package), package); if (!e->description[0]) safe_strcpy(e->description, sizeof(e->description), summary); if (installed) e->installed= 1; } + return 0; } @@ -1854,12 +1877,13 @@ static int search_dnf(void) if ((error= run_argv_capture(repo_argv, &search_output, 0))) return error; - parse_dnf_records(search_output.str, 0); + if (parse_dnf_records(search_output.str, 0)) + return 1; /* same query against the installed packages only, for the status */ - if (run_argv_capture(inst_argv, &search_output, 0) == 0) - parse_dnf_records(search_output.str, 1); - return 0; + if ((error= run_argv_capture(inst_argv, &search_output, 0))) + return error; + return parse_dnf_records(search_output.str, 1); } @@ -1872,7 +1896,7 @@ static int search_dnf(void) @param[in] output The captured zypper output, modified in place. */ -static void parse_zypper_solvables(char *output) +static int parse_zypper_solvables(char *output) { struct plugin_entry e; char *line, *next, *val, *end; @@ -1902,8 +1926,9 @@ static void parse_zypper_solvables(char *output) safe_strcpy(e.description, sizeof(e.description), val); } if (insert_dynamic(&plugin_list, &e)) - return; + return 1; } + return 0; } @@ -1987,7 +2012,8 @@ static int search_zypper(void) error= run_argv_capture(cmd_argv, &search_output, 0); if (error && error != 104) return error; - parse_zypper_solvables(search_output.str); + if (parse_zypper_solvables(search_output.str)) + return 1; if (plugin_list.elements && search_zypper_names()) return 1; return 0; @@ -2002,7 +2028,7 @@ static int search_zypper(void) @param[in] output The captured apt-cache output, modified in place. */ -static void parse_apt_records(char *output) +static int parse_apt_records(char *output) { struct plugin_entry *e; char *line, *next, *sep; @@ -2015,11 +2041,12 @@ static void parse_apt_records(char *output) *sep= '\0'; if (strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) != 0) continue; - if (!(e= add_plugin_entry(line))) - return; + if (!(e= add_plugin_entry(line + PLUGIN_PREFIX_LEN))) + return 1; if (sep && !e->description[0]) safe_strcpy(e->description, sizeof(e->description), sep + 3); } + return 0; } @@ -2037,19 +2064,21 @@ static int search_apt(void) cmd_argv[4]= 0; if ((error= run_argv_capture(cmd_argv, &search_output, 0))) return error; - parse_apt_records(search_output.str); + if (parse_apt_records(search_output.str)) + return 1; /* - dpkg-query prints "no packages found" on stderr and exits nonzero - when nothing is installed, which is an answer here, not an error. + dpkg-query exits 1 with no output when the pattern matches no packages. + Execution/capture failures and dpkg operational errors must propagate. */ cmd_argv[0]= (char *) "dpkg-query"; cmd_argv[1]= (char *) "-W"; cmd_argv[2]= (char *) "-f=${Package} ${db:Status-Status}\n"; cmd_argv[3]= (char *) PLUGIN_PREFIX "*"; cmd_argv[4]= 0; - if (run_argv_capture(cmd_argv, &search_output, 1)) - return 0; + error= run_argv_capture(cmd_argv, &search_output, 1); + if (error) + return error == 1 && !search_output.length ? 0 : error; for (line= search_output.str; line && *line; line= next) { if ((next= strchr(line, '\n'))) @@ -2058,9 +2087,12 @@ static int search_apt(void) continue; *sep++= '\0'; if (strcmp(sep, "installed") == 0 && - strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) == 0 && - (e= add_plugin_entry(line))) + strncmp(line, PLUGIN_PREFIX, PLUGIN_PREFIX_LEN) == 0) + { + if (!(e= add_plugin_entry(line + PLUGIN_PREFIX_LEN))) + return 1; e->installed= 1; + } } return 0; } @@ -2073,25 +2105,23 @@ static int search_apt(void) /** Search for plugins. - On rpm and deb installations the distribution's package index is the - plugin metadata: it is queried for everything providing mariadb-plugin-* - and the result is shown uniformly as plugin names, never as the - distribution's own package names. Needs no root. + RPM searches provided capabilities; DEB searches package names with the + mariadb-plugin- prefix. Tarball builds read the repository index and + local manifests. Needs no root. @param[in] term Substring to match, empty to list all plugins. @param[in] basedir The base directory, empty for packaged installations. - @retval int error or no matches = 1, matches printed = 0 */ static int do_search(const char *term, const char *basedir) { -#ifdef PKG_DELEGATION int error; if (my_init_dynamic_array(PSI_NOT_INSTRUMENTED, &plugin_list, sizeof(struct plugin_entry), 32, 32, MYF(MY_WME))) return 1; +#ifdef PKG_DELEGATION if (init_dynamic_string(&search_output, "", 16 * 1024, 16 * 1024)) { delete_dynamic(&plugin_list); @@ -2104,26 +2134,1593 @@ static int do_search(const char *term, const char *basedir) const char *pm= get_package_manager(); error= pm ? (strcmp(pm, "dnf") == 0 ? search_dnf() : search_zypper()) : 1; } +#endif + dynstr_free(&search_output); +#else + error= search_tarball(basedir); #endif if (!error) error= print_search_results(term); - dynstr_free(&search_output); + else + fprintf(stderr, "ERROR: could not determine plugin availability or " + "installed state.\n"); delete_dynamic(&plugin_list); return error ? 1 : 0; -#else - printf("search: not available for %s installations yet, the plugin " - "index does not exist\n", INSTALL_METHOD_NAME); - return 1; -#endif } -/** - Install a plugin. +#ifndef PKG_DELEGATION - On rpm and deb installations the work is delegated to the system package - manager, which resolves the uniform package name through its own real - package names (via Provides on rpm). Its exit code is passed through. +/* + On tarball installations nothing tracks what a plugin put on disk, so + install writes a manifest and uninstall acts strictly on it: the header + lines describe the plugin, each "file:" or "dir:" line is one path, + relative to the basedir, that install created and uninstall removes. +*/ + +#define MANIFEST_SUBDIR ".mariadb-plugin" +#define PLUGIN_BASE_URL "" +#define PLUGIN_INDEX "plugins.index" + +struct manifest_entry +{ + char path[FN_REFLEN]; + my_bool is_dir; +}; + +enum plugin_file_operation {PLUGIN_OPEN, PLUGIN_DELETE, PLUGIN_MKDIR, + PLUGIN_RMDIR, PLUGIN_CHECK_DIR}; + +static int valid_relative_path(const char *path); + + +static int build_full_path(char *to, size_t size, const char *basedir, + const char *rel) +{ + if (safe_strcpy_truncated(to, size, basedir) || + safe_strcat(to, size, "/") || safe_strcat(to, size, rel)) + { + fprintf(stderr, "ERROR: path is too long: '%s/%s'.\n", basedir, rel); + return 1; + } + return 0; +} + + +/* Resolve parents without following links. The basedir itself is trusted. */ +static int plugin_file_op(const char *basedir, const char *rel, + enum plugin_file_operation op, int flags) +{ + char full[FN_REFLEN]; + int result= -1, saved_errno; +#ifdef _WIN32 + HANDLE parents[FN_REFLEN / 2 + 1]; + size_t count= 0; + char *p; +#else + int parent= -1, next; + const char *part= rel, *slash; + char component[FN_REFLEN]; + size_t len; +#endif + + if (!valid_relative_path(rel) || + build_full_path(full, sizeof(full), basedir, rel)) + { + errno= my_errno= EINVAL; + return -1; + } +#ifdef _WIN32 + /* Deny writes and renames to parent directories during the operation. */ + for (p= full + strlen(basedir); ; p++) + { + char end= *p; + BY_HANDLE_FILE_INFORMATION info; + HANDLE handle; + if (end != '/' && end != '\0') + continue; + if (!end && op != PLUGIN_CHECK_DIR && + (op != PLUGIN_OPEN || (flags & O_CREAT))) + break; + *p= '\0'; + handle= CreateFile(full, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + *p= end; + if (handle == INVALID_HANDLE_VALUE) + { + my_osmaperr(GetLastError()); + goto end; + } + if (count == array_elements(parents)) + { + CloseHandle(handle); + errno= ENAMETOOLONG; + goto end; + } + parents[count++]= handle; + if (!GetFileInformationByHandle(handle, &info) || + (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) || + ((end || op == PLUGIN_CHECK_DIR) && + !(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY))) + { + errno= EACCES; + goto end; + } + if (!end) + break; + } + switch (op) { + case PLUGIN_OPEN: result= my_open(full, flags, MYF(0)); break; + case PLUGIN_DELETE: result= my_delete(full, MYF(0)); break; + case PLUGIN_MKDIR: result= my_mkdir(full, 0755, MYF(0)); break; + case PLUGIN_RMDIR: result= rmdir(full); break; + case PLUGIN_CHECK_DIR: result= 0; break; + } +#else + parent= open(basedir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (parent < 0) + goto end; + while ((slash= strchr(part, '/'))) + { + len= (size_t) (slash - part); + if (len) + { + memcpy(component, part, len); + component[len]= '\0'; + next= openat(parent, component, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0) + goto end; + close(parent); + parent= next; + } + part= slash + 1; + } + switch (op) { + case PLUGIN_OPEN: + result= openat(parent, part, flags | O_NOFOLLOW | O_CLOEXEC, my_umask); + if (result >= 0) + result= my_register_filename(result, full, FILE_BY_OPEN, 0, MYF(0)); + break; + case PLUGIN_DELETE: result= unlinkat(parent, part, 0); break; + case PLUGIN_MKDIR: result= mkdirat(parent, part, 0755); break; + case PLUGIN_RMDIR: result= unlinkat(parent, part, AT_REMOVEDIR); break; + case PLUGIN_CHECK_DIR: + next= openat(parent, part, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next >= 0) + { + close(next); + result= 0; + } + break; + } +#endif +end: + saved_errno= errno; +#ifdef _WIN32 + while (count) + CloseHandle(parents[--count]); +#else + if (parent >= 0) + close(parent); +#endif + if (result < 0) + errno= my_errno= saved_errno; + return result; +} + + +static int build_manifest_path(char *to, size_t size, const char *basedir, + const char *name) +{ + if (build_full_path(to, size, basedir, MANIFEST_SUBDIR "/") || + safe_strcat(to, size, name) || safe_strcat(to, size, ".list")) + return 1; + return 0; +} + + +/** + Check that a relative path stays inside the basedir. + + Used for manifest lines and archive entries alike, neither of which is + trusted input: an absolute path or a ".." component would let install + write, and uninstall delete, files the plugin never owned. + + @param[in] path The path, relative to the basedir. + + @retval int acceptable = 1, not = 0 +*/ + +static int valid_relative_path(const char *path) +{ + const uchar *p; + /* tar paths use '/', so a backslash only ever comes from hostile input */ + if (!*path || *path == '/' || strchr(path, '\\') || strchr(path, ':') || + strstr(path, "..")) + return 0; + /* a control character, above all a newline, would let a crafted entry + name inject extra lines into the manifest and make uninstall act on + files this plugin never installed */ + for (p= (const uchar *) path; *p; p++) + if (*p < 0x20 || *p == 0x7f) + return 0; + return 1; +} + + +/* 1 = line, 0 = EOF, -1 = error. Long manifest headers remain readable. */ +static int read_kv_line(FILE *file, const char *source, char *line, + my_bool manifest) +{ + size_t len= 0; + int c, oversized= 0, invalid= 0; + + while ((c= fgetc(file)) != EOF && c != '\n') + { + if (c == '\0') + invalid= 1; + if (len < KV_LINE_SIZE - 1) + line[len++]= (char) c; + else + oversized= 1; + } + line[len]= '\0'; + if (ferror(file) || invalid) + goto corrupt; + if (oversized) + { + if (!manifest || !strncmp(line, "dir: ", 5) || + !strncmp(line, "file: ", 6)) + goto corrupt; + /* Preserve the prefix so identity checks can reject an oversized name. */ + return 1; + } + if (c == EOF && !len) + return 0; + if (len && line[len - 1] == '\r') + line[--len]= '\0'; + return 1; + +corrupt: + fprintf(stderr, "ERROR: '%s' contains an invalid or oversized line, " + "or could not be read.\n", source); + return -1; +} + + +struct download_target +{ + FILE *file; + size_t remaining; +}; + + +static size_t download_write(char *data, size_t size, size_t count, void *arg) +{ + struct download_target *target= (struct download_target *) arg; + size_t bytes; + if (size && count > target->remaining / size) + return 0; + bytes= size * count; + target->remaining-= bytes; + return fwrite(data, 1, bytes, target->file); +} + + +static FILE *plugin_tmpfile(void) +{ + char name[FN_REFLEN]; + File fd= create_temp_file(name, NULL, "plugin", O_BINARY, + MYF(MY_WME | MY_TEMPORARY)); + FILE *file; + if (fd < 0) + return NULL; + if (!(file= my_fdopen(fd, name, O_RDWR | O_BINARY, MYF(MY_WME)))) + my_close(fd, MYF(0)); + return file; +} + + +/* Verify and extract a private snapshot, not a replaceable local pathname. */ +static FILE *copy_local_archive(const char *name) +{ + File fd; + MY_STAT info; + FILE *input, *output; + uchar buf[8192]; + size_t n; + int flags= O_RDONLY | O_BINARY, error= 0; +#ifndef _WIN32 + flags|= O_NONBLOCK; +#endif + if ((fd= my_open(name, flags, MYF(MY_WME))) < 0) + return NULL; + if (my_fstat(fd, &info, MYF(MY_WME)) || !MY_S_ISREG(info.st_mode)) + { + fprintf(stderr, "ERROR: '%s' is not a readable regular archive file.\n", + name); + my_close(fd, MYF(0)); + return NULL; + } + if (!(input= my_fdopen(fd, name, O_RDONLY | O_BINARY, MYF(MY_WME)))) + { + my_close(fd, MYF(0)); + return NULL; + } + output= plugin_tmpfile(); + if (output) + { + while ((n= fread(buf, 1, sizeof(buf), input)) > 0) + if (fwrite(buf, 1, n, output) != n) + { + error= 1; + break; + } + if (error || ferror(input) || fflush(output) || fseek(output, 0, SEEK_SET)) + { + fprintf(stderr, "ERROR: cannot copy archive '%s': %s.\n", name, + strerror(errno)); + my_fclose(output, MYF(0)); + output= NULL; + } + } + my_fclose(input, MYF(0)); + return output; +} + + +/* Keep downloads open and anonymous, including between verification passes. */ +static FILE *download_file(const char *url, size_t limit) +{ + char detail[CURL_ERROR_SIZE]= ""; + struct download_target target; + CURL *curl; + CURLcode rc; + long status= 0; + + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) + { + fprintf(stderr, "ERROR: cannot initialize libcurl.\n"); + return NULL; + } + if (!(curl= curl_easy_init())) + { + curl_global_cleanup(); + return NULL; + } + target.file= plugin_tmpfile(); + target.remaining= limit; + if (!target.file) + goto end; + +#define DOWNLOAD_OPTION(option, value) \ + if ((rc= curl_easy_setopt(curl, option, value)) != CURLE_OK) goto failed + + DOWNLOAD_OPTION(CURLOPT_URL, url); + DOWNLOAD_OPTION(CURLOPT_ERRORBUFFER, detail); + DOWNLOAD_OPTION(CURLOPT_WRITEFUNCTION, download_write); + DOWNLOAD_OPTION(CURLOPT_WRITEDATA, &target); + DOWNLOAD_OPTION(CURLOPT_FAILONERROR, 1L); + DOWNLOAD_OPTION(CURLOPT_FOLLOWLOCATION, 1L); + DOWNLOAD_OPTION(CURLOPT_MAXREDIRS, 5L); + DOWNLOAD_OPTION(CURLOPT_CONNECTTIMEOUT, 10L); + DOWNLOAD_OPTION(CURLOPT_TIMEOUT, 300L); + DOWNLOAD_OPTION(CURLOPT_LOW_SPEED_LIMIT, 1L); + DOWNLOAD_OPTION(CURLOPT_LOW_SPEED_TIME, 30L); + DOWNLOAD_OPTION(CURLOPT_NOSIGNAL, 1L); +#if LIBCURL_VERSION_NUM >= 0x075500 + DOWNLOAD_OPTION(CURLOPT_PROTOCOLS_STR, "http,https"); + DOWNLOAD_OPTION(CURLOPT_REDIR_PROTOCOLS_STR, + strncmp(url, "https://", 8) ? "http,https" : "https"); +#else + DOWNLOAD_OPTION(CURLOPT_PROTOCOLS, (long) (CURLPROTO_HTTP | CURLPROTO_HTTPS)); + DOWNLOAD_OPTION(CURLOPT_REDIR_PROTOCOLS, (long) (strncmp(url, "https://", 8) ? + CURLPROTO_HTTP | CURLPROTO_HTTPS : CURLPROTO_HTTPS)); +#endif +#undef DOWNLOAD_OPTION + + if ((rc= curl_easy_perform(curl)) != CURLE_OK || + (rc= curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status)) != CURLE_OK) + goto failed; + if (status != 200 || fflush(target.file) || + fseek(target.file, 0, SEEK_SET)) + { + fprintf(stderr, "ERROR: download of '%s' failed (HTTP %ld or file I/O).\n", + url, status); + goto discard; + } + goto end; + +failed: + fprintf(stderr, "ERROR: cannot download '%s': %s.\n", url, + detail[0] ? detail : curl_easy_strerror(rc)); +discard: + my_fclose(target.file, MYF(0)); + target.file= NULL; +end: + curl_easy_cleanup(curl); + curl_global_cleanup(); + return target.file; +} + + +static int build_download_url(char *url, size_t size, const char *file) +{ + const char *base= opt_base_url ? opt_base_url : PLUGIN_BASE_URL; + const char *host, *p; + size_t len= strlen(base); + + if (!len) + { + fprintf(stderr, "ERROR: no plugin repository configured; use " + "--base-url=.\n"); + return 1; + } + if (!strncmp(base, "https://", 8)) + host= base + 8; + else if (!strncmp(base, "http://", 7)) + host= base + 7; + else + goto invalid; + if (!*host || *host == '/' || strpbrk(host, "\\?#@")) + goto invalid; + for (p= base; *p; p++) + if ((uchar) *p <= 0x20 || *p == 0x7f) + goto invalid; + if (safe_strcpy_truncated(url, size, base) || + (base[len - 1] != '/' && safe_strcat(url, size, "/")) || + safe_strcat(url, size, file)) + { + fprintf(stderr, "ERROR: plugin repository URL is too long.\n"); + return 1; + } + return 0; + +invalid: + fprintf(stderr, "ERROR: --base-url must be an HTTP or HTTPS directory " + "URL without credentials, a query or a fragment.\n"); + return 1; +} + + +struct index_entry +{ + char name[NAME_CHAR_LEN + 1]; + char version[64]; + char server[32]; + char platform[64]; + char arch[64]; + char file[FN_REFLEN]; + char sha256[65]; + char author[KV_LINE_SIZE]; + char description[KV_LINE_SIZE]; + char license[KV_LINE_SIZE]; +}; + + +/* Blank-separated prototype records. Returns 1 = record, 0 = EOF, -1 = error. */ +static int read_index_entry(FILE *file, const char *source, + struct index_entry *e) +{ + char line[KV_LINE_SIZE]; + const char *keys[]= {"name", "version", "server", "platform", "arch", + "file", "sha256", "author", "description", "license"}; + char *values[]= {e->name, e->version, e->server, e->platform, e->arch, + e->file, e->sha256, e->author, e->description, e->license}; + size_t sizes[]= {sizeof(e->name), sizeof(e->version), sizeof(e->server), + sizeof(e->platform), sizeof(e->arch), sizeof(e->file), + sizeof(e->sha256), sizeof(e->author), + sizeof(e->description), sizeof(e->license)}; + uint seen= 0; + my_bool have_fields= FALSE; + int rc; + size_t i, len; + + bzero(e, sizeof(*e)); + for (;;) + { + rc= read_kv_line(file, source, line, FALSE); + if (rc < 0) + return -1; + if (rc && line[0]) + { + char *value= strchr(line, ':'); + if (!value || value == line || value[1] != ' ') + goto invalid; + *value++= '\0'; + while (*value == ' ') + value++; + len= strlen(value); + while (len && value[len - 1] == ' ') + value[--len]= '\0'; + for (i= 0; value[i]; i++) + if ((uchar) value[i] < 0x20 || value[i] == 0x7f) + goto invalid; + for (i= 0; i < array_elements(keys); i++) + if (!strcmp(line, keys[i])) + break; + if (i < array_elements(keys)) + { + if ((i < 7 && !len) || (seen & (1U << i)) || + safe_strcpy_truncated(values[i], sizes[i], value)) + goto invalid; + seen|= 1U << i; + } + have_fields= TRUE; + continue; + } + if (have_fields) + { + if ((seen & 127) != 127 || validate_plugin_name(e->name)) + goto invalid; + len= strlen(e->file); + if (len < 7 || strcmp(e->file + len - 7, ".tar.gz") || + !valid_relative_path(e->file)) + goto invalid; + for (i= 0; i < len; i++) + if (!isalnum((uchar) e->file[i]) && e->file[i] != '.' && + e->file[i] != '_' && e->file[i] != '-') + goto invalid; + if (strlen(e->sha256) != 64) + goto invalid; + for (i= 0; i < 64; i++) + if (!isxdigit((uchar) e->sha256[i])) + goto invalid; + return 1; + } + if (!rc) + return 0; + } + +invalid: + fprintf(stderr, "ERROR: invalid plugin record in '%s'.\n", source); + return -1; +} + + +static int index_entry_compatible(const struct index_entry *e) +{ + char server[32]; + my_snprintf(server, sizeof(server), "%u.%u", MYSQL_VERSION_ID / 10000, + MYSQL_VERSION_ID / 100 % 100); + return !strcmp(e->server, server) && !strcmp(e->platform, SYSTEM_TYPE) && + !strcmp(e->arch, MACHINE_TYPE); +} + + +static int read_index(FILE *file, const char *source, const char *name, + struct index_entry *result) +{ + struct index_entry e; + int rc, found= 0; + + while ((rc= read_index_entry(file, source, &e)) > 0) + { + if (strcmp(e.name, name) || !index_entry_compatible(&e)) + continue; + if (found) + { + fprintf(stderr, "ERROR: multiple compatible entries for '%s' " + "in '%s'.\n", name, source); + return 1; + } + *result= e; + found= 1; + } + if (rc < 0) + return 1; + if (!found) + fprintf(stderr, "ERROR: no compatible download for '%s' " + "(server %u.%u, %s, %s).\n", name, MYSQL_VERSION_ID / 10000, + MYSQL_VERSION_ID / 100 % 100, SYSTEM_TYPE, MACHINE_TYPE); + return !found; +} + + +/** + Read and validate all manifest entries before any deletion. + + @param[in] manifest Path of the manifest file. + @param[in] name Plugin the manifest must belong to, or NULL to skip + the ownership check (rollback of a manifest the tool + has just written itself). + @param[out] entries Initialized array, filled with manifest_entry. + + @retval int error = 1, success = 0 +*/ +static int read_manifest(const char *basedir, const char *manifest, + const char *name, DYNAMIC_ARRAY *entries) +{ + FILE *file; + File fd; + MY_STAT info; + char line[KV_LINE_SIZE]; + struct manifest_entry e; + const char *path; + int rc= 0, error= 0, flags= O_RDONLY | O_BINARY; + my_bool have_name= FALSE; + +#ifndef _WIN32 + /* A FIFO or device in place of the manifest must fail, not block. */ + flags|= O_NONBLOCK; +#endif + fd= plugin_file_op(basedir, manifest + strlen(basedir) + 1, + PLUGIN_OPEN, flags); + if (fd < 0) + { + fprintf(stderr, "ERROR: cannot read '%s': %s.\n", manifest, + strerror(errno)); + return 1; + } + if (my_fstat(fd, &info, MYF(MY_WME)) || !MY_S_ISREG(info.st_mode)) + { + fprintf(stderr, "ERROR: '%s' is not a readable regular manifest.\n", + manifest); + my_close(fd, MYF(0)); + return 1; + } + if (!(file= my_fdopen(fd, manifest, O_RDONLY | O_BINARY, MYF(MY_WME)))) + { + my_close(fd, MYF(0)); + return 1; + } + while (!error && (rc= read_kv_line(file, manifest, line, TRUE)) > 0) + { + if (!strncmp(line, "name: ", 6)) + { + /* uninstall may only remove what a manifest of this plugin owns */ + if (name && strcmp(line + 6, name)) + { + fprintf(stderr, "ERROR: manifest '%s' belongs to plugin '%s', not " + "'%s'; nothing was removed.\n", manifest, line + 6, name); + error= 1; + break; + } + if (have_name) + { + fprintf(stderr, "ERROR: invalid plugin manifest '%s'.\n", manifest); + error= 1; + break; + } + have_name= TRUE; + continue; + } + e.is_dir= strncmp(line, "dir: ", 5) == 0; + if (e.is_dir) + path= line + 5; + else if (strncmp(line, "file: ", 6) == 0) + path= line + 6; + else + continue; /* header lines; uninstall only consumes the paths */ + + if (!valid_relative_path(path) || strlen(path) >= sizeof(e.path)) + { + fprintf(stderr, "ERROR: unsafe path '%s' in '%s', nothing was " + "removed.\n", path, manifest); + error= 1; + break; + } + safe_strcpy(e.path, sizeof(e.path), path); + error= insert_dynamic(entries, &e); + } + my_fclose(file, MYF(0)); + if (!error && name && !have_name) + { + fprintf(stderr, "ERROR: invalid plugin manifest '%s'.\n", manifest); + error= 1; + } + return error || rc < 0; +} + + +/* Validate ownership metadata, without checking file health or server state. */ +static int tarball_plugin_installed(const char *basedir, const char *name, + int *installed) +{ + char manifest[FN_REFLEN], line[KV_LINE_SIZE]; + MY_STAT info; + FILE *file; + File fd; + int rc, error= 1, flags= O_RDONLY | O_BINARY; + my_bool have_name= FALSE; + + *installed= 0; + if (build_manifest_path(manifest, sizeof(manifest), basedir, name)) + return 1; +#ifndef _WIN32 + flags|= O_NONBLOCK; +#endif + fd= plugin_file_op(basedir, manifest + strlen(basedir) + 1, + PLUGIN_OPEN, flags); + if (fd < 0) + { + if (errno == ENOENT) + return 0; + fprintf(stderr, "ERROR: cannot read '%s': %s.\n", manifest, + strerror(errno)); + return 1; + } + if (my_fstat(fd, &info, MYF(MY_WME)) || !MY_S_ISREG(info.st_mode)) + { + fprintf(stderr, "ERROR: '%s' is not a readable regular manifest.\n", + manifest); + my_close(fd, MYF(0)); + return 1; + } + if (!(file= my_fdopen(fd, manifest, O_RDONLY | O_BINARY, MYF(MY_WME)))) + { + my_close(fd, MYF(0)); + return 1; + } + while ((rc= read_kv_line(file, manifest, line, TRUE)) > 0) + { + char *value; + + if (!line[0]) + continue; + value= strchr(line, ':'); + if (!value || value == line || value[1] != ' ') + goto invalid; + *value= '\0'; + value+= 2; + if (!strcmp(line, "name")) + { + if (have_name || strcmp(value, name)) + goto invalid; + have_name= TRUE; + } + else if (!strcmp(line, "file") || !strcmp(line, "dir")) + { + if (!valid_relative_path(value) || strlen(value) >= FN_REFLEN) + goto invalid; + } + } + if (rc < 0) + goto end; + if (!have_name) + goto invalid; + *installed= 1; + error= 0; + goto end; + +invalid: + fprintf(stderr, "ERROR: invalid plugin manifest '%s'.\n", manifest); +end: + if (my_fclose(file, MYF(MY_WME))) + error= 1; + return error; +} + + +static int search_tarball(const char *basedir) +{ + char url[KV_LINE_SIZE * 2]; + struct index_entry entry; + FILE *index; + int rc, error= 1; + + if (build_download_url(url, sizeof(url), PLUGIN_INDEX)) + return 1; + if (!(index= download_file(url, 8 * 1024 * 1024))) + return 1; + while ((rc= read_index_entry(index, url, &entry)) > 0) + { + struct plugin_entry *plugin; + + if (!index_entry_compatible(&entry)) + continue; + if (find_plugin_entry(entry.name)) + { + fprintf(stderr, "ERROR: multiple compatible entries for '%s' " + "in '%s'.\n", entry.name, url); + goto end; + } + if (!(plugin= add_plugin_entry(entry.name))) + goto end; + safe_strcpy(plugin->description, sizeof(plugin->description), + entry.description); + if (tarball_plugin_installed(basedir, entry.name, &plugin->installed)) + goto end; + } + error= rc < 0; +end: + if (my_fclose(index, MYF(MY_WME))) + error= 1; + return error; +} + + +/** + Delete everything a manifest lists, then the manifest. + + Remove files before directories. Keep the manifest if file removal fails + so the user can retry; nonempty directories are reported but retained. + + @param[in] basedir The base directory. + @param[in] manifest Path of the manifest file. + @param[in] name Plugin the manifest must belong to, or NULL when + rolling back a manifest the tool just wrote. + + @retval int error = 1, success = 0 +*/ + +static int manifest_remove(const char *basedir, const char *manifest, + const char *name) +{ + char full[FN_REFLEN]; + DYNAMIC_ARRAY entries; + struct manifest_entry *e; + size_t i; + int failed= 0; + + if (my_init_dynamic_array(PSI_NOT_INSTRUMENTED, &entries, + sizeof(struct manifest_entry), 16, 16, + MYF(MY_WME))) + return 1; + if (read_manifest(basedir, manifest, name, &entries)) + { + delete_dynamic(&entries); + return 1; + } + + for (i= 0; i < entries.elements; i++) + { + e= dynamic_element(&entries, i, struct manifest_entry *); + if (e->is_dir) + continue; + if (build_full_path(full, sizeof(full), basedir, e->path)) + { + /* an undeletable file must keep the manifest, or it is orphaned */ + failed= 1; + continue; + } + if (opt_dry_run) + printf("would delete %s\n", full); + else if (plugin_file_op(basedir, e->path, PLUGIN_DELETE, 0)) + { + /* a file someone already removed by hand must not block uninstall */ + if (my_errno == ENOENT) + fprintf(stderr, "WARNING: '%s' was already gone.\n", full); + else + { + fprintf(stderr, "ERROR: cannot delete '%s': %s.\n", full, + strerror(my_errno)); + failed= 1; + } + } + } + + /* directories in reverse manifest order, so children come before parents */ + for (i= entries.elements; i-- > 0; ) + { + e= dynamic_element(&entries, i, struct manifest_entry *); + if (!e->is_dir || build_full_path(full, sizeof(full), basedir, e->path)) + continue; + if (opt_dry_run) + printf("would remove directory %s\n", full); + else if (plugin_file_op(basedir, e->path, PLUGIN_RMDIR, 0) && + errno != ENOENT) + fprintf(stderr, "WARNING: directory '%s' was not removed: %s.\n", full, + strerror(errno)); + } + delete_dynamic(&entries); + + if (failed) + { + fprintf(stderr, "ERROR: not all files could be deleted; the manifest " + "was kept, so uninstall can be run again.\n"); + return 1; + } + if (opt_dry_run) + { + printf("would delete %s\n", manifest); + return 0; + } + if (plugin_file_op(basedir, manifest + strlen(basedir) + 1, + PLUGIN_DELETE, 0)) + return 1; + /* the manifest directory goes with the last plugin; busy is fine */ + plugin_file_op(basedir, MANIFEST_SUBDIR, PLUGIN_RMDIR, 0); + return 0; +} + + +/* + The tool reads plugin tarballs itself instead of running tar: every entry + is judged before anything is written, no tar binary is needed on the + machine, and no command line is ever built from a user-chosen path. + + Supported entries are ustar headers with regular files and + directories, plus the two ways a long path is spelled, GNU long-name + entries and pax "path" records. Links and devices are refused. +*/ + +#define TAR_BLOCK 512 + +struct tar_reader +{ + gzFile gz; + const char *file; + ulonglong data_left; /* unread bytes of the current entry, plus padding */ +}; + +struct tar_entry +{ + char path[FN_REFLEN]; + ulonglong size; + uint mode; + my_bool is_dir; +}; + + +static int tar_open(struct tar_reader *r, const char *file, FILE *contents) +{ + int fd; + r->file= file; + r->data_left= 0; + r->gz= NULL; + if (fseek(contents, 0, SEEK_SET)) + goto error; + /* gzdopen takes a CRT descriptor, not a Windows mysys descriptor. */ +#ifdef _WIN32 + fd= _dup(_fileno(contents)); +#else + fd= dup(fileno(contents)); +#endif + if (fd < 0) + goto error; + if (!(r->gz= gzdopen(fd, "rb"))) + { +#ifdef _WIN32 + _close(fd); +#else + close(fd); +#endif + goto error; + } + return 0; + +error: + fprintf(stderr, "ERROR: cannot open '%s': %s.\n", file, strerror(errno)); + return 1; +} + + +static void tar_close(struct tar_reader *r) +{ + gzclose(r->gz); +} + + +static int tar_read_bytes(struct tar_reader *r, void *buf, size_t len) +{ + int n= gzread(r->gz, buf, (unsigned) len); + if (n != (int) len) + { + int err; + const char *msg= gzerror(r->gz, &err); + fprintf(stderr, "ERROR: '%s' is truncated or not a gzip file%s%s.\n", + r->file, err == Z_ERRNO || err == Z_OK ? "" : ": ", + err == Z_ERRNO || err == Z_OK ? "" : msg); + return 1; + } + return 0; +} + + +static int tar_skip_data(struct tar_reader *r) +{ + if (r->data_left && gzseek(r->gz, (z_off_t) r->data_left, SEEK_CUR) < 0) + { + fprintf(stderr, "ERROR: '%s' is truncated.\n", r->file); + return 1; + } + r->data_left= 0; + return 0; +} + + +/** + Parse a tar numeric field: octal digits, terminated by NUL or space. + + @retval int error = 1, success = 0 +*/ + +static int tar_number(const uchar *field, size_t len, ulonglong *out) +{ + ulonglong v= 0; + size_t i; + + /* Only octal numeric fields are supported. */ + if (field[0] & 0x80) + return 1; + for (i= 0; i < len && field[i] == ' '; i++) ; + for (; i < len && field[i] != '\0' && field[i] != ' '; i++) + { + if (field[i] < '0' || field[i] > '7') + return 1; + v= v * 8 + (field[i] - '0'); + } + for (; i < len; i++) + if (field[i] != '\0' && field[i] != ' ') + return 1; + *out= v; + return 0; +} + + +static int tar_checksum_ok(const uchar *block) +{ + ulonglong stored; + unsigned sum= 0; + size_t i; + + if (tar_number(block + 148, 8, &stored)) + return 0; + for (i= 0; i < TAR_BLOCK; i++) + sum+= (i >= 148 && i < 156) ? ' ' : block[i]; + return sum == stored; +} + + +/** + Read the next file or directory entry. + + Long-name and pax entries are consumed here and applied to the entry that + follows them, so callers only ever see real files and directories. The + entry's data is left unread; call tar_skip_data() before the next entry. + + @param[in] r The open reader. + @param[out] e The entry. + + @retval int 1 = entry returned, 0 = end of archive, -1 = error +*/ + +static int tar_next(struct tar_reader *r, struct tar_entry *e) +{ + uchar block[TAR_BLOCK]; + char longname[FN_REFLEN]; + ulonglong size, mode; + size_t len; + char type; + + longname[0]= '\0'; + for (;;) + { + if (tar_skip_data(r) || tar_read_bytes(r, block, TAR_BLOCK)) + return -1; + if (block[0] == '\0') + { + static const uchar zero[TAR_BLOCK]= {0}; + int n, err; + if (longname[0] || memcmp(block, zero, TAR_BLOCK) || + tar_read_bytes(r, block, TAR_BLOCK) || memcmp(block, zero, TAR_BLOCK)) + goto bad_end; + /* Consume padding and the gzip trailer, including its checksum. */ + while ((n= gzread(r->gz, block, TAR_BLOCK)) > 0) + if (memcmp(block, zero, (size_t) n)) + goto bad_end; + gzerror(r->gz, &err); + if (n == 0 && err == Z_OK) + return 0; +bad_end: + fprintf(stderr, "ERROR: '%s' has an invalid archive ending.\n", r->file); + return -1; + } + if (memcmp(block + 257, "ustar", 5) != 0 || !tar_checksum_ok(block)) + { + fprintf(stderr, "ERROR: '%s' is not a valid tar archive.\n", r->file); + return -1; + } + if (tar_number(block + 124, 12, &size) || tar_number(block + 100, 8, &mode)) + { + fprintf(stderr, "ERROR: '%s' has a corrupt entry header.\n", r->file); + return -1; + } + r->data_left= (size + TAR_BLOCK - 1) / TAR_BLOCK * TAR_BLOCK; + type= block[156]; + + if (type == 'L' || type == 'x') + { + /* the data of these entries names the entry after them */ + char *buf, *p, *end; + int invalid= 0; + if (size >= sizeof(longname) * 4) + { + fprintf(stderr, "ERROR: '%s' has an entry name that is too long.\n", + r->file); + return -1; + } + if (!(buf= (char *) my_malloc(PSI_NOT_INSTRUMENTED, (size_t) size + 1, + MYF(MY_WME)))) + return -1; + if (tar_read_bytes(r, buf, (size_t) size)) + { + my_free(buf); + return -1; + } + buf[size]= '\0'; + r->data_left-= size; + if (type == 'L') + { + len= (size_t) size; + if (len && buf[len - 1] == '\0') + len--; + if (!len || len >= sizeof(longname) || memchr(buf, '\0', len)) + invalid= 1; + else + { + memcpy(longname, buf, len); + longname[len]= '\0'; + } + } + else + { + /* PAX lengths include the digits, separator and terminating newline. */ + for (p= buf; p < buf + size; p= end) + { + char *key= p; + len= 0; + while (*key >= '0' && *key <= '9' && len <= size) + len= len * 10 + (uint) (*key++ - '0'); + if (key == p || *key != ' ' || len > (size_t) (buf + size - p) || + len <= (size_t) (key - p) + 1) + { + invalid= 1; + break; + } + end= p + len; + key++; + if (end[-1] != '\n' || memchr(key, '\0', (size_t) (end - key)) || + !memchr(key, '=', (size_t) (end - key - 1))) + { + invalid= 1; + break; + } + if (end - key > 5 && !memcmp(key, "path=", 5)) + { + len= (size_t) (end - key - 6); + if (!len || len >= sizeof(longname)) + { + invalid= 1; + break; + } + memcpy(longname, key + 5, len); + longname[len]= '\0'; + } + } + } + my_free(buf); + if (invalid) + { + fprintf(stderr, "ERROR: '%s' has an invalid or oversized extended " + "header.\n", r->file); + return -1; + } + continue; + } + if (type == 'g') /* pax global header, carries nothing we use */ + continue; + break; + } + + switch (type) { + case '0': case '\0': case '7': + e->is_dir= FALSE; + break; + case '5': + e->is_dir= TRUE; + break; + case '1': case '2': + fprintf(stderr, "ERROR: '%s' contains a link, which plugin archives " + "must not have.\n", r->file); + return -1; + default: + fprintf(stderr, "ERROR: '%s' contains an entry of unsupported type " + "'%c'.\n", r->file, type); + return -1; + } + + if (longname[0]) + safe_strcpy(e->path, sizeof(e->path), longname); + else + { + /* ustar splits long paths into prefix (155) and name (100) */ + e->path[0]= '\0'; + if (block[345]) + { + safe_strcpy_truncated(e->path, MY_MIN(sizeof(e->path), 156), + (char *) block + 345); + safe_strcat(e->path, sizeof(e->path), "/"); + } + len= strlen(e->path); + safe_strcpy_truncated(e->path + len, MY_MIN(sizeof(e->path) - len, 101), + (char *) block + 0); + } + /* "./x" and "x/" spell the same thing; normalize before judging */ + while (strncmp(e->path, "./", 2) == 0) + memmove(e->path, e->path + 2, strlen(e->path) - 1); + len= strlen(e->path); + while (len > 1 && e->path[len - 1] == '/') + e->path[--len]= '\0'; + + e->size= size; + /* setuid and setgid bits from an archive are never honored */ + e->mode= (uint) mode & 0777; + return 1; +} + + + +/** + Compute the SHA-256 of a file as 64 lower case hex digits. + + @retval int error = 1, success = 0 +*/ + +static int file_sha256(const char *file, FILE *input, char *hex) +{ + uchar buf[8192], digest[32]; + void *ctx; + size_t n, i; + int error= 0; + + if (!(ctx= my_malloc(PSI_NOT_INSTRUMENTED, my_sha256_context_size(), + MYF(MY_WME)))) + return 1; + my_sha256_init(ctx); + while ((n= fread(buf, 1, sizeof(buf), input)) > 0) + my_sha256_input(ctx, buf, n); + if (ferror(input)) + { + fprintf(stderr, "ERROR: cannot read '%s': %s.\n", file, strerror(errno)); + error= 1; + } + my_sha256_result(ctx, digest); + my_free(ctx); + for (i= 0; i < sizeof(digest); i++) + sprintf(hex + 2 * i, "%02x", digest[i]); + return error; +} + + +/** + Compare the archive against the checksum the user was given for it. + + Case does not matter; whitespace and the "sha256:" prefix some sites + print are tolerated. A mismatch means the file is not the one that was + published, whatever the reason, so nothing is installed from it. + + @retval int error = 1, success = 0 +*/ + +static int verify_sha256(const char *file, FILE *contents, + const char *expected, char *hex) +{ + const char *p= expected; + size_t i; + + if (file_sha256(file, contents, hex)) + return 1; + while (*p == ' ' || *p == '\t') p++; + if (strncasecmp(p, "sha256:", 7) == 0) + p+= 7; + for (i= 0; i < 64 && p[i]; i++) + if (tolower((uchar) p[i]) != hex[i]) + break; + if (i != 64 || (p[64] && !isspace((uchar) p[64]))) + { + fprintf(stderr, "ERROR: '%s' does not match the expected checksum.\n" + " expected: %s\n actual: %s\n", file, expected, hex); + return 1; + } + return 0; +} + +/** + Read a whole archive, judging every entry, without writing anything. + + CPack wraps an archive's contents in one directory named after the + archive file. When every entry lives under such a directory it is + stripped from the paths and dropped from the list, so the remaining paths + are relative to the basedir. Any other layout is taken as is: "lib/x" and + "top/lib/x" cannot be told apart by shape, only by that name. + + @param[in] file Original archive name, used to identify its wrapper. + @param[in] contents Private archive snapshot. + @param[out] entries Initialized array, filled with tar_entry. + @param[out] topdir The stripped directory, "" when nothing was stripped. + + @retval int error = 1, success = 0 +*/ + +static int tar_scan(const char *file, FILE *contents, + DYNAMIC_ARRAY *entries, char *topdir) +{ + struct tar_reader r; + struct tar_entry e, *p; + const char *base; + my_bool have_topdir= TRUE; + size_t i, len; + int rc; + + if (tar_open(&r, file, contents)) + return 1; + topdir[0]= '\0'; + base= file + dirname_length(file); + while ((rc= tar_next(&r, &e)) > 0) + { + const char *slash; + if (!valid_relative_path(e.path)) + { + fprintf(stderr, "ERROR: '%s' contains the unsafe path '%s'.\n", file, + e.path); + rc= -1; + break; + } + /* a top directory exists only if no entry sits beside it at the root */ + slash= strchr(e.path, '/'); + len= slash ? (size_t) (slash - e.path) : strlen(e.path); + if (!slash && !e.is_dir) + have_topdir= FALSE; + if (!topdir[0]) + safe_strcpy_truncated(topdir, MY_MIN(FN_REFLEN, len + 1), e.path); + else if (strlen(topdir) != len || strncmp(topdir, e.path, len) != 0) + have_topdir= FALSE; + if (insert_dynamic(entries, &e)) + { + rc= -1; + break; + } + } + tar_close(&r); + if (rc < 0) + return 1; + if (!entries->elements) + { + fprintf(stderr, "ERROR: '%s' is empty.\n", file); + return 1; + } + len= strlen(topdir); + if (!have_topdir || strncmp(base, topdir, len) != 0 || + (base[len] != '\0' && base[len] != '.')) + { + topdir[0]= '\0'; + return 0; + } + + for (i= 0; i < entries->elements; ) + { + p= dynamic_element(entries, i, struct tar_entry *); + if (strlen(p->path) == len) /* the top directory itself */ + delete_dynamic_element(entries, i); + else + { + memmove(p->path, p->path + len + 1, strlen(p->path) - len); + i++; + } + } + if (!entries->elements) + { + fprintf(stderr, "ERROR: '%s' contains only an empty directory.\n", file); + return 1; + } + return 0; +} + + +/** + Append and flush one manifest record for rollback and later uninstall. + + @retval int error = 1, success = 0 +*/ + +static int manifest_append(FILE *m, const char *key, const char *value) +{ + /* a newline in a value, such as the --file path written as "source", + would be read back as a second manifest line: refuse it */ + if (strpbrk(value, "\r\n")) + { + fprintf(stderr, "ERROR: refusing to write a '%s' value that contains a " + "newline into the manifest.\n", key); + return 1; + } + if (fprintf(m, "%s: %s\n", key, value) < 0 || fflush(m)) + { + fprintf(stderr, "ERROR: cannot write the manifest: %s.\n", + strerror(errno)); + return 1; + } + return 0; +} + + +/** + Copy one entry's data from the archive into a new file. + + Create exclusively relative to a checked parent directory. + + @retval int error = 1, success = 0 +*/ + +static int tar_extract_file(struct tar_reader *r, struct tar_entry *e, + const char *basedir, const char *full, FILE *m, + const char *relpath) +{ + char buf[8192]; + ulonglong left= e->size; + File fd; + int error= 0; + + fd= plugin_file_op(basedir, relpath, PLUGIN_OPEN, + O_WRONLY | O_CREAT | O_EXCL | O_BINARY); + if (fd < 0) + { + fprintf(stderr, "ERROR: cannot create '%s': %s.\n", full, + strerror(my_errno)); + return 1; + } + /* Record ownership before writing contents so a failed write is tracked. */ + if (manifest_append(m, "file", relpath)) + { + my_close(fd, MYF(0)); + plugin_file_op(basedir, relpath, PLUGIN_DELETE, 0); + return 1; + } + while (left && !error) + { + size_t n= (size_t) MY_MIN(left, sizeof(buf)); + if (tar_read_bytes(r, buf, n) || + my_write(fd, (uchar *) buf, n, MYF(MY_WME | MY_NABP))) + error= 1; + left-= n; + r->data_left-= n; + } +#ifndef _WIN32 + /* Change the opened file, not a pathname that could have been replaced. */ + if (!error && e->mode && fchmod(fd, e->mode)) + { + fprintf(stderr, "ERROR: cannot set permissions on '%s': %s.\n", + full, strerror(errno)); + error= 1; + } +#endif + if (my_close(fd, MYF(MY_WME))) + error= 1; + return error; +} + + +/** + Create the scanned entries and record ownership. Existing directories + are checked and reused without recording ownership of them. + + The archive must list newly created parent directories before children. + + @retval int error = 1, success = 0 +*/ + +static int tar_extract(const char *file, FILE *contents, const char *basedir, + const char *topdir, DYNAMIC_ARRAY *entries, FILE *m) +{ + struct tar_reader r; + struct tar_entry e; + char full[FN_REFLEN]; + size_t skip= topdir[0] ? strlen(topdir) + 1 : 0; + size_t i; + int rc; + + if (tar_open(&r, file, contents)) + return 1; + /* the archive is walked again, in step with the approved entry list */ + for (i= 0; i < entries->elements; i++) + { + struct tar_entry *ok= dynamic_element(entries, i, struct tar_entry *); + do + { + if ((rc= tar_next(&r, &e)) <= 0) + { + if (rc == 0) + fprintf(stderr, "ERROR: '%s' changed while it was being read.\n", + file); + tar_close(&r); + return 1; + } + } while (strlen(e.path) < skip || strcmp(e.path + skip, ok->path) != 0); + + if (build_full_path(full, sizeof(full), basedir, ok->path)) + goto err; + if (e.is_dir) + { + if (file_exists(full)) + { + if (plugin_file_op(basedir, ok->path, PLUGIN_CHECK_DIR, 0)) + { + fprintf(stderr, "ERROR: '%s' is not an accessible directory " + "without symlinks.\n", full); + goto err; + } + continue; + } + if (plugin_file_op(basedir, ok->path, PLUGIN_MKDIR, 0)) + { + fprintf(stderr, "ERROR: cannot create directory '%s': %s.\n", full, + strerror(my_errno)); + goto err; + } + if (manifest_append(m, "dir", ok->path)) + goto err; + } + else + { + if (tar_extract_file(&r, &e, basedir, full, m, ok->path)) + goto err; + } + } + tar_close(&r); + return 0; + +err: + tar_close(&r); + return 1; +} + + +/** + Tell the user how to enable what was just installed. Install never + edits the server configuration: a tarball installation has no conf.d + and no convention for one, and the user may keep my.cnf anywhere. +*/ + +static void print_enable_instructions(const char *basedir, + DYNAMIC_ARRAY *entries) +{ + size_t i, prefix= sizeof(STR(INSTALL_PLUGINDIR)) - 1; + int pass, shown= 0; + + /* two passes: the INSTALL SONAME lines, then the plugin-load-add lines */ + for (pass= 0; pass < 2; pass++) + { + for (i= 0; i < entries->elements; i++) + { + struct tar_entry *e= dynamic_element(entries, i, struct tar_entry *); + const char *name= e->path + prefix + 1, *ext; + if (e->is_dir || + strncmp(e->path, STR(INSTALL_PLUGINDIR), prefix) != 0 || + e->path[prefix] != '/' || strchr(name, '/') || + !(ext= strstr(name, SO_EXT))) + continue; + if (pass == 0) + { + if (!shown++) + printf("To enable it, either run in the server:\n"); + printf(" INSTALL SONAME '%.*s';\n", (int) (ext - name), name); + } + else + printf(" plugin-load-add=%s\n", name); + } + if (pass == 0 && shown) + printf("or add to your server configuration and restart:\n" + " [mariadb]\n"); + } + if (!shown) + printf("No plugin library was found under %s/%s; nothing to enable.\n", + basedir, STR(INSTALL_PLUGINDIR)); +} + +#endif /* !PKG_DELEGATION */ + + +/** + Install a plugin. + + On rpm and deb installations the work is delegated to the system package + manager, which resolves the uniform package name through its own real + package names (via Provides on rpm). Its exit code is passed through. @param[in] name The normalized plugin name. @param[in] basedir The base directory, empty for packaged installations. @@ -2138,6 +3735,14 @@ static int do_install(const char *name, const char *basedir) const char *pm; char *cmd_argv[4]; + /* Do not silently replace a requested archive with a repository package. */ + if (opt_file || opt_sha256 || opt_base_url) + { + fprintf(stderr, "ERROR: --file, --sha256 and --base-url are only for tarball " + "installations; on this system plugins are installed by the " + "package manager.\n"); + return 1; + } if (check_root("install")) return 1; if (!(pm= get_package_manager())) @@ -2150,9 +3755,181 @@ static int do_install(const char *name, const char *basedir) cmd_argv[3]= 0; return run_argv(cmd_argv); #else - printf("install: '%s' (%s installation%s%s) not implemented yet\n", name, - INSTALL_METHOD_NAME, *basedir ? ", basedir=" : "", basedir); - return 0; + char manifest[FN_REFLEN], full[FN_REFLEN], topdir[FN_REFLEN]; + char sha256[65]; + DYNAMIC_ARRAY entries; + struct tar_entry *e; + struct index_entry remote; + FILE *m= 0, *contents= NULL; + char url[KV_LINE_SIZE * 2]; + const char *archive= opt_file, *source= opt_file, *expected= opt_sha256; + size_t i; + int error= 1; + + if ((opt_sha256 && !opt_file) || (opt_base_url && opt_file)) + { + fprintf(stderr, "ERROR: --sha256 requires --file; --base-url cannot " + "be combined with --file.\n"); + return 1; + } + if (build_manifest_path(manifest, sizeof(manifest), basedir, name)) + return 1; + if (file_exists(manifest)) + { + fprintf(stderr, "ERROR: plugin '%s' is already installed.\n", name); + return 1; + } + if (!archive) + { + FILE *index; + int invalid; + if (build_download_url(url, sizeof(url), PLUGIN_INDEX)) + return 1; + if (!(index= download_file(url, 8 * 1024 * 1024))) + return 1; + invalid= read_index(index, url, name, &remote); + my_fclose(index, MYF(0)); + if (invalid || build_download_url(url, sizeof(url), remote.file)) + return 1; + archive= remote.file; + expected= remote.sha256; + source= url; + if (opt_dry_run) + { + printf("would download %s\nwould verify SHA-256 %s\n" + "would install plugin '%s' into %s\n", + source, expected, name, basedir); + return 0; + } + printf("Downloading %s\n", source); + if (!(contents= download_file(source, 1024 * 1024 * 1024))) + return 1; + } + if (!contents && !(contents= copy_local_archive(archive))) + return 1; + if (expected) + { + if (verify_sha256(archive, contents, expected, sha256)) + goto close_archive; + } + else + { + safe_strcpy(sha256, sizeof(sha256), "unverified"); + fprintf(stderr, "WARNING: no --sha256 given, the tarball is not " + "verified.\n"); + } + if (my_init_dynamic_array(PSI_NOT_INSTRUMENTED, &entries, + sizeof(struct tar_entry), 64, 64, MYF(MY_WME))) + goto close_archive; + if (tar_scan(archive, contents, &entries, topdir)) + goto end; + + /* + Everything is judged before anything is written: a file that already + exists is refused, as it belongs to the server or to another plugin. + */ + for (i= 0; i < entries.elements; i++) + { + e= dynamic_element(&entries, i, struct tar_entry *); + if (!strncmp(e->path, MANIFEST_SUBDIR, sizeof(MANIFEST_SUBDIR) - 1) && + (e->path[sizeof(MANIFEST_SUBDIR) - 1] == '/' || + e->path[sizeof(MANIFEST_SUBDIR) - 1] == '\0')) + { + fprintf(stderr, "ERROR: archive entry '%s' uses the reserved manifest " + "directory.\n", e->path); + goto end; + } + if (build_full_path(full, sizeof(full), basedir, e->path)) + goto end; + if (!e->is_dir && file_exists(full)) + { + fprintf(stderr, "ERROR: '%s' already exists, refusing to overwrite " + "it.\n", full); + goto end; + } + } + + if (opt_dry_run) + { + for (i= 0; i < entries.elements; i++) + { + e= dynamic_element(&entries, i, struct tar_entry *); + build_full_path(full, sizeof(full), basedir, e->path); + if (e->is_dir && file_exists(full)) + continue; + printf("would %s %s\n", e->is_dir ? "create directory" : "install", + full); + } + printf("would write %s\n", manifest); + error= 0; + goto end; + } + + /* + Write ownership records during extraction so ordinary failures can use + the same removal logic as uninstall. This is not a crash-atomic journal. + */ + if (build_full_path(full, sizeof(full), basedir, MANIFEST_SUBDIR)) + goto end; + if (!file_exists(full) && + plugin_file_op(basedir, MANIFEST_SUBDIR, PLUGIN_MKDIR, 0)) + goto end; + /* my_fopen() would map these flags to fopen("w"), dropping O_EXCL and + following a dangling symlink; my_open() honours O_EXCL, so the manifest + is created only if the name does not already exist */ + { + File mfd= plugin_file_op(basedir, manifest + strlen(basedir) + 1, + PLUGIN_OPEN, + O_WRONLY | O_CREAT | O_EXCL | O_BINARY); + if (mfd < 0) + { + fprintf(stderr, "ERROR: cannot create '%s': %s.\n", manifest, + strerror(my_errno)); + goto end; + } + if (!(m= my_fdopen(mfd, manifest, O_WRONLY, MYF(MY_WME)))) + { + my_close(mfd, MYF(0)); + plugin_file_op(basedir, manifest + strlen(basedir) + 1, PLUGIN_DELETE, 0); + goto end; + } + } + { + char today[16]; + struct tm *t; + time_t now= time(0); + t= localtime(&now); + strftime(today, sizeof(today), "%Y-%m-%d", t); + if (manifest_append(m, "name", name) || + manifest_append(m, "source", source) || + manifest_append(m, "sha256", sha256) || + manifest_append(m, "date", today) || + (topdir[0] && manifest_append(m, "topdir", topdir))) + goto rollback; + } + if (tar_extract(archive, contents, basedir, topdir, &entries, m)) + goto rollback; + my_fclose(m, MYF(0)); + m= 0; + + printf("Plugin '%s' installed into %s.\n", name, basedir); + print_enable_instructions(basedir, &entries); + error= 0; + goto end; + +rollback: + if (m) + my_fclose(m, MYF(0)); + fprintf(stderr, "ERROR: installation of '%s' failed, removing what was " + "written.\n", name); + /* the manifest was just written by this tool and may be incomplete */ + manifest_remove(basedir, manifest, 0); +end: + delete_dynamic(&entries); +close_archive: + if (contents) + my_fclose(contents, MYF(0)); + return error; #endif } @@ -2234,8 +4011,19 @@ static int do_uninstall(const char *name, const char *basedir) #endif return error; #else - printf("uninstall: '%s' (%s installation%s%s) not implemented yet\n", name, - INSTALL_METHOD_NAME, *basedir ? ", basedir=" : "", basedir); + char manifest[FN_REFLEN]; + + if (build_manifest_path(manifest, sizeof(manifest), basedir, name)) + return 1; + if (!file_exists(manifest)) + { + fprintf(stderr, "ERROR: plugin '%s' is not installed.\n", name); + return 1; + } + if (manifest_remove(basedir, manifest, name)) + return 1; + if (!opt_dry_run) + printf("Plugin '%s' uninstalled from %s.\n", name, basedir); return 0; #endif } @@ -2244,8 +4032,7 @@ static int do_uninstall(const char *name, const char *basedir) /** Run the new package-manager style commands. - Parses the options (--help, --version, etc. are handled by - handle_options), then validates the verb and the plugin name and + Options have already been parsed by main. Validate the verb and name and dispatches to the appropriate command handler. The plugin name is normalized to lower case before validation. @@ -2261,10 +4048,12 @@ static int run_new_command(int argc, char **argv) char basedir[FN_REFLEN]; const char *verb; size_t i, len; - int error, is_search; + int is_search; - if ((error= handle_options(&argc, &argv, my_long_options, get_one_option))) - return 1; + /* --print-defaults only displays information; it must not fall through + into an install or uninstall that changes the system */ + if (opt_print_defaults) + return 0; if (argc < 1) { @@ -2309,6 +4098,31 @@ static int run_new_command(int argc, char **argv) return 1; } +#ifdef INSTALL_LAYOUT_DEB + /* APT interprets a trailing '-' on an install/remove operand as removal. */ + if (!is_search && name[strlen(name) - 1] == '-') + { + fprintf(stderr, "ERROR: plugin names ending in '-' cannot be passed " + "to apt-get.\n"); + return 1; + } +#endif + + if (is_search && (opt_file || opt_sha256)) + { + fprintf(stderr, "ERROR: --file and --sha256 are only supported with " + "install.\n"); + return 1; + } +#ifdef PKG_DELEGATION + if (is_search && opt_base_url) + { + fprintf(stderr, "ERROR: --base-url is only for tarball installations; " + "search uses the native package repositories on this system.\n"); + return 1; + } +#endif + if (detect_install_method(basedir, sizeof(basedir))) return 1; From 55c2ec882837b269f6e334646c7325f7252aa5f1 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 19 Sep 2026 18:18:41 +0300 Subject: [PATCH 3/4] MDEV-40786 Align metadata and document commands Document package and tarball operations, activation instructions, repository requirements and manifest-based uninstall. Cover search option rejection, trailing-hyphen search terms, option-value routing and deprecated dry-run rejection in MTR. Read restricted package YAML documents so repository metadata can follow the publisher's format. Match combined package platforms and numeric server series, preserving existing local uninstall manifests. Document the supported syntax and reject malformed or ambiguous records. Temporarily reject remote operations on Windows without requiring curl, and normalize the specific MSAN curl loader warning in CLI tests. --- client/CMakeLists.txt | 23 +- client/mysql_plugin.c | 385 ++++++++++++++---- man/mariadb-plugin.1 | 456 +++++++++++++++------- mysql-test/main/mariadb-plugin-win.result | 11 + mysql-test/main/mariadb-plugin-win.test | 25 ++ mysql-test/main/mariadb-plugin.result | 24 ++ mysql-test/main/mariadb-plugin.test | 79 +++- 7 files changed, 774 insertions(+), 229 deletions(-) create mode 100644 mysql-test/main/mariadb-plugin-win.result create mode 100644 mysql-test/main/mariadb-plugin-win.test diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 93916d5f870b3..b41c17de4f159 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -76,15 +76,30 @@ TARGET_LINK_LIBRARIES(mariadb-show ${CLIENT_LIB}) MYSQL_ADD_EXECUTABLE(mariadb-plugin mysql_plugin.c) TARGET_LINK_LIBRARIES(mariadb-plugin ${CLIENT_LIB} mysys_ssl) -IF(NOT INSTALL_LAYOUT MATCHES "^(RPM|DEB)$") - FIND_PACKAGE(CURL REQUIRED) - TARGET_LINK_LIBRARIES(mariadb-plugin CURL::libcurl) -ENDIF() # The tool needs to know how MariaDB was installed to pick the way plugins # are installed. The install layout is only known when building, so it is # passed to the tool, together with the directories it has to verify. SET_TARGET_PROPERTIES(mariadb-plugin PROPERTIES COMPILE_DEFINITIONS "INSTALL_LAYOUT_${INSTALL_LAYOUT};INSTALL_PLUGINDIR=${INSTALL_PLUGINDIR};INSTALL_BINDIRABS=${INSTALL_BINDIRABS}") +# A caller can supply CPACK_PACKAGE_FILE_NAME, skipping platform discovery +# in mysql_version.cmake. Use the same naming rules as package generation. +FUNCTION(MARIADB_PLUGIN_PACKAGE_PLATFORM) + IF(NOT SYSTEM_NAME_AND_PROCESSOR) + GET_PACKAGE_FILE_NAME(_plugin_package_name) + ENDIF() + TARGET_COMPILE_DEFINITIONS(mariadb-plugin PRIVATE + PLUGIN_PACKAGE_PLATFORM="${SYSTEM_NAME_AND_PROCESSOR}") +ENDFUNCTION() +MARIADB_PLUGIN_PACKAGE_PLATFORM() +# Tarball installations download plugin archives over HTTP, so those builds +# link libcurl and define HAVE_LIBCURL for the repository code. RPM and DEB +# installations delegate to the package manager, and Windows builds have no +# downloader: mysql_plugin.c refuses the repository operations instead. +IF(NOT INSTALL_LAYOUT MATCHES "^(RPM|DEB)$" AND NOT WIN32) + FIND_PACKAGE(CURL REQUIRED) + TARGET_LINK_LIBRARIES(mariadb-plugin CURL::libcurl) + TARGET_COMPILE_DEFINITIONS(mariadb-plugin PRIVATE HAVE_LIBCURL=1) +ENDIF() MYSQL_ADD_EXECUTABLE(mariadb-binlog mysqlbinlog.cc mysqlbinlog-engine.cc) TARGET_LINK_LIBRARIES(mariadb-binlog ${CLIENT_LIB} mysys_ssl) diff --git a/client/mysql_plugin.c b/client/mysql_plugin.c index ee5d2a34819b5..bcde72d2aac2c 100644 --- a/client/mysql_plugin.c +++ b/client/mysql_plugin.c @@ -54,7 +54,13 @@ #ifndef PKG_DELEGATION #include +/* + Repository access needs libcurl. Builds without it, such as Windows, keep + the archive handling but refuse the operations that would use the network. +*/ +#ifdef HAVE_LIBCURL #include +#endif #include #endif @@ -2352,12 +2358,14 @@ static int valid_relative_path(const char *path) } -/* 1 = line, 0 = EOF, -1 = error. Long manifest headers remain readable. */ -static int read_kv_line(FILE *file, const char *source, char *line, - my_bool manifest) +/* 1 = line, 0 = EOF, -1 = error; always drain the complete physical line. */ +static int read_metadata_line(FILE *file, const char *source, char *line, + my_bool *oversized) { size_t len= 0; - int c, oversized= 0, invalid= 0; + int c, invalid= 0; + + *oversized= FALSE; while ((c= fgetc(file)) != EOF && c != '\n') { @@ -2366,19 +2374,11 @@ static int read_kv_line(FILE *file, const char *source, char *line, if (len < KV_LINE_SIZE - 1) line[len++]= (char) c; else - oversized= 1; + *oversized= TRUE; } line[len]= '\0'; if (ferror(file) || invalid) goto corrupt; - if (oversized) - { - if (!manifest || !strncmp(line, "dir: ", 5) || - !strncmp(line, "file: ", 6)) - goto corrupt; - /* Preserve the prefix so identity checks can reject an oversized name. */ - return 1; - } if (c == EOF && !len) return 0; if (len && line[len - 1] == '\r') @@ -2392,6 +2392,23 @@ static int read_kv_line(FILE *file, const char *source, char *line, } +/* Never truncate deletion paths. Informational legacy headers can be long. */ +static int read_manifest_line(FILE *file, const char *source, char *line) +{ + my_bool oversized; + int rc= read_metadata_line(file, source, line, &oversized); + if (rc > 0 && oversized && + (!strncmp(line, "dir: ", 5) || !strncmp(line, "file: ", 6))) + { + fprintf(stderr, "ERROR: '%s' contains an oversized manifest path.\n", + source); + return -1; + } + return rc; +} + + +#ifdef HAVE_LIBCURL struct download_target { FILE *file; @@ -2409,6 +2426,7 @@ static size_t download_write(char *data, size_t size, size_t count, void *arg) target->remaining-= bytes; return fwrite(data, 1, bytes, target->file); } +#endif static FILE *plugin_tmpfile(void) @@ -2474,6 +2492,7 @@ static FILE *copy_local_archive(const char *name) /* Keep downloads open and anonymous, including between verification passes. */ +#ifdef HAVE_LIBCURL static FILE *download_file(const char *url, size_t limit) { char detail[CURL_ERROR_SIZE]= ""; @@ -2546,6 +2565,21 @@ static FILE *download_file(const char *url, size_t limit) curl_global_cleanup(); return target.file; } +#else +/* + Windows builds are not linked with libcurl, so they have no downloader. The + commands that would use it are refused first; this stub keeps the tarball + code compilable and cannot proceed silently. +*/ +static FILE *download_file(const char *url, size_t limit) +{ + (void) url; + (void) limit; + fprintf(stderr, "ERROR: Remote plugin download is not supported on " + "Windows yet.\n"); + return NULL; +} +#endif static int build_download_url(char *url, size_t size, const char *file) @@ -2593,7 +2627,6 @@ struct index_entry char version[64]; char server[32]; char platform[64]; - char arch[64]; char file[FN_REFLEN]; char sha256[65]; char author[KV_LINE_SIZE]; @@ -2602,93 +2635,273 @@ struct index_entry }; -/* Blank-separated prototype records. Returns 1 = record, 0 = EOF, -1 = error. */ -static int read_index_entry(FILE *file, const char *source, - struct index_entry *e) +/* + Repository YAML uses single-line scalars only. Decode in place; rejecting + unsupported YAML features is preferable to treating syntax as field data. +*/ +static int index_scalar(char *value) +{ + char *p= value, *out= value, quote= 0; + size_t len; + + if (*p == '\'' || *p == '"') + quote= *p++; + if (!quote && (*p == '-' || *p == '?' || *p == ':')) + { + if (!p[1] || p[1] == ' ') + return 1; + } + else if (!quote && *p && strchr(",[]{}#&*!|>@`%", *p)) + return 1; + + while (*p) + { + if (quote && *p == quote) + { + p++; + if (quote == '\'' && *p == '\'') + { + *out++= *p++; + continue; + } + if (*p && *p != ' ') + return 1; + while (*p == ' ') + p++; + if (*p && *p != '#') + return 1; + *out= '\0'; + return 0; + } + if (quote == '"' && *p == '\\') + { + p++; + if (*p != '\\' && *p != '"') + return 1; + } + else if (!quote) + { + if (*p == '#' && (p == value || p[-1] == ' ')) + break; + if (*p == ':' && (!p[1] || p[1] == ' ')) + return 1; + } + *out++= *p++; + } + if (quote) + return 1; + len= (size_t) (out - value); + while (len && value[len - 1] == ' ') + len--; + value[len]= '\0'; + /* These plain scalars resolve to null/boolean in YAML, not text. */ + return !len || !strcmp(value, "~") || + !strcasecmp(value, "null") || !strcasecmp(value, "true") || + !strcasecmp(value, "false"); +} + + +/* Numeric major.minor[.patch], without suffixes or partial numeric matches. */ +static int index_server_series(const char *value, uint *major, uint *minor) +{ + uint parts[3]= {0, 0, 0}; + uint part= 0; + const char *p= value; + for (;;) + { + if (*p < '0' || *p > '9') + return 1; + do + { + if (parts[part] > (UINT_MAX - (uint) (*p - '0')) / 10) + return 1; + parts[part]= parts[part] * 10 + (uint) (*p++ - '0'); + } while (*p >= '0' && *p <= '9'); + if (!*p) + break; + if (*p++ != '.' || ++part > 2) + return 1; + } + if (!part) + return 1; + *major= parts[0]; + *minor= parts[1]; + return 0; +} + + +/* The restricted YAML reader only supports physical, single-line UTF-8. */ +static int index_line_utf8(const char *line) +{ + const uchar *p= (const uchar *) line; + size_t len= strlen(line); + int error; + + if (my_well_formed_length(&my_charset_utf8mb4_bin, line, line + len, + len, &error) != len || error) + return 1; + for (; *p; p++) + { + /* YAML treats NEL and the Unicode separators as line breaks. */ + if ((p[0] == 0xc2 && p[1] >= 0x80 && p[1] <= 0x9f) || + /* The charset helper accepts encoded surrogates, but UTF-8 does not. */ + (p[0] == 0xed && p[1] >= 0xa0 && p[1] <= 0xbf) || + (p[0] == 0xe2 && p[1] == 0x80 && + (p[2] == 0xa8 || p[2] == 0xa9))) + return 1; + } + return 0; +} + + +struct index_reader +{ + FILE *file; + const char *source; + my_bool after_separator; +}; + + +/* One package mapping per YAML document. 1 = record, 0 = EOF, -1 = error. */ +static int read_index_entry(struct index_reader *reader, struct index_entry *e) { char line[KV_LINE_SIZE]; - const char *keys[]= {"name", "version", "server", "platform", "arch", + const char *keys[]= {"name", "version", "server", "platform", "file", "sha256", "author", "description", "license"}; - char *values[]= {e->name, e->version, e->server, e->platform, e->arch, + char *values[]= {e->name, e->version, e->server, e->platform, e->file, e->sha256, e->author, e->description, e->license}; size_t sizes[]= {sizeof(e->name), sizeof(e->version), sizeof(e->server), - sizeof(e->platform), sizeof(e->arch), sizeof(e->file), + sizeof(e->platform), sizeof(e->file), sizeof(e->sha256), sizeof(e->author), sizeof(e->description), sizeof(e->license)}; - uint seen= 0; - my_bool have_fields= FALSE; + uint seen= 0, major, minor; + my_bool have_package= FALSE, separator= reader->after_separator; int rc; - size_t i, len; + size_t i, len, indent= 0; bzero(e, sizeof(*e)); for (;;) { - rc= read_kv_line(file, source, line, FALSE); + my_bool oversized; + char *key, *value; + size_t spaces; + + rc= read_metadata_line(reader->file, reader->source, line, &oversized); if (rc < 0) return -1; - if (rc && line[0]) + if (oversized) { - char *value= strchr(line, ':'); - if (!value || value == line || value[1] != ' ') + fprintf(stderr, "ERROR: '%s' contains an invalid or oversized line.\n", + reader->source); + return -1; + } + if (index_line_utf8(line)) + goto invalid; + for (i= 0; line[i]; i++) + if ((uchar) line[i] < 0x20 || line[i] == 0x7f) goto invalid; - *value++= '\0'; + key= line; + while (*key == ' ') + key++; + spaces= (size_t) (key - line); + if (rc && (!*key || *key == '#')) + continue; + if (rc && !spaces && !strncmp(key, "---", 3) && + (!key[3] || key[3] == ' ')) + { + value= key + 3; while (*value == ' ') value++; - len= strlen(value); - while (len && value[len - 1] == ' ') - value[--len]= '\0'; - for (i= 0; value[i]; i++) - if ((uchar) value[i] < 0x20 || value[i] == 0x7f) + if (*value && *value != '#') + goto invalid; + if (!have_package) + { + if (separator) goto invalid; - for (i= 0; i < array_elements(keys); i++) - if (!strcmp(line, keys[i])) - break; - if (i < array_elements(keys)) + separator= TRUE; + continue; + } + reader->after_separator= TRUE; + break; + } + if (!rc) + { + if (!have_package) { - if ((i < 7 && !len) || (seen & (1U << i)) || - safe_strcpy_truncated(values[i], sizes[i], value)) + if (separator) goto invalid; - seen|= 1U << i; + return 0; } - have_fields= TRUE; - continue; + reader->after_separator= FALSE; + break; } - if (have_fields) + if (!have_package) { - if ((seen & 127) != 127 || validate_plugin_name(e->name)) + if (spaces || strncmp(key, "package:", 8)) goto invalid; - len= strlen(e->file); - if (len < 7 || strcmp(e->file + len - 7, ".tar.gz") || - !valid_relative_path(e->file)) + value= key + 8; + if (*value && *value != ' ') goto invalid; - for (i= 0; i < len; i++) - if (!isalnum((uchar) e->file[i]) && e->file[i] != '.' && - e->file[i] != '_' && e->file[i] != '-') - goto invalid; - if (strlen(e->sha256) != 64) + while (*value == ' ') + value++; + if (*value && *value != '#') goto invalid; - for (i= 0; i < 64; i++) - if (!isxdigit((uchar) e->sha256[i])) - goto invalid; - return 1; + have_package= TRUE; + continue; } - if (!rc) - return 0; + if (!spaces || (indent && spaces != indent)) + goto invalid; + indent= spaces; + value= strchr(key, ':'); + if (!value || value == key || value[1] != ' ') + goto invalid; + *value++= '\0'; + while (*value == ' ') + value++; + if (index_scalar(value)) + goto invalid; + for (i= 0; i < array_elements(keys); i++) + if (!strcmp(key, keys[i])) + break; + /* Unknown fields require an explicit schema extension, not silent loss. */ + if (i == array_elements(keys) || (seen & (1U << i)) || + (i < 6 && !*value) || + safe_strcpy_truncated(values[i], sizes[i], value)) + goto invalid; + seen|= 1U << i; } + if ((seen & 63) != 63 || validate_plugin_name(e->name) || + index_server_series(e->server, &major, &minor)) + goto invalid; + len= strlen(e->file); + if (len < 7 || strcmp(e->file + len - 7, ".tar.gz") || + !valid_relative_path(e->file)) + goto invalid; + for (i= 0; i < len; i++) + if (!isalnum((uchar) e->file[i]) && e->file[i] != '.' && + e->file[i] != '_' && e->file[i] != '-') + goto invalid; + if (strlen(e->sha256) != 64) + goto invalid; + for (i= 0; i < 64; i++) + if (!isxdigit((uchar) e->sha256[i])) + goto invalid; + return 1; invalid: - fprintf(stderr, "ERROR: invalid plugin record in '%s'.\n", source); + fprintf(stderr, "ERROR: invalid plugin record in '%s' " + "(expected a supported package YAML document).\n", reader->source); return -1; } static int index_entry_compatible(const struct index_entry *e) { - char server[32]; - my_snprintf(server, sizeof(server), "%u.%u", MYSQL_VERSION_ID / 10000, - MYSQL_VERSION_ID / 100 % 100); - return !strcmp(e->server, server) && !strcmp(e->platform, SYSTEM_TYPE) && - !strcmp(e->arch, MACHINE_TYPE); + uint major, minor; + return !index_server_series(e->server, &major, &minor) && + major == MYSQL_VERSION_ID / 10000 && + minor == MYSQL_VERSION_ID / 100 % 100 && + !strcasecmp(e->platform, PLUGIN_PACKAGE_PLATFORM); } @@ -2696,9 +2909,10 @@ static int read_index(FILE *file, const char *source, const char *name, struct index_entry *result) { struct index_entry e; + struct index_reader reader= {file, source, FALSE}; int rc, found= 0; - while ((rc= read_index_entry(file, source, &e)) > 0) + while ((rc= read_index_entry(&reader, &e)) > 0) { if (strcmp(e.name, name) || !index_entry_compatible(&e)) continue; @@ -2715,8 +2929,9 @@ static int read_index(FILE *file, const char *source, const char *name, return 1; if (!found) fprintf(stderr, "ERROR: no compatible download for '%s' " - "(server %u.%u, %s, %s).\n", name, MYSQL_VERSION_ID / 10000, - MYSQL_VERSION_ID / 100 % 100, SYSTEM_TYPE, MACHINE_TYPE); + "(server %u.%u, platform %s).\n", name, + MYSQL_VERSION_ID / 10000, MYSQL_VERSION_ID / 100 % 100, + PLUGIN_PACKAGE_PLATFORM); return !found; } @@ -2768,7 +2983,7 @@ static int read_manifest(const char *basedir, const char *manifest, my_close(fd, MYF(0)); return 1; } - while (!error && (rc= read_kv_line(file, manifest, line, TRUE)) > 0) + while (!error && (rc= read_manifest_line(file, manifest, line)) > 0) { if (!strncmp(line, "name: ", 6)) { @@ -2856,7 +3071,7 @@ static int tarball_plugin_installed(const char *basedir, const char *name, my_close(fd, MYF(0)); return 1; } - while ((rc= read_kv_line(file, manifest, line, TRUE)) > 0) + while ((rc= read_manifest_line(file, manifest, line)) > 0) { char *value; @@ -2900,6 +3115,7 @@ static int search_tarball(const char *basedir) { char url[KV_LINE_SIZE * 2]; struct index_entry entry; + struct index_reader reader; FILE *index; int rc, error= 1; @@ -2907,7 +3123,10 @@ static int search_tarball(const char *basedir) return 1; if (!(index= download_file(url, 8 * 1024 * 1024))) return 1; - while ((rc= read_index_entry(index, url, &entry)) > 0) + reader.file= index; + reader.source= url; + reader.after_separator= FALSE; + while ((rc= read_index_entry(&reader, &entry)) > 0) { struct plugin_entry *plugin; @@ -4114,6 +4333,12 @@ static int run_new_command(int argc, char **argv) "install.\n"); return 1; } + if (!strcmp(verb, "uninstall") && (opt_file || opt_sha256 || opt_base_url)) + { + fprintf(stderr, "ERROR: uninstall does not support --file, --sha256 " + "or --base-url.\n"); + return 1; + } #ifdef PKG_DELEGATION if (is_search && opt_base_url) { @@ -4123,6 +4348,26 @@ static int run_new_command(int argc, char **argv) } #endif +#if !defined(HAVE_LIBCURL) && !defined(PKG_DELEGATION) + /* + Tarball builds without libcurl are the Windows builds: they have no + downloader, so the repository operations are refused before any path is + resolved or any file is touched. A local archive (--file) stays usable. + */ + if (is_search) + { + fprintf(stderr, "ERROR: Remote plugin search is not supported on " + "Windows yet.\n"); + return 1; + } + if (!opt_file && strcmp(verb, "install") == 0) + { + fprintf(stderr, "ERROR: Remote plugin installation is not supported on " + "Windows yet.\n"); + return 1; + } +#endif + if (detect_install_method(basedir, sizeof(basedir))) return 1; diff --git a/man/mariadb-plugin.1 b/man/mariadb-plugin.1 index 6bbbe2fee4778..4c70f3c16979f 100644 --- a/man/mariadb-plugin.1 +++ b/man/mariadb-plugin.1 @@ -1,6 +1,6 @@ '\" t .\" -.TH "\fBMARIADB-PLUGIN\fR" "1" "5 March 2025" "MariaDB 11.8" "MariaDB Database System" +.TH "\fBMARIADB-PLUGIN\fR" "1" "18 September 2026" "MariaDB 13.1" "MariaDB Database System" .\" ----------------------------------------------------------------- .\" * Define some portability stuff .\" ----------------------------------------------------------------- @@ -22,138 +22,215 @@ .\" ----------------------------------------------------------------- .\" mariadb-plugin .SH "NAME" -mariadb-plugin \- configure MariaDB server plugins (mariadb-plugin is now a symlink to mariadb-plugin) +mariadb-plugin \- search for, install and uninstall MariaDB server plugins .SH "SYNOPSIS" -.HP \w'\fBmariadb-plugin\ [\fR\fB\fIoptions\fR\fR\fB]\ \fR\fB\fIplugin\fR\fR\fB\ {ENABLE|DISABLE}\fR\ 'u -\fBmariadb-plugin [\fR\fB\fIoptions\fR\fR\fB] \fR\fB\fIplugin\fR\fR\fB {ENABLE|DISABLE}\fR +.HP \w'\fBmariadb-plugin\ [\fR\fB\fIoptions\fR\fR\fB]\ search\ [\fR\fB\fIplugin\fR\fR\fB]\fR\ 'u +\fBmariadb-plugin [\fR\fB\fIoptions\fR\fR\fB] search [\fR\fB\fIplugin\fR\fR\fB]\fR +.HP \w'\fBmariadb-plugin\ [\fR\fB\fIoptions\fR\fR\fB]\ install\ \fR\fB\fIplugin\fR\fR\ 'u +\fBmariadb-plugin [\fR\fB\fIoptions\fR\fR\fB] install \fR\fB\fIplugin\fR\fR +.HP \w'\fBmariadb-plugin\ [\fR\fB\fIoptions\fR\fR\fB]\ uninstall\ \fR\fB\fIplugin\fR\fR\ 'u +\fBmariadb-plugin [\fR\fB\fIoptions\fR\fR\fB] uninstall \fR\fB\fIplugin\fR\fR .SH "DESCRIPTION" .PP The \fBmariadb-plugin\fR -utility enables MariaDB administrators to manage which plugins a MariaDB server loads\&. It provides an alternative to manually specifying the -\fB\-\-plugin\-load\fR -option at server startup or using the -INSTALL PLUGIN -and -UNINSTALL PLUGIN -statements at runtime\&. +utility installs, uninstalls and searches for MariaDB server plugins\&. .PP -Depending on whether -\fBmariadb-plugin\fR -is invoked to enable or disable plugins, it inserts or deletes rows in the -mysql\&.plugin -table that serves as a plugin registry\&. (To perform this operation, +Plugins are named the same way on every platform, +\fIplugin\fR +being the plugin name without any prefix, for instance +rocksdb +or +provider\-lz4\&. The tool builds the package name from it, so that +rocksdb +becomes +mariadb\-plugin\-rocksdb\&. +.PP +The build layout selects how \fBmariadb-plugin\fR -invokes the MariaDB server in bootstrap mode\&. This means that the server must not already be running\&.) For normal server startups, the server loads and enables plugins listed in -mysql\&.plugin -automatically\&. For additional control over plugin activation, use -\fB\-\-\fR\fB\fIplugin_name\fR\fR -options named for specific plugins\&. +installs a plugin. At runtime it validates that its own executable belongs to +the matching installation. On RPM or DEB installations the work is left to +the system package manager, so that the plugin +files stay owned by it and dependency resolution, signature checks and +upgrades keep working\&. +\fBapt\-get\fR +is used on DEB installations, and +\fBdnf\fR +or +\fBzypper\fR +on RPM ones, whichever is present\&. The package manager talks to the user +directly, asks for confirmation itself, and its exit status is passed +through unchanged\&. Installing and uninstalling this way requires root\&. +.PP +On RPM installations the packages carry their own names, such as +MariaDB\-rocksdb\-engine, and answer to the uniform name through +Provides\&. Uninstall resolves the real package name before removing it\&. .PP -Each invocation of +On binary tarball installations, install reads a local archive specified by +\fB\-\-file\fR, or downloads one from the plugin repository\&. Downloads +require a matching SHA\-256 checksum from the repository index\&. The tool +validates the archive, refuses to overwrite files, records installed paths +under \fI/.mariadb\-plugin/\fR, and prints instructions for enabling +the plugin\&. It does not edit server configuration\&. Uninstall uses the +recorded paths and does not need network access\&. Before removing files, +uninstall validates the manifest's plugin name and paths. An invalid, +unreadable or non-regular manifest causes no deletion. If a file cannot be +removed, the manifest is kept so uninstall can be retried\&. +.PP +The deprecated +ENABLE/DISABLE +syntax requires the legacy plugin configuration file and an already-installed +plugin library. It does not extract an archive. Use the activation instructions +printed after a tarball installation, or the server's +INSTALL SONAME +statement or +\fB\-\-plugin\-load\-add\fR +option\&. +.PP +An error is given if \fBmariadb-plugin\fR -reads a configuration file to determine how to configure the plugins contained in a single plugin library object file\&. To invoke -\fBmariadb-plugin\fR, use this syntax: +does not run from the installation it belongs to, for example when it is +started from a build directory, as it would otherwise act on an unrelated +installation\&. +.PP +To install a plugin: .sp .if n \{\ .RS 4 .\} .nf -mariadb-plugin [\fIoptions\fR] \fIplugin\fR {ENABLE|DISABLE} +shell> \fBmariadb-plugin install rocksdb\fR .fi .if n \{\ .RE .\} .PP -\fIplugin\fR -is the name of the plugin to configure\&. -ENABLE -or -DISABLE -(not case sensitive) specify whether to enable or disable components of the plugin library named in the configuration file\&. The order of the -\fIplugin\fR -and -ENABLE -or -DISABLE -arguments does not matter\&. -.PP -For example, to configure components of a plugin library file named -myplugins\&.so -on Linux or -myplugins\&.dll -on Windows, specify a -\fIplugin\fR -value of -myplugins\&. Suppose that this plugin library contains three plugins, -plugin1, -plugin2, and -plugin3, all of which should be configured under -\fBmariadb-plugin\fR -control\&. By convention, configuration files have a suffix of -\&.ini -and the same basename as the plugin library, so the default configuration file name for this plugin library is -myplugins\&.ini\&. The configuration file contents look like this: +To see which plugins are available and which are already installed, with an +optional word to search for: .sp .if n \{\ .RS 4 .\} .nf -myplugins -plugin1 -plugin2 -plugin3 +shell> \fBmariadb-plugin search\fR +shell> \fBmariadb-plugin search provider\fR .fi .if n \{\ .RE .\} .PP -The first line in the -myplugins\&.ini -file is the name of the library object file, without any extension such as -\&.so -or -\&.dll\&. The remaining lines are the names of the components to be enabled or disabled\&. Each value in the file should be on a separate line\&. Lines on which the first character is -\*(Aq#\*(Aq -are taken as comments and ignored\&. +Each plugin is listed with its name, whether it is installed or only +available, and its description\&. Searching needs no root, and ends with a +non\-zero status when nothing matches\&. .PP -To enable the plugins listed in the configuration file, invoke -\fBmariadb-plugin\fR -this way: +On tarball installations, search reads the same repository index as install, +lists compatible builds, and filters by a substring of the plugin name\&. +It downloads only metadata and does not change installation files\&. +The installed status means that this tool has a readable manifest with a +matching plugin name and valid path records\&. It does not verify file health +or whether the server has loaded the plugin; an interrupted installation can +also leave a manifest\&. Invalid or unreadable manifests cause an error\&. +Bundled libraries without a manifest are shown as available when listed in +the repository\&. Plugins absent from the index are omitted from search but +can still be uninstalled using their local manifests\&. +.PP +Windows ZIP packages are not implemented yet\&. +.SH "TARBALL REPOSITORIES" +.PP +\fB\-\-base\-url\fR specifies an HTTP or HTTPS directory containing +\fBplugins.index\fR and the plugin archives\&. No default repository is +configured yet, so tarball search and downloading require this option\&. HTTPS is +recommended; certificate verification is enabled\&. An HTTPS download +cannot redirect to HTTP\&. +.PP +The index uses a restricted YAML subset: one \fBpackage:\fR mapping per +document, with documents separated by \fB\-\-\-\fR on its own line\&. +Fields use consistent space indentation and single-line scalar values\&. +Blank lines and comments are allowed; blank lines do not separate records\&. +Each physical line may contain at most 1023 bytes before its newline\&. +For example: +.sp +.nf +package: + name: example + version: "1.0" + server: "13.1.0" + platform: linux\-x86_64 + file: mariadb\-plugin\-example\-1.0.tar.gz + sha256: "<64 hexadecimal characters>" +.fi +.PP +All six fields are required\&. Server versions must be numeric +major.minor or major.minor.patch; only the major/minor series is matched\&. +The combined platform identifier must match CMake's +\fBSYSTEM_NAME_AND_PROCESSOR\fR value for the build, ignoring letter case\&. +The tool refuses an index +with multiple matching records rather than guessing which version to use\&. +The optional author, description and license fields are accepted\&. Search +prints the description, or leaves that column empty when it is missing\&. +Duplicate occurrences of these fields are rejected, as are duplicate required +fields\&. Unknown fields and unsupported YAML structures are rejected\&. +Values may be plain, single-quoted (double an embedded apostrophe), or +double-quoted (only escaped quotes and backslashes are supported)\&. +Quote versions, checksums, and text containing a colon followed by a space +or a space followed by a hash sign\&. Lists, aliases, tags, multiline values, +directives and explicit document-end markers are not supported\&. +Plain null and boolean values are rejected; use quoted strings instead\&. +Index text must be UTF-8; Unicode line-break characters are rejected by this +single-line subset\&. +.PP +Per-package metadata inside an archive describes the package\&. The repository +adds \fBfile\fR and \fBsha256\fR after creating the archive and combines +the documents into \fBplugins.index\fR\&. Local uninstall manifests retain +their existing format\&. +.PP +Archive filenames must end in \fB.tar.gz\fR and contain only letters, +digits, dots, underscores and hyphens, without consecutive dots\&. +They cannot contain directory paths or URLs\&. Keep the original CPack +filename so the installer can recognize the archive's wrapper directory\&. +The repository index is limited to 8 MiB and each downloaded archive to 1 GiB\&. +.SH "DEPRECATED SYNTAX" +.PP +The former syntax .sp .if n \{\ .RS 4 .\} .nf -shell> \fBmariadb-plugin myplugins ENABLE\fR +mariadb-plugin [\fIoptions\fR] \fIplugin\fR {ENABLE|DISABLE} .fi .if n \{\ .RE .\} .PP -To disable the plugins, use -DISABLE -rather than -ENABLE\&. -.PP -An error occurs if -\fBmariadb-plugin\fR -cannot find the configuration file or plugin library file, or if -\fBmariadb-plugin\fR -cannot start the MariaDB server\&. +is deprecated and kept only for backward compatibility\&. It does not +install or remove any files: it enables or disables plugins in the +mysql\&.plugin +table by starting the server in bootstrap mode, which means the server must +not already be running\&. The plugins to configure are read from a +configuration file named after the plugin library, for instance +myplugins\&.ini +for +myplugins\&.so, listing the library name on the first line and the +components to enable on the following ones\&. .PP -\fBmariadb-plugin\fR -supports the following options, which can be specified on the command line or in the -[mariadbd] -group of any option file\&. For options specified in a -[mariadbd] -group, -\fBmariadb-plugin\fR -recognizes the +The \fB\-\-basedir\fR, -\fB\-\-datadir\fR, and -\fB\-\-plugin\-dir\fR -options and ignores others\&. +\fB\-\-datadir\fR, +\fB\-\-plugin\-dir\fR, +\fB\-\-plugin\-ini\fR, +\fB\-\-mysqld\fR, +\fB\-\-my\-print\-defaults\fR +and +\fB\-\-lc\-messages\-dir\fR +options apply to this syntax only\&. +.PP +For new setups, use +INSTALL SONAME +or the +\fB\-\-plugin\-load\-add\fR +server option instead\&. +.SH "OPTIONS" .PP mariadb-plugin Options .sp @@ -181,12 +258,20 @@ Display a help message and exit\&. .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: basedir option -.\" basedir option: mariadb-plugin -\fB\-\-basedir=\fR\fB\fIpath\fR\fR, -\fB\-b \fR\fB\fIpath\fR\fR +.\" mariadb-plugin: dry-run option +.\" dry-run option: mariadb-plugin +\fB\-\-dry\-run\fR .sp -The server base directory\&. +Print the commands that +install +and +uninstall +would run, without running them\&. The package name is resolved as usual, so +the command shown is the one that would be executed\&. Needs no root\&. +For tarball downloads, fetch the index and show the chosen URL and checksum, +without downloading the archive or changing the installation\&. For a local +archive, validate it and print the planned extraction\&. +This option is rejected with the deprecated ENABLE/DISABLE syntax\&. .RE .sp .RS 4 @@ -197,12 +282,13 @@ The server base directory\&. .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: datadir option -.\" datadir option: mariadb-plugin -\fB\-\-datadir=\fR\fB\fIpath\fR\fR, -\fB\-d \fR\fB\fIpath\fR\fR +.\" mariadb-plugin: verbose option +.\" verbose option: mariadb-plugin +\fB\-\-verbose\fR, +\fB\-v\fR .sp -The server data directory\&. +Verbose mode\&. Print more information about what the program does\&. This +option can be used multiple times to increase the amount of information\&. .RE .sp .RS 4 @@ -213,15 +299,48 @@ The server data directory\&. .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: my-print-defaults option -.\" my-print-defaults option: mariadb-plugin -\fB\-\-my\-print\-defaults=\fR\fB\fIpath\fR\fR, -\fB\-b \fR\fB\fIpath\fR\fR +.\" mariadb-plugin: version option +.\" version option: mariadb-plugin +\fB\-\-version\fR, +\fB\-V\fR .sp -The path to the -\fBmy_print_defaults\fR -program\&. +Display version information and exit\&. .RE +.SS "Tarball Options" +.TP +\fB\-\-base\-url=\fIURL\fR +Override the plugin repository directory\&. Applies to tarball search and install; +cannot be combined with \fB\-\-file\fR\&. +.TP +\fB\-\-file=\fIpath\fR +Install from a local regular archive file without using the repository\&. +The archive is copied to private temporary storage before verification and +extraction, so replacing the source path cannot change the installed bytes\&. +.TP +\fB\-\-sha256=\fIhex\fR +Expected checksum of a local archive\&. Requires \fB\-\-file\fR\&. +Without this option, local installation warns that the archive is unverified\&. +Downloaded archives always require the checksum supplied in the index\&. +Search rejects \fB\-\-file\fR and \fB\-\-sha256\fR\&. +RPM and DEB installations also reject \fB\-\-base\-url\fR for search; +they use native package repositories\&. They reject all three options for +install\&. +.PP +The following options apply to the deprecated +ENABLE +and +DISABLE +syntax only\&. They can be specified on the command line or in the +[mariadbd] +group of any option file, where +\fBmariadb-plugin\fR +recognizes +\fB\-\-basedir\fR, +\fB\-\-datadir\fR, +\fB\-\-plugin\-dir\fR +and +\fB\-\-lc\-messages\-dir\fR +and ignores other options from that group\&. .sp .RS 4 .ie n \{\ @@ -231,14 +350,12 @@ program\&. .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: mariadbd option -.\" mariadbd option: mariadb-plugin -\fB\-\-mariadbd=\fR\fB\fIpath\fR\fR, +.\" mariadb-plugin: basedir option +.\" basedir option: mariadb-plugin +\fB\-\-basedir=\fR\fB\fIpath\fR\fR, \fB\-b \fR\fB\fIpath\fR\fR .sp -The path to the -\fBmariadbd\fR -server\&. +The server base directory\&. .RE .sp .RS 4 @@ -249,16 +366,12 @@ server\&. .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: no-defaults option -.\" no-defaults option: mariadb-plugin -\fB\-\-no\-defaults\fR, -\fB\-p\fR -.sp -Do not read values from the configuration file\&. This option enables an administrator to skip reading defaults from the configuration file\&. +.\" mariadb-plugin: datadir option +.\" datadir option: mariadb-plugin +\fB\-\-datadir=\fR\fB\fIpath\fR\fR, +\fB\-d \fR\fB\fIpath\fR\fR .sp -With -\fBmariadb-plugin\fR, this option need not be given first on the command line, unlike most other MariaDB programs that support -\fB\-\-no\-defaults\fR\&. +The server data directory\&. .RE .sp .RS 4 @@ -292,7 +405,8 @@ The server plugin directory\&. .sp The \fBmariadb-plugin\fR -configuration file\&. Relative path names are interpreted relative to the current directory\&. If this option is not given, the default is +configuration file\&. Relative path names are interpreted relative to the +current directory\&. If this option is not given, the default is \fIplugin\fR\&.ini in the plugin directory, where \fIplugin\fR @@ -309,22 +423,32 @@ argument on the command line\&. .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: print-defaults option -.\" print-defaults option: mariadb-plugin -\fB\-\-print\-defaults\fR, -\fB\-P\fR +.\" mariadb-plugin: mysqld option +.\" mysqld option: mariadb-plugin +\fB\-\-mysqld=\fR\fB\fIpath\fR\fR, +\fB\-m \fR\fB\fIpath\fR\fR .sp -Display the default values from the configuration file\&. This option causes -\fBmariadb-plugin\fR -to print the defaults for -\fB\-\-basedir\fR, -\fB\-\-datadir\fR, and -\fB\-\-plugin\-dir\fR -if they are found in the configuration file\&. If no value for a variable is found, nothing is shown\&. +The path to the +\fBmariadbd\fR +server\&. +.RE .sp -With -\fBmariadb-plugin\fR, this option need not be given first on the command line, unlike most other MariaDB programs that support -\fB\-\-print\-defaults\fR\&. +.RS 4 +.ie n \{\ +\h'-04'\(bu\h'+03'\c +.\} +.el \{\ +.sp -1 +.IP \(bu 2.3 +.\} +.\" mariadb-plugin: my-print-defaults option +.\" my-print-defaults option: mariadb-plugin +\fB\-\-my\-print\-defaults=\fR\fB\fIpath\fR\fR, +\fB\-f \fR\fB\fIpath\fR\fR +.sp +The path to the +\fBmy_print_defaults\fR +program\&. .RE .sp .RS 4 @@ -335,12 +459,12 @@ With .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: verbose option -.\" verbose option: mariadb-plugin -\fB\-\-verbose\fR, -\fB\-v\fR +.\" mariadb-plugin: lc-messages-dir option +.\" lc-messages-dir option: mariadb-plugin +\fB\-\-lc\-messages\-dir=\fR\fB\fIpath\fR\fR, +\fB\-l \fR\fB\fIpath\fR\fR .sp -Verbose mode\&. Print more information about what the program does\&. This option can be used multiple times to increase the amount of information\&. +The directory with the server error messages\&. .RE .sp .RS 4 @@ -351,17 +475,51 @@ Verbose mode\&. Print more information about what the program does\&. This optio .sp -1 .IP \(bu 2.3 .\} -.\" mariadb-plugin: version option -.\" version option: mariadb-plugin -\fB\-\-version\fR, -\fB\-V\fR +.\" mariadb-plugin: no-defaults option +.\" no-defaults option: mariadb-plugin +\fB\-\-no\-defaults\fR, +\fB\-n\fR .sp -Display version information and exit\&. +Do not read values from the configuration file\&. This option enables an +administrator to skip reading defaults from the configuration file\&. +.sp +With +\fBmariadb-plugin\fR, this option need not be given first on the command line, +unlike most other MariaDB programs that support +\fB\-\-no\-defaults\fR\&. +.RE +.sp +.RS 4 +.ie n \{\ +\h'-04'\(bu\h'+03'\c +.\} +.el \{\ +.sp -1 +.IP \(bu 2.3 +.\} +.\" mariadb-plugin: print-defaults option +.\" print-defaults option: mariadb-plugin +\fB\-\-print\-defaults\fR, +\fB\-P\fR +.sp +Display the default values from the configuration file\&. This option causes +\fBmariadb-plugin\fR +to print the defaults for +\fB\-\-basedir\fR, +\fB\-\-datadir\fR, and +\fB\-\-plugin\-dir\fR +if they are found in the configuration file\&. If no value for a variable is +found, nothing is shown\&. +.sp +With +\fBmariadb-plugin\fR, this option need not be given first on the command line, +unlike most other MariaDB programs that support +\fB\-\-print\-defaults\fR\&. .RE .SH "COPYRIGHT" .br .PP -Copyright \(co 1997, 2013, Oracle and/or its affiliates. All rights reserved., 2013-2025 MariaDB Foundation +Copyright \(co 1997, 2013, Oracle and/or its affiliates. All rights reserved., 2013-2026 MariaDB Foundation .PP This documentation is free software; you can redistribute it and/or modify it only under the terms of the GNU General Public License as published by the Free Software Foundation; version 2 of the License. .PP diff --git a/mysql-test/main/mariadb-plugin-win.result b/mysql-test/main/mariadb-plugin-win.result new file mode 100644 index 0000000000000..ffd796a6ce823 --- /dev/null +++ b/mysql-test/main/mariadb-plugin-win.result @@ -0,0 +1,11 @@ +# +# Remote installation is refused, with or without --dry-run/--base-url +# +ERROR: Remote plugin installation is not supported on Windows yet. +ERROR: Remote plugin installation is not supported on Windows yet. +ERROR: Remote plugin installation is not supported on Windows yet. +# +# Remote search is refused +# +ERROR: Remote plugin search is not supported on Windows yet. +ERROR: Remote plugin search is not supported on Windows yet. diff --git a/mysql-test/main/mariadb-plugin-win.test b/mysql-test/main/mariadb-plugin-win.test new file mode 100644 index 0000000000000..70e6513f28bd4 --- /dev/null +++ b/mysql-test/main/mariadb-plugin-win.test @@ -0,0 +1,25 @@ +# +# MDEV-40786: Windows builds are not linked with libcurl, so the operations +# that reach a remote repository must fail with a clear message and a +# non-zero exit status, before any path is resolved or any file is touched. +# Installations from a local archive are still possible. +# +--source include/windows.inc + +--echo # +--echo # Remote installation is refused, with or without --dry-run/--base-url +--echo # +--error 1 +--exec $MYSQL_PLUGIN install provider_example 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install provider_example --dry-run 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN install provider_example --base-url=https://example.invalid/plugins/ 2>&1 > /dev/null + +--echo # +--echo # Remote search is refused +--echo # +--error 1 +--exec $MYSQL_PLUGIN search 2>&1 > /dev/null +--error 1 +--exec $MYSQL_PLUGIN search provider 2>&1 > /dev/null diff --git a/mysql-test/main/mariadb-plugin.result b/mysql-test/main/mariadb-plugin.result index 1f4db80f195ae..1043ff7128a08 100644 --- a/mysql-test/main/mariadb-plugin.result +++ b/mysql-test/main/mariadb-plugin.result @@ -13,6 +13,18 @@ ERROR: 'install' requires exactly one plugin name. # ERROR: 'search' takes at most one search term. # +# search rejects archive-install options before accessing repositories +# +ERROR: --file and --sha256 are only supported with install. +ERROR: --file and --sha256 are only supported with install. +ERROR: --file and --sha256 are only supported with install. +# +# uninstall rejects install-only options before accessing the installation +# +ERROR: uninstall does not support --file, --sha256 or --base-url. +ERROR: uninstall does not support --file, --sha256 or --base-url. +ERROR: uninstall does not support --file, --sha256 or --base-url. +# # Plugin names are validated, as they become package names # ERROR: invalid character ';' in plugin name. Use only [a-z0-9_-]. @@ -36,3 +48,15 @@ ERROR: 'install' requires exactly one plugin name. ERROR: Missing --basedir option. ERROR: Missing --basedir option. ERROR: Missing --basedir option. +# +# Option values do not choose between legacy and new commands +# +ERROR: Missing --basedir option. +ERROR: Missing --basedir option. +ERROR: Missing --basedir option. +ERROR: 'install' requires exactly one plugin name. +# +# Legacy syntax must not silently ignore --dry-run +# +ERROR: --dry-run is not supported with ENABLE/DISABLE. +ERROR: --dry-run is not supported with ENABLE/DISABLE. diff --git a/mysql-test/main/mariadb-plugin.test b/mysql-test/main/mariadb-plugin.test index 2e785ca5162f5..05c69e095b16d 100644 --- a/mysql-test/main/mariadb-plugin.test +++ b/mysql-test/main/mariadb-plugin.test @@ -1,49 +1,85 @@ # # MDEV-40786 mariadb-plugin as a plugin package manager. # -# Only the command line handling is tested here, as it is the same for -# every installation method. Installing, removing and searching for -# plugins is not testable in mtr: the tool acts only when it runs from -# the location it was installed to, never from a build tree, so those -# commands are covered by the install and upgrade builders instead. +# These checks cover command-line validation and legacy syntax routing. +# They stop before package-manager or plugin-file operations. # # Errors are printed on stderr, the usage text on stdout, so only stderr # is kept below to leave paths and version strings out of the result. -# +# Output normalization: The MSAN container supplies an unversioned libcurl.so.4 +# which emits a dynamic loader diagnostic on startup to stderr. +# Normalize this environment warning on a line-bounded basis so application +# diagnostics remain tested. --echo # --echo # Unknown command --echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN nosuchcommand foo 2>&1 > /dev/null --echo # --echo # A plugin name is required, and only one --echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN uninstall 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install foo bar 2>&1 > /dev/null --echo # --echo # search takes an optional term, but not more than one --echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN search foo bar 2>&1 > /dev/null +--echo # +--echo # search rejects archive-install options before accessing repositories +--echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN search --file=missing.tar.gz 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN search --sha256=invalid 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN search provider- --file=missing.tar.gz 2>&1 > /dev/null + +--echo # +--echo # uninstall rejects install-only options before accessing the installation +--echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN uninstall probe --file=missing.tar.gz 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN uninstall probe --sha256=invalid 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN uninstall probe --base-url=http://127.0.0.1:1/ 2>&1 > /dev/null + --echo # --echo # Plugin names are validated, as they become package names --echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install "a;b" 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install "a b" 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install "../etc" 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install "" 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2>&1 > /dev/null @@ -51,8 +87,10 @@ --echo # A command word wins over the deprecated keywords, so that a plugin --echo # can be searched for by a name containing them --echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN search enable extra 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN install disable extra 2>&1 > /dev/null @@ -69,9 +107,38 @@ --echo # The deprecated syntax still reaches the old code path, which --echo # asks for its own options --echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN --no-defaults foo ENABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN --no-defaults foo DISABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN --no-defaults foo enable 2>&1 > /dev/null + +--echo # +--echo # Option values do not choose between legacy and new commands +--echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --plugin-ini install foo ENABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --plugin_ini install foo ENABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults -viinstall foo ENABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --file ENABLE install 2>&1 > /dev/null + +--echo # +--echo # Legacy syntax must not silently ignore --dry-run +--echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --dry-run foo ENABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --dry-run foo DISABLE 2>&1 > /dev/null From 9f5e6f569aec657fba46d9bd554fe639ab1942a9 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 26 Sep 2026 08:20:46 +0300 Subject: [PATCH 4/4] MDEV-40786 Add Windows repository downloads and ZIP support Use native WinHTTP on Windows so repository access does not require libcurl provisioning. Keep libcurl on other archive platforms and reuse the existing YAML, checksum and tarball installation paths. Bound downloads by size and a shared deadline, verify TLS certificates, reject HTTPS-to-HTTP redirects, and retain private temporary streams through verification and extraction. Update Windows CLI tests and the manual to reflect the restored remote commands. Verified on Windows x64 with disposable tarball fixtures: search, download/install/uninstall, checksums, cleanup, slow responses, trusted HTTPS and downgrade rejection. Add Windows ZIP extraction using in-tree MiniZip, reusing private archive snapshots, manifests and uninstall. Validate paths and headers, reject unsafe entries, and limit entry count and expanded size. Shared indexes accept TAR/ZIP records and skip unsupported ZIP packages. Track created paths in memory for rollback and reject incomplete manifests to prevent partial writes from becoming deletion targets. Verified local/remote ZIP operations, checksums and hostile archives on Windows with synthetic and CPack fixtures. Linux builds, CLI MTR and rollback regressions passed. Real packages and Windows DLL loading verification remain pending. --- client/CMakeLists.txt | 21 +- client/mysql_plugin.c | 1137 +++++++++++++++++++-- man/mariadb-plugin.1 | 19 +- mysql-test/main/mariadb-plugin-win.result | 14 +- mysql-test/main/mariadb-plugin-win.test | 20 +- mysql-test/main/mariadb-plugin.result | 6 + mysql-test/main/mariadb-plugin.test | 13 + 7 files changed, 1134 insertions(+), 96 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index b41c17de4f159..575f1efc6ae02 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -91,14 +91,19 @@ FUNCTION(MARIADB_PLUGIN_PACKAGE_PLATFORM) PLUGIN_PACKAGE_PLATFORM="${SYSTEM_NAME_AND_PROCESSOR}") ENDFUNCTION() MARIADB_PLUGIN_PACKAGE_PLATFORM() -# Tarball installations download plugin archives over HTTP, so those builds -# link libcurl and define HAVE_LIBCURL for the repository code. RPM and DEB -# installations delegate to the package manager, and Windows builds have no -# downloader: mysql_plugin.c refuses the repository operations instead. -IF(NOT INSTALL_LAYOUT MATCHES "^(RPM|DEB)$" AND NOT WIN32) - FIND_PACKAGE(CURL REQUIRED) - TARGET_LINK_LIBRARIES(mariadb-plugin CURL::libcurl) - TARGET_COMPILE_DEFINITIONS(mariadb-plugin PRIVATE HAVE_LIBCURL=1) +# Archive installations fetch repository metadata and plugin archives. +IF(NOT INSTALL_LAYOUT MATCHES "^(RPM|DEB)$") + IF(WIN32) + TARGET_SOURCES(mariadb-plugin PRIVATE + ${CMAKE_SOURCE_DIR}/storage/connect/unzip.c + ${CMAKE_SOURCE_DIR}/storage/connect/ioapi.c) + TARGET_INCLUDE_DIRECTORIES(mariadb-plugin PRIVATE + ${CMAKE_SOURCE_DIR}/storage/connect) + TARGET_LINK_LIBRARIES(mariadb-plugin winhttp shlwapi) + ELSE() + FIND_PACKAGE(CURL REQUIRED) + TARGET_LINK_LIBRARIES(mariadb-plugin CURL::libcurl) + ENDIF() ENDIF() MYSQL_ADD_EXECUTABLE(mariadb-binlog mysqlbinlog.cc mysqlbinlog-engine.cc) diff --git a/client/mysql_plugin.c b/client/mysql_plugin.c index bcde72d2aac2c..b33bb665b7e4c 100644 --- a/client/mysql_plugin.c +++ b/client/mysql_plugin.c @@ -54,11 +54,14 @@ #ifndef PKG_DELEGATION #include -/* - Repository access needs libcurl. Builds without it, such as Windows, keep - the archive handling but refuse the operations that would use the network. -*/ -#ifdef HAVE_LIBCURL +#ifdef _WIN32 +#include +#include +#include +#include +#include +#include "unzip.h" +#else #include #endif #include @@ -107,13 +110,13 @@ static struct my_option my_long_options[] = {"dry-run", 0, "Print the commands that install and uninstall would run, " "without running them.", &opt_dry_run, &opt_dry_run, 0, GET_BOOL, NO_ARG, 0, 0, 0, 0, 0, 0}, - {"file", 0, "Install the plugin from this local tarball instead of " + {"file", 0, "Install the plugin from this local archive instead of " "downloading it.", &opt_file, &opt_file, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, {"base-url", 0, "Plugin repository URL. Overrides the default download " "location for tarball installations.", &opt_base_url, &opt_base_url, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, - {"sha256", 0, "Expected SHA-256 checksum of the tarball, as published " + {"sha256", 0, "Expected SHA-256 checksum of the archive, as published " "beside it. Refuse to install if it does not match.", &opt_sha256, &opt_sha256, 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0}, {"no-defaults", 'n', "Do not read values from configuration file.", @@ -184,6 +187,14 @@ int main(int argc,char *argv[]) exit(error); } + if (opt_file || opt_sha256 || opt_base_url) + { + fprintf(stderr, "ERROR: --file, --sha256 and --base-url are not " + "supported with ENABLE/DISABLE.\n"); + my_end(my_end_arg); + return 1; + } + if (opt_dry_run) { fprintf(stderr, "ERROR: --dry-run is not supported with ENABLE/DISABLE.\n"); @@ -2358,6 +2369,114 @@ static int valid_relative_path(const char *path) } +static int archive_is_zip(const char *name) +{ + size_t len= strlen(name); + return len >= 4 && !strncasecmp(name + len - 4, ".zip", 4); +} + + +static int reserved_manifest_path(const char *path) +{ +#ifdef _WIN32 + const char *end; + size_t len; +#endif + /* Dot and empty components do not change the first directory on disk. */ + while (*path == '/') + path++; + while (path[0] == '.' && (path[1] == '/' || path[1] == '\0')) + { + path+= path[1] ? 2 : 1; + while (*path == '/') + path++; + } +#ifdef _WIN32 + end= strchr(path, '/'); + len= end ? (size_t) (end - path) : strlen(path); + while (len && (path[len - 1] == '.' || path[len - 1] == ' ')) + len--; + return len == sizeof(MANIFEST_SUBDIR) - 1 && + !strncasecmp(path, MANIFEST_SUBDIR, len); +#else + return !strncmp(path, MANIFEST_SUBDIR, + sizeof(MANIFEST_SUBDIR) - 1) && + (path[sizeof(MANIFEST_SUBDIR) - 1] == '/' || + path[sizeof(MANIFEST_SUBDIR) - 1] == '\0'); +#endif +} + + +#ifdef _WIN32 +/* An 8.3 name or a trailing dot/space can name the manifest directory too. */ +static int archive_targets_manifest(const char *basedir, const char *rel, + my_bool missing_ok) +{ + char manifest[FN_REFLEN], first[FN_REFLEN]; + const char *component= rel; + char *slash; + HANDLE owned= INVALID_HANDLE_VALUE, candidate= INVALID_HANDLE_VALUE; + BY_HANDLE_FILE_INFORMATION a, b; + int result= 1; + + while (*component == '/') + component++; + while (component[0] == '.' && + (component[1] == '/' || component[1] == '\0')) + { + component+= component[1] ? 2 : 1; + while (*component == '/') + component++; + } + if (build_full_path(manifest, sizeof(manifest), basedir, MANIFEST_SUBDIR) || + build_full_path(first, sizeof(first), basedir, component)) + return 1; + slash= strchr(first + strlen(basedir) + 1, '/'); + if (slash) + *slash= '\0'; + + owned= CreateFile(manifest, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + if (owned == INVALID_HANDLE_VALUE) + { + DWORD error= GetLastError(); + if (missing_ok && + (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)) + result= 0; + goto end; + } + if (!GetFileInformationByHandle(owned, &a)) + goto end; + candidate= CreateFile(first, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + if (candidate == INVALID_HANDLE_VALUE) + { + DWORD error= GetLastError(); + result= error != ERROR_FILE_NOT_FOUND && error != ERROR_PATH_NOT_FOUND; + goto end; + } + if (!GetFileInformationByHandle(candidate, &b)) + goto end; + result= a.dwVolumeSerialNumber == b.dwVolumeSerialNumber && + a.nFileIndexHigh == b.nFileIndexHigh && + a.nFileIndexLow == b.nFileIndexLow; + +end: + if (candidate != INVALID_HANDLE_VALUE) + CloseHandle(candidate); + if (owned != INVALID_HANDLE_VALUE) + CloseHandle(owned); + return result; +} +#endif + + /* 1 = line, 0 = EOF, -1 = error; always drain the complete physical line. */ static int read_metadata_line(FILE *file, const char *source, char *line, my_bool *oversized) @@ -2397,6 +2516,13 @@ static int read_manifest_line(FILE *file, const char *source, char *line) { my_bool oversized; int rc= read_metadata_line(file, source, line, &oversized); + /* A short write must never turn a prefix into a deletion target. */ + if (rc > 0 && feof(file)) + { + fprintf(stderr, "ERROR: '%s' contains an incomplete manifest record.\n", + source); + return -1; + } if (rc > 0 && oversized && (!strncmp(line, "dir: ", 5) || !strncmp(line, "file: ", 6))) { @@ -2408,7 +2534,7 @@ static int read_manifest_line(FILE *file, const char *source, char *line) } -#ifdef HAVE_LIBCURL +#ifndef _WIN32 struct download_target { FILE *file; @@ -2492,7 +2618,7 @@ static FILE *copy_local_archive(const char *name) /* Keep downloads open and anonymous, including between verification passes. */ -#ifdef HAVE_LIBCURL +#ifndef _WIN32 static FILE *download_file(const char *url, size_t limit) { char detail[CURL_ERROR_SIZE]= ""; @@ -2566,18 +2692,375 @@ static FILE *download_file(const char *url, size_t limit) return target.file; } #else -/* - Windows builds are not linked with libcurl, so they have no downloader. The - commands that would use it are refused first; this stub keeps the tarball - code compilable and cannot proceed silently. -*/ + +#define WINHTTP_URL_CHARS (KV_LINE_SIZE * 8) +#ifndef WINHTTP_DOWNLOAD_MS +#define WINHTTP_DOWNLOAD_MS 300000 +#endif + +struct winhttp_completion +{ + HANDLE done; + volatile DWORD expected; + DWORD error, bytes; + my_bool callback_set; + LONG detached; + uchar buffer[8192]; +}; + + +static void CALLBACK winhttp_callback(HINTERNET handle, DWORD_PTR context, + DWORD status, void *info, DWORD length) +{ + struct winhttp_completion *state= (struct winhttp_completion *) context; + (void) handle; + if (!state) + return; + if (status == WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING) + { + /* WinHTTP promises this is the final callback for the request. */ + if (InterlockedCompareExchange(&state->detached, 0, 0)) + { + CloseHandle(state->done); + free(state); + } + } + else if (status == WINHTTP_CALLBACK_STATUS_REQUEST_ERROR) + { + state->error= ((WINHTTP_ASYNC_RESULT *) info)->dwError; + SetEvent(state->done); + } + else if (status == state->expected) + { + state->bytes= length; + SetEvent(state->done); + } +} + + +/* Closing an async request can return before its final callback arrives. */ +static void winhttp_close_request(HINTERNET *request, + struct winhttp_completion **state) +{ + my_bool callback_set= *state && (*state)->callback_set; + if (callback_set) + InterlockedExchange(&(*state)->detached, 1); + if (*request) + { + WinHttpCloseHandle(*request); + *request= NULL; + } + if (*state && !callback_set) + { + if ((*state)->done) + CloseHandle((*state)->done); + free(*state); + } + *state= NULL; +} + + +/* Each wait uses one deadline, even when a server keeps sending small chunks. */ +static int winhttp_wait(struct winhttp_completion *state, ULONGLONG started) +{ + ULONGLONG elapsed= GetTickCount64() - started; + DWORD result; + if (elapsed >= WINHTTP_DOWNLOAD_MS) + { + SetLastError(ERROR_TIMEOUT); + return 1; + } + result= WaitForSingleObject(state->done, (DWORD) (WINHTTP_DOWNLOAD_MS - elapsed)); + if (result == WAIT_OBJECT_0) + { + if (!state->error) + return 0; + SetLastError(state->error); + return 1; + } + if (result == WAIT_TIMEOUT) + SetLastError(ERROR_TIMEOUT); + return 1; +} + + +/* Phase limits also provide meaningful network errors before the deadline. */ +static int winhttp_set_deadline(HINTERNET request, ULONGLONG started) +{ + ULONGLONG elapsed= GetTickCount64() - started; + int remaining, connect, transfer; + + if (elapsed >= WINHTTP_DOWNLOAD_MS) + { + SetLastError(ERROR_TIMEOUT); + return 1; + } + remaining= (int) (WINHTTP_DOWNLOAD_MS - elapsed); + connect= MY_MIN(remaining, 10000); + transfer= MY_MIN(remaining, 30000); + return !WinHttpSetTimeouts(request, connect, connect, transfer, transfer); +} + + +static void winhttp_start_wait(struct winhttp_completion *state, DWORD expected) +{ + state->expected= expected; + state->error= 0; + state->bytes= 0; + ResetEvent(state->done); +} + + +/* Fetch into the same private, held stream used by the tarball verifier. */ static FILE *download_file(const char *url, size_t limit) { - (void) url; - (void) limit; - fprintf(stderr, "ERROR: Remote plugin download is not supported on " - "Windows yet.\n"); - return NULL; + WCHAR current[WINHTTP_URL_CHARS], next[WINHTTP_URL_CHARS]; + WCHAR host[WINHTTP_URL_CHARS], object[WINHTTP_URL_CHARS]; + WCHAR location[WINHTTP_URL_CHARS], length_text[40]; + URL_COMPONENTS parts; + HINTERNET session= NULL, connection= NULL, request= NULL; + struct winhttp_completion *completion= NULL; + FILE *output= NULL; + ULONGLONG started= GetTickCount64(); + DWORD status, bytes, policy, redirects= 0, length_size; + DWORD_PTR context; + size_t received= 0, advertised= 0; + my_bool have_length= FALSE, secure, previous_secure= FALSE; + const WCHAR *p; + DWORD error= 0; + + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, url, -1, + current, (int) array_elements(current))) + { + fprintf(stderr, "ERROR: invalid or oversized download URL '%s'.\n", url); + return NULL; + } + session= WinHttpOpen(L"mariadb-plugin/1.0", + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, + WINHTTP_FLAG_ASYNC); + if (!session) + goto failed; + if (!(output= plugin_tmpfile())) + goto failed_file; + + for (;;) + { + DWORD host_length, object_length; + + if (GetTickCount64() - started >= WINHTTP_DOWNLOAD_MS) + { + SetLastError(ERROR_TIMEOUT); + goto failed; + } + for (p= current; *p; p++) + if (*p <= 0x20 || *p == 0x7f || *p == L'\\' || + *p == L'#' || *p == L'@') + goto invalid_url; + memset(&parts, 0, sizeof(parts)); + parts.dwStructSize= sizeof(parts); + parts.dwHostNameLength= (DWORD) -1; + parts.dwUserNameLength= (DWORD) -1; + parts.dwPasswordLength= (DWORD) -1; + parts.dwUrlPathLength= (DWORD) -1; + parts.dwExtraInfoLength= (DWORD) -1; + if (!WinHttpCrackUrl(current, 0, 0, &parts)) + goto invalid_url; + secure= parts.nScheme == INTERNET_SCHEME_HTTPS; + if ((!secure && parts.nScheme != INTERNET_SCHEME_HTTP) || + (previous_secure && !secure) || !parts.dwHostNameLength || + parts.dwUserNameLength || parts.dwPasswordLength) + goto invalid_url; + previous_secure= secure; + host_length= parts.dwHostNameLength; + object_length= (parts.dwUrlPathLength ? parts.dwUrlPathLength : 1) + + parts.dwExtraInfoLength; + if (host_length >= array_elements(host) || + object_length >= array_elements(object)) + goto invalid_url; + memcpy(host, parts.lpszHostName, host_length * sizeof(WCHAR)); + host[host_length]= 0; + if (parts.dwUrlPathLength) + memcpy(object, parts.lpszUrlPath, + parts.dwUrlPathLength * sizeof(WCHAR)); + else + object[0]= L'/'; + if (parts.dwExtraInfoLength) + memcpy(object + (parts.dwUrlPathLength ? parts.dwUrlPathLength : 1), + parts.lpszExtraInfo, + parts.dwExtraInfoLength * sizeof(WCHAR)); + object[object_length]= 0; + + connection= WinHttpConnect(session, host, parts.nPort, 0); + if (!connection) + goto failed; + request= WinHttpOpenRequest(connection, L"GET", + object, NULL, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0); + if (!request) + goto failed; + completion= (struct winhttp_completion *) calloc(1, sizeof(*completion)); + if (!completion) + goto failed_file; + completion->done= CreateEvent(NULL, TRUE, FALSE, NULL); + if (!completion->done) + goto failed; + policy= WINHTTP_OPTION_REDIRECT_POLICY_NEVER; + if (!WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, + &policy, sizeof(policy))) + goto failed; + policy= WINHTTP_DISABLE_AUTHENTICATION | WINHTTP_DISABLE_COOKIES; + if (!WinHttpSetOption(request, WINHTTP_OPTION_DISABLE_FEATURE, + &policy, sizeof(policy))) + goto failed; + policy= WINHTTP_AUTOLOGON_SECURITY_LEVEL_HIGH; + if (!WinHttpSetOption(request, WINHTTP_OPTION_AUTOLOGON_POLICY, + &policy, sizeof(policy))) + goto failed; + context= (DWORD_PTR) completion; + if (!WinHttpSetOption(request, WINHTTP_OPTION_CONTEXT_VALUE, + &context, sizeof(context)) || + WinHttpSetStatusCallback(request, winhttp_callback, + WINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS | WINHTTP_CALLBACK_FLAG_HANDLES, + 0) == WINHTTP_INVALID_STATUS_CALLBACK) + goto failed; + completion->callback_set= TRUE; + + winhttp_start_wait(completion, WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE); + if (winhttp_set_deadline(request, started)) + goto failed; + if (!WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, context) && + GetLastError() != ERROR_IO_PENDING) + goto failed; + if (winhttp_wait(completion, started)) + goto failed; + + winhttp_start_wait(completion, WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE); + if (winhttp_set_deadline(request, started)) + goto failed; + if (!WinHttpReceiveResponse(request, NULL) && + GetLastError() != ERROR_IO_PENDING) + goto failed; + if (winhttp_wait(completion, started)) + goto failed; + bytes= sizeof(status); + if (!WinHttpQueryHeaders(request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &bytes, + WINHTTP_NO_HEADER_INDEX)) + goto failed; + if (status == 301 || status == 302 || status == 303 || + status == 307 || status == 308) + { + DWORD capacity= (DWORD) array_elements(next); + if (redirects == 5) + { + fprintf(stderr, "ERROR: download of '%s' has too many redirects.\n", + url); + goto discard; + } + bytes= sizeof(location); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_LOCATION, + WINHTTP_HEADER_NAME_BY_INDEX, location, &bytes, + WINHTTP_NO_HEADER_INDEX)) + goto failed; + if (FAILED(UrlCombineW(current, location, next, &capacity, 0))) + goto invalid_url; + redirects++; + memcpy(current, next, (wcslen(next) + 1) * sizeof(WCHAR)); + winhttp_close_request(&request, &completion); + WinHttpCloseHandle(connection); + connection= NULL; + continue; + } + if (status != 200) + { + fprintf(stderr, "ERROR: download of '%s' failed (HTTP %lu).\n", + url, (ulong) status); + goto discard; + } + length_size= sizeof(length_text); + if (WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH, + WINHTTP_HEADER_NAME_BY_INDEX, length_text, + &length_size, WINHTTP_NO_HEADER_INDEX)) + { + have_length= TRUE; + for (p= length_text; *p; p++) + { + if (*p < L'0' || *p > L'9' || + (size_t) (*p - L'0') > limit || + advertised > (limit - (size_t) (*p - L'0')) / 10) + { + fprintf(stderr, "ERROR: download of '%s' exceeds size limit.\n", + url); + goto discard; + } + advertised= advertised * 10 + (size_t) (*p - L'0'); + } + } + else if (GetLastError() != ERROR_WINHTTP_HEADER_NOT_FOUND) + goto failed; + + for (;;) + { + winhttp_start_wait(completion, WINHTTP_CALLBACK_STATUS_READ_COMPLETE); + if (winhttp_set_deadline(request, started)) + goto failed; + if (!WinHttpReadData(request, completion->buffer, + sizeof(completion->buffer), NULL) && + GetLastError() != ERROR_IO_PENDING) + goto failed; + if (winhttp_wait(completion, started)) + goto failed; + if (!completion->bytes) + break; + if (completion->bytes > limit - received) + { + fprintf(stderr, "ERROR: download of '%s' exceeds size limit.\n", + url); + goto discard; + } + if (fwrite(completion->buffer, 1, completion->bytes, output) != + completion->bytes) + goto failed_file; + received+= completion->bytes; + } + if (GetTickCount64() - started >= WINHTTP_DOWNLOAD_MS || + (have_length && received != advertised) || fflush(output) || + fseek(output, 0, SEEK_SET)) + { + fprintf(stderr, "ERROR: download of '%s' is incomplete.\n", url); + goto discard; + } + break; + } + goto end; + +invalid_url: + fprintf(stderr, "ERROR: invalid download or redirect URL '%s'.\n", url); + goto discard; +failed: + error= GetLastError(); + fprintf(stderr, "ERROR: cannot download '%s': WinHTTP error %lu.\n", + url, (ulong) error); + goto discard; +failed_file: + fprintf(stderr, "ERROR: cannot write download of '%s': %s.\n", + url, strerror(errno)); +discard: + if (output) + { + my_fclose(output, MYF(0)); + output= NULL; + } +end: + winhttp_close_request(&request, &completion); + if (connection) + WinHttpCloseHandle(connection); + if (session) + WinHttpCloseHandle(session); + return output; } #endif @@ -2874,8 +3357,11 @@ static int read_index_entry(struct index_reader *reader, struct index_entry *e) index_server_series(e->server, &major, &minor)) goto invalid; len= strlen(e->file); - if (len < 7 || strcmp(e->file + len - 7, ".tar.gz") || - !valid_relative_path(e->file)) + /* The index combines platforms, so recognize both archive types here. */ + if (!archive_is_zip(e->file) && + (len < 7 || strcmp(e->file + len - 7, ".tar.gz"))) + goto invalid; + if (!valid_relative_path(e->file)) goto invalid; for (i= 0; i < len; i++) if (!isalnum((uchar) e->file[i]) && e->file[i] != '.' && @@ -2898,6 +3384,10 @@ static int read_index_entry(struct index_reader *reader, struct index_entry *e) static int index_entry_compatible(const struct index_entry *e) { uint major, minor; +#ifndef _WIN32 + if (archive_is_zip(e->file)) + return 0; +#endif return !index_server_series(e->server, &major, &minor) && major == MYSQL_VERSION_ID / 10000 && minor == MYSQL_VERSION_ID / 100 % 100 && @@ -2940,9 +3430,7 @@ static int read_index(FILE *file, const char *source, const char *name, Read and validate all manifest entries before any deletion. @param[in] manifest Path of the manifest file. - @param[in] name Plugin the manifest must belong to, or NULL to skip - the ownership check (rollback of a manifest the tool - has just written itself). + @param[in] name Plugin the manifest must belong to. @param[out] entries Initialized array, filled with manifest_entry. @retval int error = 1, success = 0 @@ -2988,7 +3476,7 @@ static int read_manifest(const char *basedir, const char *manifest, if (!strncmp(line, "name: ", 6)) { /* uninstall may only remove what a manifest of this plugin owns */ - if (name && strcmp(line + 6, name)) + if (strcmp(line + 6, name)) { fprintf(stderr, "ERROR: manifest '%s' belongs to plugin '%s', not " "'%s'; nothing was removed.\n", manifest, line + 6, name); @@ -3023,7 +3511,7 @@ static int read_manifest(const char *basedir, const char *manifest, error= insert_dynamic(entries, &e); } my_fclose(file, MYF(0)); - if (!error && name && !have_name) + if (!error && !have_name) { fprintf(stderr, "ERROR: invalid plugin manifest '%s'.\n", manifest); error= 1; @@ -3161,8 +3649,7 @@ static int search_tarball(const char *basedir) @param[in] basedir The base directory. @param[in] manifest Path of the manifest file. - @param[in] name Plugin the manifest must belong to, or NULL when - rolling back a manifest the tool just wrote. + @param[in] name Plugin the manifest must belong to. @retval int error = 1, success = 0 */ @@ -3273,6 +3760,7 @@ struct tar_entry ulonglong size; uint mode; my_bool is_dir; + my_bool created; /* ownership for rollback, independent of disk */ }; @@ -3768,7 +4256,7 @@ static int manifest_append(FILE *m, const char *key, const char *value) static int tar_extract_file(struct tar_reader *r, struct tar_entry *e, const char *basedir, const char *full, FILE *m, - const char *relpath) + const char *relpath, my_bool *created) { char buf[8192]; ulonglong left= e->size; @@ -3783,11 +4271,11 @@ static int tar_extract_file(struct tar_reader *r, struct tar_entry *e, strerror(my_errno)); return 1; } + *created= TRUE; /* Record ownership before writing contents so a failed write is tracked. */ if (manifest_append(m, "file", relpath)) { my_close(fd, MYF(0)); - plugin_file_op(basedir, relpath, PLUGIN_DELETE, 0); return 1; } while (left && !error) @@ -3871,12 +4359,13 @@ static int tar_extract(const char *file, FILE *contents, const char *basedir, strerror(my_errno)); goto err; } + ok->created= TRUE; if (manifest_append(m, "dir", ok->path)) goto err; } else { - if (tar_extract_file(&r, &e, basedir, full, m, ok->path)) + if (tar_extract_file(&r, &e, basedir, full, m, ok->path, &ok->created)) goto err; } } @@ -3889,6 +4378,471 @@ static int tar_extract(const char *file, FILE *contents, const char *basedir, } +#ifdef _WIN32 +#define ZIP_MAX_ENTRIES 4096 +#define ZIP_MAX_EXPANDED (1024ULL * 1024 * 1024) + +struct zip_reader +{ + FILE *contents; /* owned by do_install, not by MiniZip */ + unzFile zip; + uint64_t size; + int failed; +}; + + +static voidpf ZCALLBACK zip_stream_open(voidpf opaque, const void *name, + int mode) +{ + struct zip_reader *r= (struct zip_reader *) opaque; + (void) name; + if ((mode & ZLIB_FILEFUNC_MODE_READWRITEFILTER) != ZLIB_FILEFUNC_MODE_READ) + return NULL; + clearerr(r->contents); + return r; +} + + +static uLong ZCALLBACK zip_stream_read(voidpf opaque, voidpf stream, + void *buf, uLong size) +{ + struct zip_reader *r= (struct zip_reader *) stream; + size_t n; + (void) opaque; + n= fread(buf, 1, size, r->contents); + if (ferror(r->contents)) + r->failed= 1; + return (uLong) n; +} + + +static uLong ZCALLBACK zip_stream_write(voidpf opaque, voidpf stream, + const void *buf, uLong size) +{ + (void) opaque; + (void) buf; + (void) size; + ((struct zip_reader *) stream)->failed= 1; + return 0; +} + + +static ZPOS64_T ZCALLBACK zip_stream_tell(voidpf opaque, voidpf stream) +{ + __int64 pos= _ftelli64(((struct zip_reader *) stream)->contents); + (void) opaque; + return pos < 0 ? (ZPOS64_T) -1 : (ZPOS64_T) pos; +} + + +static long ZCALLBACK zip_stream_seek(voidpf opaque, voidpf stream, + ZPOS64_T offset, int origin) +{ + struct zip_reader *r= (struct zip_reader *) stream; + uint64_t base= 0; + __int64 pos; + (void) opaque; + if (origin == ZLIB_FILEFUNC_SEEK_CUR) + { + pos= _ftelli64(r->contents); + if (pos < 0) + return -1; + base= (uint64_t) pos; + } + else if (origin == ZLIB_FILEFUNC_SEEK_END) + base= r->size; + else if (origin != ZLIB_FILEFUNC_SEEK_SET) + return -1; + if (base > r->size || offset > r->size - base || + base + offset > INT64_MAX) + return -1; + return _fseeki64(r->contents, (__int64) (base + offset), SEEK_SET); +} + + +static int ZCALLBACK zip_stream_close(voidpf opaque, voidpf stream) +{ + (void) opaque; + (void) stream; + return 0; /* do_install closes the private snapshot */ +} + + +static int ZCALLBACK zip_stream_error(voidpf opaque, voidpf stream) +{ + (void) opaque; + return ((struct zip_reader *) stream)->failed; +} + + +static int zip_open(struct zip_reader *r, const char *file, FILE *contents) +{ + zlib_filefunc64_def io; + __int64 size; + memset(r, 0, sizeof(*r)); + r->contents= contents; + if (_fseeki64(contents, 0, SEEK_END) || + (size= _ftelli64(contents)) < 0 || _fseeki64(contents, 0, SEEK_SET)) + goto error; + r->size= (uint64_t) size; + memset(&io, 0, sizeof(io)); + io.opaque= r; + io.zopen64_file= zip_stream_open; + io.zread_file= zip_stream_read; + io.zwrite_file= zip_stream_write; + io.ztell64_file= zip_stream_tell; + io.zseek64_file= zip_stream_seek; + io.zclose_file= zip_stream_close; + io.zerror_file= zip_stream_error; + r->zip= unzOpen2_64(file, &io); + if (r->zip) + return 0; +error: + fprintf(stderr, "ERROR: cannot read ZIP archive '%s'.\n", file); + return 1; +} + + +static int zip_device_name(const char *part, size_t len) +{ + size_t stem= 0; + while (stem < len && part[stem] != '.') + stem++; + if ((stem == 3 && (!strncasecmp(part, "con", 3) || + !strncasecmp(part, "prn", 3) || + !strncasecmp(part, "aux", 3) || + !strncasecmp(part, "nul", 3))) || + (stem == 4 && (part[3] >= '1' && part[3] <= '9') && + (!strncasecmp(part, "com", 3) || !strncasecmp(part, "lpt", 3)))) + return 1; + return 0; +} + + +/* Accept the simple ASCII names emitted by the Windows plugin package. */ +static int zip_safe_path(const char *path) +{ + const char *part= path, *p; + if (!valid_relative_path(path)) + return 0; + for (p= path; ; p++) + { + char c= *p; + if (c == '/' || c == '\0') + { + size_t len= (size_t) (p - part); + if (!len || (len == 1 && part[0] == '.') || + part[len - 1] == '.' || zip_device_name(part, len)) + return 0; + if (!c) + return 1; + part= p + 1; + } + else if (!((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_' || c == '-' || c == '.')) + return 0; + } +} + + +static uint zip_u16(const uchar *p) +{ + return (uint) p[0] | ((uint) p[1] << 8); +} + + +static uint32_t zip_u32(const uchar *p) +{ + return (uint32_t) zip_u16(p) | ((uint32_t) zip_u16(p + 2) << 16); +} + + +/* MiniZip checks the local method and sizes, but not the local filename. */ +static int zip_local_name_matches(struct zip_reader *r, const char *name, + const unz_file_info64 *info) +{ + uchar central[46], local[30]; + char local_name[FN_REFLEN]; + uint32_t offset; + ZPOS64_T central_offset= unzGetOffset64(r->zip); + if (central_offset > r->size || r->size - central_offset < sizeof(central) || + _fseeki64(r->contents, (__int64) central_offset, SEEK_SET) || + fread(central, 1, sizeof(central), r->contents) != sizeof(central) || + zip_u32(central) != 0x02014b50 || + zip_u16(central + 28) != info->size_filename) + return 0; + offset= zip_u32(central + 42); + if (offset == UINT32_MAX || offset > r->size || + r->size - offset < sizeof(local) + info->size_filename || + _fseeki64(r->contents, (__int64) offset, SEEK_SET) || + fread(local, 1, sizeof(local), r->contents) != sizeof(local) || + zip_u32(local) != 0x04034b50 || + zip_u16(local + 6) != info->flag || + zip_u16(local + 8) != info->compression_method || + zip_u16(local + 26) != info->size_filename || + fread(local_name, 1, info->size_filename, r->contents) != + info->size_filename || + memcmp(local_name, name, info->size_filename)) + return 0; + return 1; +} + + +static int zip_current_entry(struct zip_reader *r, struct tar_entry *e) +{ + unz_file_info64 info; + size_t len; + uint type; + if (unzGetCurrentFileInfo64(r->zip, &info, NULL, 0, NULL, 0, NULL, 0) != UNZ_OK || + !info.size_filename || info.size_filename >= sizeof(e->path) || + info.uncompressed_size > ZIP_MAX_EXPANDED || + (info.compression_method != 0 && info.compression_method != 8) || + (info.flag & ~(8UL | 0x800UL | + (info.compression_method == 8 ? 0x6UL : 0))) || + unzGetCurrentFileInfo64(r->zip, &info, e->path, sizeof(e->path), + NULL, 0, NULL, 0) != UNZ_OK || + strlen(e->path) != info.size_filename || + !zip_local_name_matches(r, e->path, &info)) + return 1; + len= strlen(e->path); + e->is_dir= e->path[len - 1] == '/'; + if (e->is_dir) + e->path[--len]= '\0'; + type= (info.external_fa >> 16) & 0170000; + if ((type && type != (e->is_dir ? 0040000 : 0100000)) || + (e->is_dir && info.uncompressed_size) || !zip_safe_path(e->path)) + return 1; + e->size= info.uncompressed_size; + e->mode= 0; + return 0; +} + + +static int zip_scan(const char *file, FILE *contents, DYNAMIC_ARRAY *entries, + char *topdir) +{ + struct zip_reader r; + unz_global_info64 global; + struct tar_entry e, *p; + const char *base= file + dirname_length(file); + size_t len, i; + ulonglong expanded= 0; + int rc, error= 1; + my_bool have_topdir= TRUE; + if (zip_open(&r, file, contents)) + return 1; + topdir[0]= '\0'; + if (unzGetGlobalInfo64(r.zip, &global) != UNZ_OK || + !global.number_entry || global.number_entry > ZIP_MAX_ENTRIES || + unzGoToFirstFile(r.zip) != UNZ_OK) + goto end; + for (i= 0; i < (size_t) global.number_entry; i++) + { + const char *slash; + if (zip_current_entry(&r, &e) || + e.size > ZIP_MAX_EXPANDED - expanded) + goto end; + expanded+= e.size; + slash= strchr(e.path, '/'); + len= slash ? (size_t) (slash - e.path) : strlen(e.path); + if (!slash && !e.is_dir) + have_topdir= FALSE; + if (!topdir[0]) + safe_strcpy_truncated(topdir, MY_MIN(FN_REFLEN, len + 1), e.path); + else if (strlen(topdir) != len || strncmp(topdir, e.path, len)) + have_topdir= FALSE; + if (insert_dynamic(entries, &e)) + goto end; + rc= unzGoToNextFile(r.zip); + if (i + 1 < (size_t) global.number_entry && rc != UNZ_OK) + goto end; + if (i + 1 == (size_t) global.number_entry && + rc != UNZ_END_OF_LIST_OF_FILE) + goto end; + } + len= strlen(topdir); + if (!have_topdir || strlen(base) != len + 4 || + strncmp(base, topdir, len) || !archive_is_zip(base)) + topdir[0]= '\0'; + else + { + for (i= 0; i < entries->elements; ) + { + p= dynamic_element(entries, i, struct tar_entry *); + if (strlen(p->path) == len) + delete_dynamic_element(entries, i); + else + { + memmove(p->path, p->path + len + 1, strlen(p->path) - len); + i++; + } + } + } + if (!entries->elements) + goto end; + for (i= 0; i < entries->elements; i++) + { + size_t j; + p= dynamic_element(entries, i, struct tar_entry *); + if (!zip_safe_path(p->path)) + goto end; + for (j= 0; j < i; j++) + { + struct tar_entry *prior= dynamic_element(entries, j, struct tar_entry *); + if (!strncasecmp(prior->path, p->path, FN_REFLEN)) + goto end; + } + } + error= r.failed; +end: + if (unzClose(r.zip) != UNZ_OK) + error= 1; + if (error) + fprintf(stderr, "ERROR: '%s' is not a supported or safe ZIP archive.\n", + file); + return error; +} + + +static int zip_extract(const char *file, FILE *contents, const char *basedir, + const char *topdir, DYNAMIC_ARRAY *entries, FILE *m) +{ + struct zip_reader r; + unz_global_info64 global; + size_t i, accepted= 0, skip= topdir[0] ? strlen(topdir) + 1 : 0; + char full[FN_REFLEN], buf[8192]; + int error= 1; + if (zip_open(&r, file, contents)) + return 1; + if (unzGetGlobalInfo64(r.zip, &global) != UNZ_OK || + unzGoToFirstFile(r.zip) != UNZ_OK) + goto end; + for (i= 0; i < (size_t) global.number_entry; i++) + { + struct tar_entry e, *ok; + int rc; + if (zip_current_entry(&r, &e)) + goto end; + if (topdir[0] && !strcmp(e.path, topdir)) + goto next; + if (accepted == entries->elements || + strlen(e.path) < skip || + strcmp(e.path + skip, + (ok= dynamic_element(entries, accepted, struct tar_entry *))->path) || + e.is_dir != ok->is_dir || e.size != ok->size || + build_full_path(full, sizeof(full), basedir, ok->path)) + goto end; + accepted++; + if (e.is_dir) + { + if (file_exists(full)) + { + if (plugin_file_op(basedir, ok->path, PLUGIN_CHECK_DIR, 0)) + goto end; + } + else + { + if (plugin_file_op(basedir, ok->path, PLUGIN_MKDIR, 0)) + goto end; + ok->created= TRUE; + if (manifest_append(m, "dir", ok->path)) + goto end; + } + } + else + { + File fd= plugin_file_op(basedir, ok->path, PLUGIN_OPEN, + O_WRONLY | O_CREAT | O_EXCL | O_BINARY); + ulonglong left= e.size; + int read_rc= 0, close_rc; + if (fd < 0) + goto end; + ok->created= TRUE; + if (manifest_append(m, "file", ok->path)) + { + my_close(fd, MYF(0)); + goto end; + } + if (unzOpenCurrentFile(r.zip) != UNZ_OK) + { + my_close(fd, MYF(0)); + goto end; + } + while (left && (read_rc= unzReadCurrentFile(r.zip, buf, + (unsigned) MY_MIN(left, sizeof(buf)))) > 0) + { + if (my_write(fd, (uchar *) buf, (size_t) read_rc, + MYF(MY_WME | MY_NABP))) + break; + left-= (unsigned) read_rc; + } + close_rc= unzCloseCurrentFile(r.zip); + if (my_close(fd, MYF(MY_WME)) || left || read_rc < 0 || + close_rc != UNZ_OK || r.failed) + goto end; + } +next: + rc= unzGoToNextFile(r.zip); + if (i + 1 < (size_t) global.number_entry && rc != UNZ_OK) + goto end; + if (i + 1 == (size_t) global.number_entry && + rc != UNZ_END_OF_LIST_OF_FILE) + goto end; + } + if (accepted == entries->elements && !r.failed) + error= 0; +end: + if (unzClose(r.zip) != UNZ_OK) + error= 1; + if (error) + fprintf(stderr, "ERROR: extraction of ZIP archive '%s' failed.\n", file); + return error; +} +#endif /* _WIN32 */ + + +/* Never reread a journal that may have ended in a partial write. */ +static void rollback_install(const char *basedir, const char *manifest, + DYNAMIC_ARRAY *entries) +{ + size_t i; + int failed= 0; + /* Parents are created before children, so remove in reverse order. */ + for (i= entries->elements; i-- > 0; ) + { + struct tar_entry *e= dynamic_element(entries, i, struct tar_entry *); + if (!e->created) + continue; + if (plugin_file_op(basedir, e->path, + e->is_dir ? PLUGIN_RMDIR : PLUGIN_DELETE, 0) && + my_errno != ENOENT) + { + fprintf(stderr, "ERROR: rollback could not remove '%s/%s': %s.\n", + basedir, e->path, strerror(my_errno)); + failed= 1; + } + else + e->created= FALSE; + } + if (failed) + { + fprintf(stderr, "ERROR: rollback is incomplete; retained '%s', which " + "may need repair before uninstall can be retried.\n", manifest); + return; + } + if (plugin_file_op(basedir, manifest + strlen(basedir) + 1, + PLUGIN_DELETE, 0)) + { + fprintf(stderr, "ERROR: cannot remove failed-install manifest '%s': %s.\n", + manifest, strerror(my_errno)); + return; + } + plugin_file_op(basedir, MANIFEST_SUBDIR, PLUGIN_RMDIR, 0); +} + + /** Tell the user how to enable what was just installed. Install never edits the server configuration: a tarball installation has no conf.d @@ -3899,7 +4853,7 @@ static void print_enable_instructions(const char *basedir, DYNAMIC_ARRAY *entries) { size_t i, prefix= sizeof(STR(INSTALL_PLUGINDIR)) - 1; - int pass, shown= 0; + int pass, shown= 0, unsafe= 0; /* two passes: the INSTALL SONAME lines, then the plugin-load-add lines */ for (pass= 0; pass < 2; pass++) @@ -3907,17 +4861,35 @@ static void print_enable_instructions(const char *basedir, for (i= 0; i < entries->elements; i++) { struct tar_entry *e= dynamic_element(entries, i, struct tar_entry *); - const char *name= e->path + prefix + 1, *ext; + const char *name, *p; + size_t name_len, ext_len= sizeof(SO_EXT) - 1; if (e->is_dir || strncmp(e->path, STR(INSTALL_PLUGINDIR), prefix) != 0 || - e->path[prefix] != '/' || strchr(name, '/') || - !(ext= strstr(name, SO_EXT))) + e->path[prefix] != '/') + continue; + name= e->path + prefix + 1; + if (strchr(name, '/')) + continue; + name_len= strlen(name); + if (name_len <= ext_len || strcmp(name + name_len - ext_len, SO_EXT)) continue; + for (p= name; p < name + name_len - ext_len; p++) + if (!(*p >= 'a' && *p <= 'z') && + !(*p >= 'A' && *p <= 'Z') && + !(*p >= '0' && *p <= '9') && *p != '_' && *p != '-') + break; + if (p != name + name_len - ext_len) + { + if (pass == 0) + unsafe++; + continue; + } if (pass == 0) { if (!shown++) printf("To enable it, either run in the server:\n"); - printf(" INSTALL SONAME '%.*s';\n", (int) (ext - name), name); + printf(" INSTALL SONAME '%.*s';\n", + (int) (name_len - ext_len), name); } else printf(" plugin-load-add=%s\n", name); @@ -3926,7 +4898,10 @@ static void print_enable_instructions(const char *basedir, printf("or add to your server configuration and restart:\n" " [mariadb]\n"); } - if (!shown) + if (unsafe) + printf("Some plugin library names need manual activation; no command " + "was printed for them.\n"); + if (!shown && !unsafe) printf("No plugin library was found under %s/%s; nothing to enable.\n", basedir, STR(INSTALL_PLUGINDIR)); } @@ -3984,6 +4959,9 @@ static int do_install(const char *name, const char *basedir) const char *archive= opt_file, *source= opt_file, *expected= opt_sha256; size_t i; int error= 1; +#ifdef _WIN32 + my_bool zip_archive; +#endif if ((opt_sha256 && !opt_file) || (opt_base_url && opt_file)) { @@ -4026,6 +5004,9 @@ static int do_install(const char *name, const char *basedir) } if (!contents && !(contents= copy_local_archive(archive))) return 1; +#ifdef _WIN32 + zip_archive= archive_is_zip(archive); +#endif if (expected) { if (verify_sha256(archive, contents, expected, sha256)) @@ -4040,9 +5021,19 @@ static int do_install(const char *name, const char *basedir) if (my_init_dynamic_array(PSI_NOT_INSTRUMENTED, &entries, sizeof(struct tar_entry), 64, 64, MYF(MY_WME))) goto close_archive; - if (tar_scan(archive, contents, &entries, topdir)) + if ( +#ifdef _WIN32 + (zip_archive ? zip_scan(archive, contents, &entries, topdir) : + tar_scan(archive, contents, &entries, topdir)) +#else + tar_scan(archive, contents, &entries, topdir) +#endif + ) goto end; + for (i= 0; i < entries.elements; i++) + dynamic_element(&entries, i, struct tar_entry *)->created= FALSE; + /* Everything is judged before anything is written: a file that already exists is refused, as it belongs to the server or to another plugin. @@ -4050,14 +5041,21 @@ static int do_install(const char *name, const char *basedir) for (i= 0; i < entries.elements; i++) { e= dynamic_element(&entries, i, struct tar_entry *); - if (!strncmp(e->path, MANIFEST_SUBDIR, sizeof(MANIFEST_SUBDIR) - 1) && - (e->path[sizeof(MANIFEST_SUBDIR) - 1] == '/' || - e->path[sizeof(MANIFEST_SUBDIR) - 1] == '\0')) + if (reserved_manifest_path(e->path)) { fprintf(stderr, "ERROR: archive entry '%s' uses the reserved manifest " "directory.\n", e->path); goto end; } +#ifdef _WIN32 + /* Preview must reject aliases of an already existing manifest directory. */ + if (archive_targets_manifest(basedir, e->path, TRUE)) + { + fprintf(stderr, "ERROR: archive entry '%s' may target the reserved " + "manifest directory.\n", e->path); + goto end; + } +#endif if (build_full_path(full, sizeof(full), basedir, e->path)) goto end; if (!e->is_dir && file_exists(full)) @@ -4085,8 +5083,9 @@ static int do_install(const char *name, const char *basedir) } /* - Write ownership records during extraction so ordinary failures can use - the same removal logic as uninstall. This is not a crash-atomic journal. + Persist ownership for uninstall. Rollback uses the created flags in + memory because a write failure can leave a partial manifest record. + This is not a crash-atomic journal. */ if (build_full_path(full, sizeof(full), basedir, MANIFEST_SUBDIR)) goto end; @@ -4126,9 +5125,34 @@ static int do_install(const char *name, const char *basedir) (topdir[0] && manifest_append(m, "topdir", topdir))) goto rollback; } - if (tar_extract(archive, contents, basedir, topdir, &entries, m)) +#ifdef _WIN32 + for (i= 0; i < entries.elements; i++) + { + e= dynamic_element(&entries, i, struct tar_entry *); + if (archive_targets_manifest(basedir, e->path, FALSE)) + { + fprintf(stderr, "ERROR: archive entry '%s' may target the reserved " + "manifest directory.\n", e->path); + goto rollback; + } + } +#endif + if ( +#ifdef _WIN32 + (zip_archive ? zip_extract(archive, contents, basedir, topdir, + &entries, m) : + tar_extract(archive, contents, basedir, topdir, + &entries, m)) +#else + tar_extract(archive, contents, basedir, topdir, &entries, m) +#endif + ) + goto rollback; + if (my_fclose(m, MYF(MY_WME))) + { + m= 0; goto rollback; - my_fclose(m, MYF(0)); + } m= 0; printf("Plugin '%s' installed into %s.\n", name, basedir); @@ -4141,8 +5165,7 @@ static int do_install(const char *name, const char *basedir) my_fclose(m, MYF(0)); fprintf(stderr, "ERROR: installation of '%s' failed, removing what was " "written.\n", name); - /* the manifest was just written by this tool and may be incomplete */ - manifest_remove(basedir, manifest, 0); + rollback_install(basedir, manifest, &entries); end: delete_dynamic(&entries); close_archive: @@ -4348,26 +5371,6 @@ static int run_new_command(int argc, char **argv) } #endif -#if !defined(HAVE_LIBCURL) && !defined(PKG_DELEGATION) - /* - Tarball builds without libcurl are the Windows builds: they have no - downloader, so the repository operations are refused before any path is - resolved or any file is touched. A local archive (--file) stays usable. - */ - if (is_search) - { - fprintf(stderr, "ERROR: Remote plugin search is not supported on " - "Windows yet.\n"); - return 1; - } - if (!opt_file && strcmp(verb, "install") == 0) - { - fprintf(stderr, "ERROR: Remote plugin installation is not supported on " - "Windows yet.\n"); - return 1; - } -#endif - if (detect_install_method(basedir, sizeof(basedir))) return 1; diff --git a/man/mariadb-plugin.1 b/man/mariadb-plugin.1 index 4c70f3c16979f..82200862bfd83 100644 --- a/man/mariadb-plugin.1 +++ b/man/mariadb-plugin.1 @@ -74,7 +74,10 @@ under \fI/.mariadb\-plugin/\fR, and prints instructions for enabling the plugin\&. It does not edit server configuration\&. Uninstall uses the recorded paths and does not need network access\&. Before removing files, uninstall validates the manifest's plugin name and paths. An invalid, -unreadable or non-regular manifest causes no deletion. If a file cannot be +unreadable, incomplete or non-regular manifest causes no deletion. Manifest +records must end with a newline. During a failed install, rollback uses +the paths recorded in memory, so a partial manifest write is never used +as a deletion path. If a file cannot be removed, the manifest is kept so uninstall can be retried\&. .PP The deprecated @@ -86,6 +89,10 @@ INSTALL SONAME statement or \fB\-\-plugin\-load\-add\fR option\&. +For library filenames that cannot be represented safely in these commands, +\fBmariadb-plugin\fR prints a warning and leaves activation to the user\&. +The \fB\-\-file\fR, \fB\-\-sha256\fR and \fB\-\-base\-url\fR +options are rejected with the deprecated syntax\&. .PP An error is given if \fBmariadb-plugin\fR @@ -134,7 +141,10 @@ Bundled libraries without a manifest are shown as available when listed in the repository\&. Plugins absent from the index are omitted from search but can still be uninstalled using their local manifests\&. .PP -Windows ZIP packages are not implemented yet\&. +Windows uses WinHTTP for repository access and accepts \fB.zip\fR packages\&. +It also accepts \fB.tar.gz\fR archives for compatibility\&. ZIP entries are +limited to regular files and directories with simple ASCII paths; encrypted +entries, links and unsupported compression methods are rejected\&. .SH "TARBALL REPOSITORIES" .PP \fB\-\-base\-url\fR specifies an HTTP or HTTPS directory containing @@ -184,11 +194,14 @@ adds \fBfile\fR and \fBsha256\fR after creating the archive and combines the documents into \fBplugins.index\fR\&. Local uninstall manifests retain their existing format\&. .PP -Archive filenames must end in \fB.tar.gz\fR and contain only letters, +Archive filenames must end in \fB.tar.gz\fR (or \fB.zip\fR on Windows) +and contain only letters, digits, dots, underscores and hyphens, without consecutive dots\&. They cannot contain directory paths or URLs\&. Keep the original CPack filename so the installer can recognize the archive's wrapper directory\&. The repository index is limited to 8 MiB and each downloaded archive to 1 GiB\&. +ZIP extraction also limits the entry count to 4096 and the total expanded +contents to 1 GiB\&. .SH "DEPRECATED SYNTAX" .PP The former syntax diff --git a/mysql-test/main/mariadb-plugin-win.result b/mysql-test/main/mariadb-plugin-win.result index ffd796a6ce823..a9de36e8cf1a3 100644 --- a/mysql-test/main/mariadb-plugin-win.result +++ b/mysql-test/main/mariadb-plugin-win.result @@ -1,11 +1,11 @@ # -# Remote installation is refused, with or without --dry-run/--base-url +# Missing operands are rejected before installation detection # -ERROR: Remote plugin installation is not supported on Windows yet. -ERROR: Remote plugin installation is not supported on Windows yet. -ERROR: Remote plugin installation is not supported on Windows yet. +ERROR: 'install' requires exactly one plugin name. +ERROR: 'uninstall' requires exactly one plugin name. +ERROR: 'search' takes at most one search term. # -# Remote search is refused +# Invalid options are rejected before network or file access # -ERROR: Remote plugin search is not supported on Windows yet. -ERROR: Remote plugin search is not supported on Windows yet. +ERROR: --file and --sha256 are only supported with install. +ERROR: uninstall does not support --file, --sha256 or --base-url. diff --git a/mysql-test/main/mariadb-plugin-win.test b/mysql-test/main/mariadb-plugin-win.test index 70e6513f28bd4..8352ba8906cfe 100644 --- a/mysql-test/main/mariadb-plugin-win.test +++ b/mysql-test/main/mariadb-plugin-win.test @@ -1,25 +1,23 @@ # -# MDEV-40786: Windows builds are not linked with libcurl, so the operations -# that reach a remote repository must fail with a clear message and a -# non-zero exit status, before any path is resolved or any file is touched. -# Installations from a local archive are still possible. +# MDEV-40786: CLI validation on Windows. Network behavior is exercised by +# the disposable WinHTTP helper, not by this offline MTR test. # --source include/windows.inc --echo # ---echo # Remote installation is refused, with or without --dry-run/--base-url +--echo # Missing operands are rejected before installation detection --echo # --error 1 ---exec $MYSQL_PLUGIN install provider_example 2>&1 > /dev/null +--exec $MYSQL_PLUGIN install 2>&1 > /dev/null --error 1 ---exec $MYSQL_PLUGIN install provider_example --dry-run 2>&1 > /dev/null +--exec $MYSQL_PLUGIN uninstall 2>&1 > /dev/null --error 1 ---exec $MYSQL_PLUGIN install provider_example --base-url=https://example.invalid/plugins/ 2>&1 > /dev/null +--exec $MYSQL_PLUGIN search one two 2>&1 > /dev/null --echo # ---echo # Remote search is refused +--echo # Invalid options are rejected before network or file access --echo # --error 1 ---exec $MYSQL_PLUGIN search 2>&1 > /dev/null +--exec $MYSQL_PLUGIN search --file=missing.tar.gz 2>&1 > /dev/null --error 1 ---exec $MYSQL_PLUGIN search provider 2>&1 > /dev/null +--exec $MYSQL_PLUGIN uninstall provider --base-url=https://example.invalid/ 2>&1 > /dev/null diff --git a/mysql-test/main/mariadb-plugin.result b/mysql-test/main/mariadb-plugin.result index 1043ff7128a08..f22fadcbe23f0 100644 --- a/mysql-test/main/mariadb-plugin.result +++ b/mysql-test/main/mariadb-plugin.result @@ -60,3 +60,9 @@ ERROR: 'install' requires exactly one plugin name. # ERROR: --dry-run is not supported with ENABLE/DISABLE. ERROR: --dry-run is not supported with ENABLE/DISABLE. +# +# Legacy syntax rejects archive options rather than ignoring them +# +ERROR: --file, --sha256 and --base-url are not supported with ENABLE/DISABLE. +ERROR: --file, --sha256 and --base-url are not supported with ENABLE/DISABLE. +ERROR: --file, --sha256 and --base-url are not supported with ENABLE/DISABLE. diff --git a/mysql-test/main/mariadb-plugin.test b/mysql-test/main/mariadb-plugin.test index 05c69e095b16d..fb9198e77d150 100644 --- a/mysql-test/main/mariadb-plugin.test +++ b/mysql-test/main/mariadb-plugin.test @@ -142,3 +142,16 @@ --replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// --error 1 --exec $MYSQL_PLUGIN --no-defaults --dry-run foo DISABLE 2>&1 > /dev/null + +--echo # +--echo # Legacy syntax rejects archive options rather than ignoring them +--echo # +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --file=missing.tar.gz foo ENABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --sha256=invalid foo DISABLE 2>&1 > /dev/null +--replace_regex /[^\n]*libcurl\.so\.4: no version information available \(required by [^\n]*mariadb-plugin\)\n?// +--error 1 +--exec $MYSQL_PLUGIN --no-defaults --base-url=http://127.0.0.1:1/ foo ENABLE 2>&1 > /dev/null