#!/usr/local/cpanel/3rdparty/bin/perl

#                                      Copyright 2026 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.

package scripts::comet_native_mysql_restore;

use cPstrict;

use Getopt::Long ();

use Cpanel::Logger ();

my $logger;

# A backtick-quoted identifier (capture = the raw name, `` still doubled).
# Declared up here: file-scope lexicals below the `exit run(...)` line are
# never initialized when the script is executed rather than loaded.
my $RX_IDENT    = qr/`((?:[^`]|``)+)`/;
my $RX_IDENT_NC = qr/`(?:[^`]|``)+`/;

# A mysqldump-family SET pragma that assigns a quoted literal to sql_mode:
# (prefix up to the opening quote)(the mode list)(closing quote + comment end).
my $RX_SQL_MODE_SET = qr{\A((?:\s*--[^\n]*\n)*\s*/\*M?!\d{4,6}\s+SET\s[^']*?\bsql_mode\s*=\s*')([^']*)('\s*\*/\s*)\z}is;

sub _logger { return $logger ||= Cpanel::Logger->new(); }

=encoding utf-8

=head1 NAME

comet_native_mysql_restore - Import a Comet native MySQL snapshot stream (CPANEL-54620)

=head1 SYNOPSIS

comet_native_mysql_restore --newuser <cpanel_user> --db <name> [--db <name> ...]

=head1 DESCRIPTION

Restore-side import helper for the Native Account-Data Model
(C<databases_native_mysql> Protected Items). The Comet C<backup-tool> agent
executes this script once per restored C<< <db>.sql >> stream (a
C<RESTORETYPE_PROCESS_PERFILE> restore) and pipes the mysqldump/mariadb-dump
SQL text to its stdin.

The script filters the stream and imports it over cPanel's managed MySQL
admin connection — the same trust model as the legacy
C<restorepkg --from-stdin> database restore. MySQL credentials never appear
in the Comet restore request or on the command line.

Filtering, per the FACT-0019 spike findings (raw replay of DEFINER clauses
requires ALL PRIVILEGES precisely because dumps can carry cross-user
DEFINERs):

=over 4

=item * Every C<DEFINER=`x`@`y`> clause on a DDL line is REWRITTEN to
C<DEFINER=`<newuser>`@`localhost`> — the restore target owns every object it
restores. A bare strip would instead make the importing account the definer
of C<SQL SECURITY DEFINER> objects, which is an escalation hazard.

=item * C<USE> and C<CREATE DATABASE> statements are validated against the
authorized C<--db> list (which mirrors the fail-closed restore authorization
in C<ProtectedItemRestore>). A stream whose C<USE>/C<CREATE DATABASE> names
any other database aborts the import before the offending statement is
executed.

=item * Values the dump carries for B<generated> (C<VIRTUAL>/C<STORED>/
C<PERSISTENT>) columns are replaced with C<DEFAULT> (CPANEL-57349).
mariadb-dump writes the computed value of every generated column into its
C<INSERT>s; MariaDB ignores it under the dump's non-strict C<SQL_MODE>, but
MySQL 8 rejects any value other than C<DEFAULT> (error 3105), which failed
the whole database import. C<DEFAULT> is the one value both engines accept,
and the server recomputes the column either way. See
L</neutralize_generated_column_values>.

=item * A C<SET sql_mode> the target server rejects is re-run without the
modes it does not know, e.g. MariaDB's C<NO_AUTO_CREATE_USER> on MySQL 8
(CPANEL-57398). See L</execute_statement>.

=back

B<Trust model.> This helper imports vault content over cPanel's managed MySQL
admin connection, exactly as the legacy C<restorepkg --from-stdin> database
restore does; that admin-replay-of-vault-content is the security bar, and this
path holds parity with it, no more and no less. The C<USE>/C<CREATE DATABASE>
allowlist and the DEFINER rewrite are B<defense-in-depth>, B<not> a sandbox: a
deliberately tampered stream could still reach other databases through a
cross-database-qualified identifier (C<UPDATE `other`.`t` ...>), just as a
tampered legacy DB tarball could. What the native model changes is blast
radius — one snapshot now holds every tenant's databases — so the fail-closed
per-database authorization in C<ProtectedItemRestore> (which decides B<which>
streams are even fetched) is the real cross-tenant boundary, and this filter
backs it up. A dedicated least-privilege restore user (spike floor P1-R6) is
the planned hardening (waypoint 1b).

The admin handle is obtained with C<mysql_multi_statements> B<disabled>, so a
crafted single line that packs a second statement after an allowed leading
C<USE> (C<USE `ok`; DROP ...>) is rejected by the server rather than executed —
the one-statement-per-C<do()> assumption below then holds.

Statement splitting is C<DELIMITER>-aware, mirroring the parsing Comet's own
engine applies to these dumps (routines/triggers arrive inside DELIMITER
blocks). Only mysqldump-family streams are supported: data lines never embed
raw newlines (mysqldump escapes them), so line-anchored DDL detection is
sound.

Exits non-zero on any validation or import failure so the agent records the
restore as failed.

=cut

exit run(@ARGV) unless caller;

sub run (@argv) {
    my ( $newuser, @dbs );
    Getopt::Long::GetOptionsFromArray(
        \@argv,
        'newuser=s' => \$newuser,
        'db=s@'     => \@dbs,
    ) or return _usage();

    return _usage() unless defined $newuser && @dbs;

    # Fail-closed identity checks. The username becomes a quoted identifier in
    # rewritten DEFINER clauses; the database names bound the import scope.
    if ( !_is_valid_target_user($newuser) ) {
        _logger()->error("comet_native_mysql_restore: refusing import: invalid or unknown target user");
        return 1;
    }
    my %allowed_db;
    for my $db (@dbs) {
        if ( !_is_valid_backed_up_db_name($db) ) {
            _logger()->error("comet_native_mysql_restore: refusing import: database name fails validation");
            return 1;
        }
        $allowed_db{$db} = 1;
    }

    my $dbh = _admin_dbh();

    # $statements counts every executed statement, including mysqldump's own
    # SET-pragma header/footer lines; $meaningful_applied only goes true once
    # something that actually restores content (not just a session-setting
    # pragma) runs. A stream that is empty, comment-only, or truncated right
    # after the header (so nothing is left mid-statement to trip the
    # truncation die below) leaves both false -- that is "nothing was
    # actually applied", not a successful restore.
    my $meaningful_applied = 0;
    my %exec_state         = ( db => undef, dropped_modes => {} );
    my $statements         = eval {
        import_sql_stream(
            \*STDIN,
            sub ($stmt) {
                $meaningful_applied ||= !_is_header_pragma_statement($stmt);
                execute_statement( $dbh, $stmt, \%exec_state );
                return;
            },
            $newuser,
            \%allowed_db,
        );
    };
    if ( my $err = $@ ) {

        # Never echo statement text (it is customer data); the error from DBI
        # already carries the server-side context.
        my $where = defined $exec_state{db} ? " (database '$exec_state{db}')" : '';
        _logger()->error("comet_native_mysql_restore: import failed for user '$newuser'$where: $err");
        return 1;
    }

    if ( !$statements || !$meaningful_applied ) {
        _logger()->error("comet_native_mysql_restore: import failed for user '$newuser': the database dump contained no data (it may be empty or truncated)");
        return 1;
    }

    _logger()->info( sprintf( "comet_native_mysql_restore: imported %d statement(s) for user '%s' (databases: %s)", $statements, $newuser, join( ',', sort keys %allowed_db ) ) );
    return 0;
}

sub _usage {
    print STDERR "usage: comet_native_mysql_restore --newuser <cpanel_user> --db <name> [--db <name> ...]\n";
    return 1;
}

=head1 FUNCTIONS

=head2 import_sql_stream( $in_fh, $exec_cb, $newuser, \%allowed_db )

Reads a mysqldump-family SQL stream from C<$in_fh>, applies the DEFINER
rewrite and database-scope enforcement line-wise, splits it into statements
(honoring C<DELIMITER> changes), and calls C<$exec_cb> with each complete
statement (after L</neutralize_generated_column_values>). Returns the number
of statements executed. Dies on scope violations or when C<$exec_cb> dies.

=cut

sub import_sql_stream ( $in_fh, $exec_cb, $newuser, $allowed_db ) {
    my $delim = ';';
    my @pending;
    my $statements      = 0;
    my %generated_state = ( db => '', tables => {}, warned => {} );    # generated-column rewrite

    while ( my $line = readline $in_fh ) {
        $line =~ s/\r?\n\z//;

        # Client-side DELIMITER directive: switch and swallow (never sent to
        # the server), exactly like the mysql client and Comet's own parser.
        if ( $line =~ m/\ADELIMITER[ \t]+(\S+)/ ) {
            $delim = $1;
            next;
        }

        assert_line_in_database_scope( $line, $allowed_db );
        $line = rewrite_definers_in_line( $line, $newuser );

        push @pending, $line;

        if ( _ends_with( $line, $delim ) ) {
            my $stmt = join( "\n", @pending );
            $stmt    = substr( $stmt, 0, length($stmt) - length($delim) );
            @pending = ();

            # Comment-only / whitespace-only chunks need not hit the server.
            if ( $stmt =~ m/\S/ && $stmt !~ m/\A(?:\s*--[^\n]*\n?)+\z/ ) {
                neutralize_generated_column_values( \$stmt, \%generated_state );
                $exec_cb->($stmt);
                $statements++;
            }
        }
    }

    # Trailing content with no terminating delimiter is comment/whitespace
    # residue in well-formed dumps; anything else is a truncated stream.
    my $residue = join( "\n", @pending );
    if ( $residue =~ m/\S/ && $residue !~ m/\A(?:\s*(?:--[^\n]*)?\n?)+\z/ ) {
        die "SQL stream ended mid-statement (truncated restore stream?)\n";
    }

    return $statements;
}

=head2 execute_statement( $dbh, $stmt, \%state )

Runs one statement on C<$dbh>. C<\%state> is per-stream state, initialised
as C<< { db => undef, dropped_modes => {} } >>; C<db> tracks the last C<USE>
so a failure can name the database.

B<sql_mode values the target does not know (CPANEL-57398).> mysqldump-family
dumps wrap every trigger, routine and event in
C<< /*!50003 SET sql_mode = '<source modes>' */ >>. MariaDB's default
sql_mode includes C<NO_AUTO_CREATE_USER>, which MySQL 8 removed, so the
server rejects that C<SET> (error 1231) and the whole database import used to
fail. When such a C<SET sql_mode> statement fails, each listed mode is tried
on its own and the statement is re-run with only the modes this server
accepts. The target server is the authority: no engine or version list is
kept here. A same-engine import never fails the C<SET>, so it runs exactly as
before. The dropped modes are logged once each. If no mode was rejected (the
failure has another cause), the original error is raised as before; if the
filtered C<SET> still fails, its error is raised.

A failed MariaDB-only C<SEQUENCE> statement gets a message that names it,
because the target engine cannot hold that object at all.

=cut

sub execute_statement ( $dbh, $stmt, $state ) {
    return _note_use( $stmt, $state ) if eval { $dbh->do($stmt); 1 };
    my $err = $@;

    if ( my ( $head, $modes, $tail ) = $stmt =~ $RX_SQL_MODE_SET ) {
        my ( @kept, @dropped );
        for my $mode ( grep { length } split /\s*,\s*/, $modes ) {
            if   ( _server_accepts_sql_mode( $dbh, $mode ) ) { push @kept,    $mode }
            else                                             { push @dropped, $mode }
        }
        die $err unless @dropped;    # not an unknown-mode failure

        $dbh->do( $head . join( ',', @kept ) . $tail );
        for my $mode ( grep { !$state->{dropped_modes}{$_}++ } @dropped ) {
            _logger()->warn("comet_native_mysql_restore: this database server does not support the sql_mode value '$mode' used in the dump; triggers, routines, and events that used it are created without it");
        }
        return;
    }

    if ( $stmt =~ m/^\s*(?:CREATE|DROP)\s+(?:OR\s+REPLACE\s+)?SEQUENCE\b/mi ) {
        die "the dump contains a SEQUENCE object, which exists only on MariaDB and cannot be created on this database server: $err";
    }
    die $err;
}

sub _note_use ( $stmt, $state ) {
    $state->{db} = _unquote_ident($1) if $stmt =~ m/\A(?:\s*--[^\n]*\n)*\s*USE\s+$RX_IDENT/i;
    return;
}

# A mode name is a bare word (never quoted text), so it is safe to inline.
# The probe changes the session sql_mode; the caller's filtered SET that
# follows replaces it.
sub _server_accepts_sql_mode ( $dbh, $mode ) {
    return 0 unless $mode =~ m/\A\w+\z/;
    return eval { $dbh->do("SET SESSION sql_mode = '$mode'"); 1 } ? 1 : 0;
}

=head2 rewrite_definers_in_line( $line, $newuser )

On DDL-context lines only, rewrites every C<DEFINER=user@host> to
C<DEFINER=`$newuser`@`localhost`>. Data lines (C<INSERT ...>) are returned
untouched, so string contents can never be corrupted.

Because a missed DEFINER on a C<SQL SECURITY DEFINER> object is a
privilege-escalation hole (not merely a cosmetic miss), the DDL-context gate
is deliberately broad: any line beginning with C<CREATE>/C<ALTER>
(case-insensitive) or a MySQL C</*!NNNNN> or MariaDB C</*M!NNNNN> versioned
conditional comment. mysqldump and mariadb-dump only ever emit a DEFINER
clause inside one of these forms; INSERT/data lines never begin this way, so
a C<DEFINER=> substring embedded in string data is never rewritten.

=cut

sub rewrite_definers_in_line ( $line, $newuser ) {
    return $line unless $line =~ m{\A\s*(?:/\*M?!\d{4,6}|CREATE\b|ALTER\b)}i;

    $line =~ s{
        DEFINER \s* = \s*
        (?: `[^`]*` | '[^']*' | "[^"]*" | [\w.-]+ )    # user part
        \@
        (?: `[^`]*` | '[^']*' | "[^"]*" | [\w.%-]+ )   # host part
    }{DEFINER=`$newuser`\@`localhost`}xgi;

    return $line;
}

=head2 neutralize_generated_column_values( \$stmt, \%state )

Rewrites C<$stmt> in place so that every value an C<INSERT>/C<REPLACE>
supplies for a generated column becomes C<DEFAULT> (CPANEL-57349).

C<\%state> is per-stream state, initialised as
C<< { db => '', tables => {}, warned => {} } >>. C<USE> sets the current
database. A C<CREATE TABLE> records that table's column layout under
database + table (only when the table has a generated column), and is itself
left unchanged. A later C<INSERT INTO `t` VALUES> or
C<INSERT INTO `t` (`a`, ...) VALUES> for a recorded table has the values at
the generated positions replaced. The other values are kept byte for byte.

Fail-safe: when the statement's shape is not understood (a tuple with the
wrong number of values, an unterminated string, a trailing clause, an
unexpected column list) the statement is left unchanged, which is exactly
the behaviour before this rewrite, and a warning names the table (once per
table, never any row data).

=cut

sub neutralize_generated_column_values ( $sref, $state ) {

    # mysqldump folds the preceding `-- ...` comment banner into the statement.
    $$sref =~ m/\A(?:\s*--[^\n]*\n)*\s*/gc;
    my $keyword_at = pos($$sref) // 0;
    $$sref =~ m/\G(CREATE|USE|INSERT|REPLACE)\b/gci or return;
    my $keyword = uc $1;

    if ( $keyword eq 'USE' ) {
        $state->{db} = _unquote_ident($1) if $$sref =~ m/\G\s+$RX_IDENT/gc;
        return;
    }

    if ( $keyword eq 'CREATE' ) {
        return unless $$sref =~ m/\G\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?$RX_IDENT\s*\(/gci;
        my $key    = "$state->{db}\0" . _unquote_ident($1);
        my $layout = _generated_column_layout( substr( $$sref, $keyword_at ) );
        if ($layout) { $state->{tables}{$key} = $layout }
        else         { delete $state->{tables}{$key} }
        return;
    }

    # INSERT / REPLACE: nothing to do (and nothing copied) unless this stream
    # has a table with a generated column.
    return unless %{ $state->{tables} };
    return unless $$sref =~ m/\G(?:\s+(?:IGNORE|LOW_PRIORITY|DELAYED|HIGH_PRIORITY))*\s+INTO\s+$RX_IDENT/gci;
    my $table  = _unquote_ident($1);
    my $layout = $state->{tables}{"$state->{db}\0$table"} or return;

    my @columns;
    if ( $$sref =~ m/\G\s*\(\s*((?:$RX_IDENT_NC\s*,\s*)*$RX_IDENT_NC)\s*\)\s*VALUES\s*/gc ) {
        @columns = map { _unquote_ident($_) } $1 =~ m/$RX_IDENT/g;
    }
    elsif ( $$sref =~ m/\G\s*VALUES\s*/gci ) {
        @columns = @{ $layout->{positional} };
    }
    else {
        return _warn_unchanged( $state, $table );
    }

    my %replace_idx = map { $_ => 1 } grep { $layout->{generated}{ $columns[$_] } } 0 .. $#columns;
    return unless %replace_idx;    # e.g. a MySQL-source dump, which already omits them

    my $spans = _generated_value_spans( $sref, pos($$sref), scalar(@columns), \%replace_idx ) or return _warn_unchanged( $state, $table );

    # One forward pass: splicing each span in place would re-shift the tail of
    # a ~1 MB extended INSERT once per row (quadratic).
    my ( $out, $at ) = ( '', 0 );
    for my $span (@$spans) {
        $out .= substr( $$sref, $at, $span->[0] - $at ) . 'DEFAULT';
        $at = $span->[0] + $span->[1];
    }
    $$sref = $out . substr( $$sref, $at );
    return;
}

sub _warn_unchanged ( $state, $table ) {
    return if $state->{warned}{"$state->{db}\0$table"}++;
    _logger()->warn("comet_native_mysql_restore: could not locate the generated-column values in an INSERT for table '$table'; importing it unchanged");
    return;
}

# Column layout of a mysqldump-family CREATE TABLE (one column definition per
# line, each starting with its backtick-quoted name). Returns undef unless the
# table has at least one generated column. `positional` is the column order a
# column-list-less INSERT fills (INVISIBLE columns are skipped by the server).
sub _generated_column_layout ($create) {
    my ( @positional, %generated );
    for my $line ( split /\n/, $create ) {
        next unless $line =~ m/\A\s*$RX_IDENT\s+(.*)\z/;
        my ( $name, $definition ) = ( _unquote_ident($1), $2 );

        # Judge the definition with string literals (DEFAULT/COMMENT text) and
        # quoted identifiers (e.g. a column named `invisible`) removed.
        $definition =~ s/'(?:[^'\\]|\\.|'')*'/''/g;
        $definition =~ s/$RX_IDENT_NC/``/g;

        # A generated expression is always parenthesised; this deliberately
        # excludes system-versioning `GENERATED ALWAYS AS ROW START/END`.
        $generated{$name} = 1
          if $definition =~ m/\bGENERATED\s+ALWAYS\s+AS\s*\(/i
          || $definition =~ m/\bAS\s*\(.*\)\s*(?:VIRTUAL|STORED|PERSISTENT)\b/i;
        push @positional, $name unless $definition =~ m/\bINVISIBLE\b/i;
    }
    return undef unless %generated;    # callers test truthiness
    return { positional => \@positional, generated => \%generated };
}

# Scans the VALUES tuples of $$sref from offset $start. Returns [ [offset, length], ... ]
# for every value whose 0-based column index is in \%replace_idx, or undef when
# the text is not a plain list of $ncols-wide tuples. A value is any run of
# quoted strings and bare tokens (numbers, NULL, 0x.., _binary '..', X'..');
# the scan is an iterative tokenizer so long strings never hit regex limits.
sub _generated_value_spans ( $sref, $start, $ncols, $replace_idx ) {
    pos($$sref) = $start;
    my @spans;
    while (1) {
        $$sref =~ m/\G\s*\(/gc or return undef;
        my $col = 0;
        while (1) {
            $$sref =~ m/\G\s*/gc;
            my ( $value_start, $value_end ) = ( pos($$sref), undef );

            # Fast path for the common single-token value (number, NULL, 0x..,
            # or a string with no escapes); anything else takes the loop below.
            my $single_token = $$sref =~ m/\G(?:[^,()'\s]++|'[^'\\]*+')(?=\s*[,)])/gc;
            $value_end = pos($$sref) if $single_token;
            while ( !$single_token ) {
                if ( $$sref =~ m/\G'/gc ) {
                    while (1) {
                        next if $$sref =~ m/\G[^'\\]+/gc;
                        next if $$sref =~ m/\G\\./gcs;
                        next if $$sref =~ m/\G''/gc;
                        last if $$sref =~ m/\G'/gc;
                        return undef;    # unterminated string
                    }
                    $value_end = pos($$sref);
                }
                elsif ( $$sref =~ m/\G[^,()'\s]+/gc ) {
                    $value_end = pos($$sref);
                }
                elsif ( $$sref =~ m/\G\s+(?=[^,()\s])/gc ) {

                    # whitespace inside a value (e.g. `_binary 'x'`)
                }
                else {
                    last;
                }
            }
            return undef unless defined $value_end;    # empty value
            push @spans, [ $value_start, $value_end - $value_start ] if $replace_idx->{$col};
            $col++;
            $$sref =~ m/\G\s*/gc;
            next if $$sref =~ m/\G,/gc;
            last if $$sref =~ m/\G\)/gc;
            return undef;                              # nested paren / junk
        }
        return undef unless $col == $ncols;
        next if $$sref =~ m/\G\s*,/gc;
        last if $$sref =~ m/\G\s*\z/gc;
        return undef;                                  # trailing clause
    }
    return \@spans;
}

sub _unquote_ident ($quoted) {
    ( my $name = $quoted ) =~ s/``/`/g;
    return $name;
}

=head2 assert_line_in_database_scope( $line, \%allowed_db )

Dies (fail-closed) when a line is a C<USE> or C<CREATE DATABASE> statement
naming a database outside the authorized set. mysqldump's C<--databases>
streams contain exactly one of each, naming the dumped database.

=cut

sub assert_line_in_database_scope ( $line, $allowed_db ) {
    my $named;

    # mysqldump/mariadb-dump always backtick-quote the database identifier in
    # USE and CREATE DATABASE (never a bare, unquoted name), and a backtick
    # can never appear inside a legal cPanel database name (see
    # Cpanel::Validate::DB::Name), so matching everything between a pair of
    # backticks is both sufficient and correct for the full range of legal
    # names (hyphens, dots, spaces, etc. included) -- not just [A-Za-z0-9_].
    if ( $line =~ m/\AUSE\s+`([^`]+)`\s*;?/i ) {
        $named = $1;
    }
    elsif ( $line =~ m{\ACREATE\s+DATABASE\s+(?:/\*!\d+\s+IF\s+NOT\s+EXISTS\s*\*/\s*|IF\s+NOT\s+EXISTS\s+)?`([^`]+)`}i ) {
        $named = $1;
    }

    return unless defined $named;
    return if $allowed_db->{$named};

    die "SQL stream references database '$named', which is outside the authorized restore scope (fail-closed)\n";
}

=head2 _is_header_pragma_statement( $stmt )

True when C<$stmt> is nothing but one of mysqldump/mariadb-dump's own
session-setting header/footer lines -- a bare C<SET> wrapped entirely in a
single MySQL/MariaDB versioned conditional comment, e.g.
C<< /*!40101 SET NAMES utf8mb4 */ >> or C<< /*!40103 SET TIME_ZONE=... */ >>.
Every mysqldump-family stream opens and closes with a run of these; none of
them restores any database content, so a dump that executed only these
(CPANEL-56040: an empty, comment-only, or header-truncated stream) must not
be reported as a successful restore even though each C<SET> itself
succeeded.

=cut

sub _is_header_pragma_statement ($stmt) {

    # The stream's very first pragma is often glued, in the same statement,
    # to the whole-line `-- ...` comment banner mysqldump prints ahead of
    # it (those lines never end in the statement delimiter, so the splitter
    # in import_sql_stream folds them into the next statement). Strip that
    # leading comment banner before checking for the pragma shape.
    return $stmt =~ m{\A(?:\s*--[^\n]*\n)*\s*/\*M?!\d{4,6}\s+SET\b.*\*/\s*\z}is ? 1 : 0;
}

sub _ends_with ( $line, $delim ) {
    return length($line) >= length($delim)
      && substr( $line, -length($delim) ) eq $delim;
}

# The target must be an existing cPanel account; its name is embedded in
# quoted DEFINER identifiers. Validation + existence check, both fail-closed.
sub _is_valid_target_user ($newuser) {
    return 0 unless defined $newuser && $newuser =~ m/\A[a-z0-9][a-z0-9_.-]{0,31}\z/;

    require Cpanel::Config::HasCpUserFile;
    return Cpanel::Config::HasCpUserFile::has_cpuser_file($newuser) ? 1 : 0;
}

# The backup side (Whostmgr::CometBackup::NativeMysql::compute_mysql_include_list)
# captures every database Cpanel::DB::Map::Reader reports the account owns --
# i.e. every name that was ever legal enough for cPanel to create, which is
# whatever Cpanel::Validate::DB::Name::verify_mysql_database_name_format
# accepts (hyphens, dots, spaces, etc. -- far broader than [A-Za-z0-9_]).
# Reusing that same core format check (rather than a stricter homegrown
# regex) is what keeps every backed-up database restorable; reserved-name
# checking is deliberately skipped here since we are restoring, not minting,
# a database.
sub _is_valid_backed_up_db_name ($db) {
    return 0 unless defined $db && length $db;

    require Cpanel::Validate::DB::Name;
    return eval { Cpanel::Validate::DB::Name::verify_mysql_database_name_format($db); 1 } ? 1 : 0;
}

# Managed cPanel MySQL admin connection — the same connector the plugin uses
# to provision the backup user and re-apply grants. Isolated for mocking.
#
# mysql_multi_statements is DISABLED for the import handle: the default cPanel
# admin handle enables it, which would let a crafted line pack a second
# statement after an allowed leading USE ("USE `ok`; DROP ...") past the
# per-line scope check. With it off, the server rejects any such line, so each
# do() below executes exactly one statement.
sub _admin_dbh {
    require Cpanel::MysqlUtils::Connect;
    return Cpanel::MysqlUtils::Connect::get_dbi_handle( extra_args => { mysql_multi_statements => 0 } );
}

1;
