Your IP : 216.73.217.94


Current Path : /home2/noxxprod/www/c52d7/
Upload File :
Current File : //home2/noxxprod/www/c52d7/l.v.e-manager.tar

cl.nodejs/yum_cache.dat000064400000000214152725704130011055 0ustar00alt-nodejs6
alt-nodejs8
alt-nodejs9
alt-nodejs10
alt-nodejs11
alt-nodejs12
alt-nodejs14
alt-nodejs16
alt-nodejs18
alt-nodejs19
alt-nodejs20
cl.nodejs/selector.json000064400000000075152725704130011146 0ustar00{
    "default_version": "20",
    "selector_enabled": true
}utils/activate000075500000004461152725704130007445 0ustar00#!/bin/bash

if [[ x"${BASH_SOURCE[0]}" == x"$0" ]]; then
    echo "'activate' script should be sourced, not run directly"
    exit 1;
fi

CWD=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)
NEW_VIRTUAL_ENV_PATH="${CWD%/bin}"

deactivate () {
    if [[ x"${BASH_SOURCE[0]}" == x"$0" ]]; then
        echo "'deactivate' script should be sourced, not run directly"
        exit 1;
    fi

    # Only restore from backup variables if they are set
    # But include the case when they are set to be empty
    if [[ ${BKP_PATH+"is_set"} == "is_set" ]]; then
        PATH="$BKP_PATH"
        export PATH
    fi

    if [[ ${BKP_NODE_PATH+"is_set"} == "is_set" ]]; then
        NODE_PATH="$BKP_NODE_PATH"
        export NODE_PATH
    fi

    if [[ ! -z $BKP_PS1 ]]; then
        PS1="$BKP_PS1"
        export PS1
    fi

    unset -v BKP_PATH
    unset -v BKP_NODE_PATH
    unset -v BKP_PS1
    unset -v CL_VIRTUAL_ENV
    unset -v CL_APP_ROOT
    unset -v CL_NODEHOME
    unset -v CL_NODEJS_VERSION

    if [ ! "${1-}" = "nondestructive" ] ; then
    # Self destruct!
        unset -f deactivate
    fi
}

activate () {
    CL_VIRTUAL_ENV="${NEW_VIRTUAL_ENV_PATH}"
    CL_NODEJS_VERSION="$(echo "${CL_VIRTUAL_ENV}" | awk -F '/' '{print $NF}')"
    CL_APP_ROOT="${CL_VIRTUAL_ENV#$HOME/nodevenv/}" # cut $HOME/nodevenv/
    CL_APP_ROOT="${CL_APP_ROOT%/$CL_NODEJS_VERSION}" # cut nodejs version
    CL_NODEHOME="/opt/alt/alt-nodejs${CL_NODEJS_VERSION}/root"
    BKP_NODE_PATH="$NODE_PATH"
    NODE_PATH="$CL_VIRTUAL_ENV/lib/node_modules:$CL_NODEHOME/lib/node_modules:$CL_NODEHOME/lib64/node_modules:$NODE_PATH"
    BKP_PATH="$PATH"
    PATH="$CL_VIRTUAL_ENV/bin:$CL_NODEHOME/usr/bin:$CL_VIRTUAL_ENV/lib/bin/:$PATH"

    if [[ -z "$CL_VIRTUAL_ENV_DISABLE_PROMPT" ]] ; then
        BKP_PS1="$PS1"
        PS1="[${CL_APP_ROOT} ($CL_NODEJS_VERSION)] $PS1"
    fi

    export BKP_PS1
    export BKP_PATH
    export BKP_NODE_PATH
    export PS1
    export CL_VIRTUAL_ENV
    export CL_APP_ROOT
    export CL_NODEHOME
    export NODE_PATH
    export PATH
}

# compare current virtual environment (that is stored in CL_VIRTUAL_ENV) path
# to the NEW_VIRTUAL_ENV_PATH, that is the path of the new environment we may enter too
# just do nothing if paths are equal
if [ "${CL_VIRTUAL_ENV}" != "${NEW_VIRTUAL_ENV_PATH}" ]; then
    deactivate nondestructive
    activate
fi
utils/cloudlinux_bun_client.py000075500000017575152725704130012676 0ustar00# coding:utf-8

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2026 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

"""Account-side client for the bun delivery socket.

Runs at the account's uid, inside its cage. It says one thing -- "deliver
the modules for this application" -- and reports the verdict. It cannot say
who it is: the kernel tells the daemon that over SO_PEERCRED, which is why
nothing here is worth forging.

The account-side Selector also uses it once, after a Selector install it
drove failed its restart/health check: `unhealthy` reports that, through
the same application lock, so the daemon can count it as broken.

Exit codes are the contract with npm_wrapper:

    0  delivered (for `unhealthy`: recorded)
    1  declined, for a reason the daemon named (printed to stderr)
    2  the daemon is unreachable
    3  classic npm: administrator policy (disabled or excluded), or, on
       `deliver`, a dependency source the Store does not serve
       (unsupported_source, registry_not_allowed), an engine limit
       (lockfile_too_large, too_many_packages) or an `npm ci` it cannot
       install exactly (lockfile_mismatch)

Only 3 selects classic npm, and only for those reasons. For a classic
`deliver` fallback the reason is the one line printed on stdout. Nothing retries
after an operational or delivery failure: a silent fallback is what let two
delivery defects sit behind healthy-looking numbers during this feature's
development (see *No fallback* in the design spec).
"""

from __future__ import absolute_import

import json
import os
import re
import socket
import struct
import sys

SOCKET_PATH = '/opt/cl-bun/run/delivery.sock'

# Long enough for a cold cache to fetch a large tree, short enough that a
# wedged daemon does not hold a terminal indefinitely.
# A client can wait in the bounded daemon queue before the Bun install and
# account-side rebuild begin; leave margin for socket and hand-over work too.
TIMEOUT = 2100


_MAJOR = re.compile(r'^[0-9]{1,3}$')


def shell_major(environ=os.environ):
    """The venv the shell is in, from CL_VIRTUAL_ENV, or None.

    npm_wrapper sources the venv's own `activate` before any phase runs,
    so this names the venv the account is actually working in -- which a
    version change may have left beside a newer one the selector records.
    The root half has to deliver where the user half resolved and linked,
    so the request carries it. Anything but a small number is left unsaid
    and the daemon falls back to the record."""
    venv = environ.get('CL_VIRTUAL_ENV', '')
    name = os.path.basename(venv)
    if venv and '/' in venv and _MAJOR.match(name) and 1 <= int(name) <= 999:
        return int(name)
    return None


def request(command, app_root, path=SOCKET_PATH, timeout=TIMEOUT, major=None,
            lock_fd=None, via=None, kind=None):
    """Returns the daemon's verdict as a dict, or raises OSError."""
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        sock.connect(path)
        request = {'cmd': command, 'app_root': app_root}
        if major is not None:
            request['major'] = major
        if via is not None:
            request['via'] = via
        if kind is not None:
            request['kind'] = kind
        payload = json.dumps(request)
        message = (payload + '\n').encode('utf-8')
        if lock_fd is None:
            sock.sendall(message)
        else:
            sent = sock.sendmsg([message], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
                                             struct.pack('i', lock_fd))])
            if sent <= 0:
                raise OSError('could not send delivery lock descriptor')
            if sent < len(message):
                sock.sendall(message[sent:])
        chunks = []
        while True:
            chunk = sock.recv(65536)
            if not chunk:
                break
            chunks.append(chunk)
            if b'\n' in chunk:
                break
    finally:
        sock.close()
    raw = b''.join(chunks).split(b'\n', 1)[0]
    if not raw:
        raise OSError('empty reply from delivery service')
    return json.loads(raw.decode('utf-8'))


def request_delivery(app_root, path=SOCKET_PATH, timeout=TIMEOUT, major=None,
                     lock_fd=None, via=None, kind=None):
    return request('deliver', app_root, path, timeout, major, lock_fd, via,
                   kind)


def request_policy(app_root, path=SOCKET_PATH, timeout=TIMEOUT, major=None,
                   lock_fd=None):
    return request('policy', app_root, path, timeout, major, lock_fd)


def request_unshare(app_root, path=SOCKET_PATH, timeout=TIMEOUT, major=None,
                    lock_fd=None):
    return request('unshare', app_root, path, timeout, major, lock_fd)


def request_unhealthy(app_root, path=SOCKET_PATH, timeout=TIMEOUT, major=None,
                      lock_fd=None):
    return request('unhealthy', app_root, path, timeout, major, lock_fd)


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    if len(argv) == 1:
        command, app_root = 'deliver', argv[0]
    elif len(argv) == 2 and argv[0] in ('policy', 'unshare', 'unhealthy'):
        command, app_root = argv
    else:
        sys.stderr.write('usage: cloudlinux-bun-client '
                         '[policy|unshare|unhealthy] <app-root>\n')
        return 2
    try:
        raw_lock_fd = os.environ.get('CL_BUN_LOCK_FD')
        lock_fd = int(raw_lock_fd) if raw_lock_fd and raw_lock_fd.isdigit() else None
        request_fn = {
            'deliver': request_delivery,
            'policy': request_policy,
            'unshare': request_unshare,
            'unhealthy': request_unhealthy,
        }[command]
        kwargs = {'major': shell_major(), 'lock_fd': lock_fd}
        # npm_wrapper sets this only after validating the Selector's app lock
        # descriptor: the install is Selector-driven, counted as `panel`.
        if command == 'deliver' and os.environ.get('CL_BUN_VIA') == 'selector':
            kwargs['via'] = 'selector'
        # npm_wrapper sets this for a bare `npm ci` it routes to the Store.
        if command == 'deliver' and os.environ.get('CL_BUN_COMMAND') == 'ci':
            kwargs['kind'] = 'ci'
        verdict = request_fn(app_root, **kwargs)
    except (OSError, ValueError) as exc:
        sys.stderr.write('bun delivery unavailable: %s\n' % exc)
        return 2
    if verdict.get('status') == 'delivered':
        return 0
    if verdict.get('status') == 'unshared':
        return 0
    if command == 'unhealthy' and verdict.get('status') == 'recorded':
        return 0
    if command == 'policy' and verdict.get('status') == 'eligible':
        return 0
    if (command in ('deliver', 'policy')
            and verdict.get('reason') in ('disabled', 'excluded')):
        # These two are explicit administrator choices of classic npm. The
        # wrapper distinguishes them from every actual delivery failure.
        return 3
    if (command == 'deliver' and verdict.get('reason')
            in ('unsupported_source', 'registry_not_allowed',
                'lockfile_mismatch', 'lockfile_too_large',
                'too_many_packages')):
        # Engine incapability, like a resolve sentinel: root refused a source
        # or package shape the Store does not serve, or an `npm ci` it cannot
        # install exactly, logged it and named the application in its
        # fallback registry for `status`. The account is not told; the
        # wrapper takes exit 3 to its classic path.
        # The one line stdout ever carries: npm_wrapper remembers an
        # `npm ci` refused for any of these (see its memo).
        sys.stdout.write('%s\n' % verdict['reason'])
        return 3
    sys.stderr.write('bun delivery declined: %s (%s)\n'
                     % (verdict.get('reason', '?'), verdict.get('detail', '')))
    return 1


if __name__ == '__main__':
    sys.exit(main())
utils/python_wrapper000075500000001072152725704130010721 0ustar00#!/bin/bash
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT
#

if [[ $EUID -eq 0 ]]; then
   echo "This program is not intended to be run as root." 1>&2
   exit 1
fi

CWD=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
source ${CWD}/activate
CL_PYTHON_VERSION="$(echo "${VIRTUAL_ENV}" | awk -F '/' '{print $NF}')"
eval "$(${CWD}/set_env_vars.py python)"

ABSOLUTE_PATH="${CWD}/python${CL_PYTHON_VERSION}_bin"
exec "${ABSOLUTE_PATH}" "$@"
utils/cloudlinux-cli-user.py000075500000000753152725704130012203 0ustar00#!/opt/cloudlinux/venv/bin/python3 -sbb
# coding:utf-8

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
from cloudlinux_cli_user import CloudlinuxCliUser

if __name__ == "__main__":
    cloudlinux_cli = CloudlinuxCliUser()
    cloudlinux_cli.main()
utils/cloudlinux_bun_migration_cleanup.py000075500000010706152725704130015105 0ustar00#!/opt/cloudlinux/venv/bin/python3 -bb
# coding:utf-8

"""Remove one account-owned Bun migration scratch transaction safely."""

import errno
import os
import re
import stat
import sys


TRANSACTION = re.compile(r'^\.cl-bun-migration-[0-9a-f]{32}$')
OPEN_DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW


def _remove_tree(directory_fd):
    """Delete entries by descriptor, refusing to traverse symlinked dirs.

    One single post-order pass. Re-deriving each directory's path from the
    transaction root cost one `open` per component, twice per directory --
    quadratic in a depth the account chooses. Only the directory being
    emptied holds a descriptor; a popped frame reaches its parent through
    `..` and proves it is the inode it descended from.
    """
    def open_directory(dir_fd, name):
        child_fd = os.open(name, OPEN_DIRECTORY, dir_fd=dir_fd)
        try:
            return child_fd, os.fstat(child_fd)
        except BaseException:
            os.close(child_fd)
            raise

    current_fd = os.dup(directory_fd)
    root_stat = os.fstat(current_fd)
    frames = [{'name': None, 'names': None, 'index': 0,
               'dev': root_stat.st_dev, 'ino': root_stat.st_ino}]
    try:
        while frames:
            frame = frames[-1]
            if frame['names'] is None:
                frame['names'] = os.listdir(current_fd)
            if frame['index'] >= len(frame['names']):
                if frame['name'] is None:
                    frames.pop()
                    continue
                parent_fd, parent_stat = open_directory(current_fd, '..')
                above = frames[-2]
                try:
                    if (parent_stat.st_dev != above['dev']
                            or parent_stat.st_ino != above['ino']):
                        raise OSError(errno.ENOTDIR,
                                      'transaction changed while cleaning')
                    os.rmdir(frame['name'], dir_fd=parent_fd)
                except BaseException:
                    os.close(parent_fd)
                    raise
                os.close(current_fd)
                current_fd = parent_fd
                frames.pop()
                continue
            name = frame['names'][frame['index']]
            frame['index'] += 1
            entry_stat = os.stat(name, dir_fd=current_fd, follow_symlinks=False)
            if stat.S_ISDIR(entry_stat.st_mode):
                child_fd, child_stat = open_directory(current_fd, name)
                os.close(current_fd)
                current_fd = child_fd
                frames.append({'name': name, 'names': None, 'index': 0,
                               'dev': child_stat.st_dev,
                               'ino': child_stat.st_ino})
            else:
                # A symlink is an entry in the transaction, never a path
                # to traverse. unlinkat removes the link itself.
                os.unlink(name, dir_fd=current_fd)
    finally:
        os.close(current_fd)


def clean(home, transaction):
    if not TRANSACTION.match(transaction):
        raise ValueError('invalid migration transaction')
    home_fd = os.open(home, OPEN_DIRECTORY)
    try:
        try:
            scratch_fd = os.open('.cl-bun-migration', OPEN_DIRECTORY, dir_fd=home_fd)
        except OSError as exc:
            if exc.errno == errno.ENOENT:
                return
            raise
        try:
            try:
                transaction_fd = os.open(transaction, OPEN_DIRECTORY, dir_fd=scratch_fd)
            except OSError as exc:
                if exc.errno == errno.ENOENT:
                    return
                raise
            try:
                _remove_tree(transaction_fd)
            finally:
                os.close(transaction_fd)
            os.rmdir(transaction, dir_fd=scratch_fd)
        finally:
            os.close(scratch_fd)
        try:
            os.rmdir('.cl-bun-migration', dir_fd=home_fd)
        except OSError as exc:
            if exc.errno not in (errno.ENOENT, errno.ENOTEMPTY):
                raise
    finally:
        os.close(home_fd)


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    if len(argv) != 2:
        sys.stderr.write('usage: cloudlinux_bun_migration_cleanup HOME TRANSACTION\n')
        return 2
    try:
        clean(argv[0], argv[1])
    except (OSError, ValueError) as exc:
        sys.stderr.write('migration scratch cleanup refused: %s\n' % exc)
        return 1
    return 0


if __name__ == '__main__':
    sys.exit(main())
utils/bun_user_phase000075500000120030152725704130010636 0ustar00#!/bin/bash
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2026 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
#
# User-side half of the bun install-time delivery, invoked through
# cagefs_enter_user by clselector/bun_delivery_hook.py. Normal delivery has
# three phases; explicit migration uses isolated resolve/rebuild variants:
#
#   prepare          canonicalize active lockfile inputs; conflicts refuse
#   prepare-classic  preserve divergent legacy prefix inputs for plain npm
#   resolve  bun install --lockfile-only   -- the account's own registries
#                                             and .npmrc; no package code runs
#   rebuild  npm rebuild                   -- lifecycle scripts and node-gyp,
#                                             as the user, venv node on PATH
#
# The venv's activate must be sourced first: without it the venv node is
# not on PATH and .bin shims that exec a bare `node` die inside the cage.

set -u
phase="${1:-}"
app_root="${2:-}"
# Overridable so the phase can be driven against a stub in tests. No
# privilege implication: this half already runs as the account.
BUN="${CL_BUN_BIN:-/opt/cl-bun/bun}"
# Refuse before creating/changing any application inputs. Preparation,
# rebuild and cleanup do not use Bun and must remain available without it.
if [[ "$phase" == resolve || "$phase" == migration-resolve ]]; then
  if [[ ! -f "$BUN" || ! -x "$BUN" ]]; then
    echo 'Shared node_modules Store requires a usable cl-bun engine. Ask the administrator to run cl-node-modules-storage enable to restore it, or cl-node-modules-storage disable to use classic npm.' >&2
    exit 1
  fi
fi
CL_PYTHON=/opt/cloudlinux/venv/bin/python3
SHRINKWRAP_UNSUPPORTED='npm-shrinkwrap.json is not supported by Shared node_modules Store; use classic npm'
PACKAGE_LOCK_V1_UNSUPPORTED='package-lock.json lockfileVersion 1 is not supported by Shared node_modules Store; run classic npm install first'
NO_DEPENDENCIES='the application has no dependencies to share'
# Separates the fatal entries of .bun-buildable from the tolerated ones. A
# valid npm name no tree carries, so an older phase reading a newer file
# rebuilds the whole list fatally, exactly as it did before.
BUILDABLE_OPTIONAL_SECTION='cl-bun-optional-names'
MIGRATION_CLEANER="${MIGRATION_CLEANER:-/usr/share/l.v.e-manager/utils/cloudlinux_bun_migration_cleanup.py}"

copy_regular_nofollow() {
  /opt/cloudlinux/venv/bin/python3 - "$1" "$2" "$3" <<'PY'
import os
import errno
import stat
import sys

source, target, label = sys.argv[1:]
source_fd = target_fd = None
created = False
try:
    try:
        source_fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
    except OSError as error:
        if error.errno == errno.ELOOP:
            raise OSError('%s is not a regular file' % label)
        raise
    if not stat.S_ISREG(os.fstat(source_fd).st_mode):
        raise OSError('%s is not a regular file' % label)
    target_fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
                        0o600)
    created = True
    while True:
        chunk = os.read(source_fd, 1024 * 1024)
        if not chunk:
            break
        offset = 0
        while offset < len(chunk):
            offset += os.write(target_fd, chunk[offset:])
except OSError as error:
    sys.stderr.write('migration %s\n' % error)
    if created:
        try:
            os.unlink(target)
        except OSError:
            pass
    sys.exit(1)
finally:
    if target_fd is not None:
        os.close(target_fd)
    if source_fd is not None:
        os.close(source_fd)
PY
}

package_lock_state() {
  "$CL_PYTHON" - "$1" <<'PY'
import errno
import json
import os
import stat
import sys

path = sys.argv[1]
maximum = 4 * 1024 * 1024

def no_duplicates(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('duplicate JSON key')
        result[key] = value
    return result

fd = None
try:
    try:
        fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
    except OSError as error:
        if error.errno == errno.ENOENT:
            sys.exit(0)
        raise
    info = os.fstat(fd)
    if not stat.S_ISREG(info.st_mode) or info.st_size > maximum:
        raise ValueError('package-lock.json is not a bounded regular file')
    chunks = []
    remaining = maximum + 1
    while remaining:
        chunk = os.read(fd, min(1024 * 1024, remaining))
        if not chunk:
            break
        chunks.append(chunk)
        remaining -= len(chunk)
    data = b''.join(chunks)
    if len(data) > maximum:
        raise ValueError('package-lock.json is too large')
    lock = json.loads(data.decode('utf-8'), object_pairs_hook=no_duplicates)
    if not isinstance(lock, dict):
        raise ValueError('package-lock.json is not an object')
    version = lock.get('lockfileVersion')
    if type(version) is not int or version not in (1, 2, 3):
        raise ValueError('unsupported package-lock.json lockfileVersion')
    sys.exit(3 if version == 1 else 0)
except (OSError, UnicodeError, ValueError, RecursionError) as error:
    sys.stderr.write('cannot inspect package-lock.json: %s\n' % error)
    sys.exit(2)
finally:
    if fd is not None:
        os.close(fd)
PY
}

unsupported_manifest_field() {
  # Print the first package.json field the Store cannot deliver, and exit 3.
  # The root validator refuses the same fields, but bun resolves first, on a
  # scratch directory that has none of the account's patch files: the account
  # was told to "run bun patch" for a feature that would have been refused
  # anyway. Read the manifest bounded, with duplicate keys rejected, the way
  # package_lock_state reads the lock. A symlinked manifest is named as the
  # token `symlink`: bun would follow it, but the privileged phase never
  # does, so it is classic npm territory. A manifest that cannot even be
  # opened is the root validator's business, not this pre-check's.
  "$CL_PYTHON" - "$1" <<'PY'
import json
import os
import stat
import sys

path = sys.argv[1]
maximum = 4 * 1024 * 1024
unsupported = ('patchedDependencies', 'workspaces')

def no_duplicates(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('duplicate JSON key')
        result[key] = value
    return result

fd = None
try:
    # The privileged phase reads this same file through a component-wise
    # O_NOFOLLOW walk (B4) and refuses a link; letting a linked manifest
    # through here only moved that failure one phase later. Name it now.
    try:
        if stat.S_ISLNK(os.lstat(path).st_mode):
            sys.stdout.write('symlink')
            sys.exit(3)
    except OSError:
        pass
    try:
        fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
    except OSError:
        # No opinion: nothing was read, so nothing is refused here.
        sys.exit(0)
    info = os.fstat(fd)
    if not stat.S_ISREG(info.st_mode) or info.st_size > maximum:
        raise ValueError('package.json is not a bounded regular file')
    chunks = []
    remaining = maximum + 1
    while remaining:
        chunk = os.read(fd, min(1024 * 1024, remaining))
        if not chunk:
            break
        chunks.append(chunk)
        remaining -= len(chunk)
    data = b''.join(chunks)
    if len(data) > maximum:
        raise ValueError('package.json is too large')
    manifest = json.loads(data.decode('utf-8'), object_pairs_hook=no_duplicates)
    if not isinstance(manifest, dict):
        raise ValueError('package.json is not an object')
    for field in unsupported:
        if field in manifest:
            sys.stdout.write(field)
            sys.exit(3)
    sys.exit(0)
except (OSError, UnicodeError, ValueError, RecursionError) as error:
    sys.stderr.write('cannot inspect package.json: %s\n' % error)
    sys.exit(2)
finally:
    if fd is not None:
        os.close(fd)
PY
}

# The administrator migration must resolve without changing the active
# application symlink, venv package.json, lockfile, or cache.  Its only
# account-side write is a transaction-named scratch directory below HOME.
# Handle it before the normal venv setup, which deliberately maintains those
# live links for ordinary delivery.
if [ "$phase" = "migration-resolve" ] || [ "$phase" = "migration-clean" ]; then
  transaction="${4:-}"
  [ -n "$app_root" ] || { echo "missing application root" >&2; exit 2; }
  [[ "$transaction" =~ ^\.cl-bun-migration-[0-9a-f]{32}$ ]] || {
    echo "invalid migration transaction" >&2; exit 2; }
  scratch_root="$HOME/.cl-bun-migration"
  scratch="$scratch_root/$transaction"
  if [ "$phase" = "migration-clean" ]; then
    exec "$MIGRATION_CLEANER" "$HOME" "$transaction"
    exit $?
  fi
  umask 077
  mkdir -p -- "$scratch_root" || exit 1
  [ ! -e "$scratch" ] || { echo "migration scratch exists" >&2; exit 1; }
  mkdir -- "$scratch" || exit 1
  copy_regular_nofollow "$HOME/$app_root/package.json" \
      "$scratch/package.json" package.json || exit 1
  # Bun resolves the existing npm/yarn pins when they are present.  Keep a
  # root-side migration semantically equivalent to the application's next
  # install without modifying any live application input.  Symlinks are not
  # copied: migration must not follow a caller-controlled detour out of the
  # application directory.
  for lock_input in package-lock.json npm-shrinkwrap.json yarn.lock; do
    app_lock="$HOME/$app_root/$lock_input"
    venv_lock="$HOME/nodevenv/$app_root/$3/lib/$lock_input"
    app_present=0
    venv_present=0
    if [ -e "$app_lock" ] || [ -L "$app_lock" ]; then
      [ -f "$app_lock" ] && [ ! -L "$app_lock" ] || {
        echo "migration lock input is not a regular file: $lock_input" >&2; exit 1; }
      app_present=1
    fi
    if [ -L "$venv_lock" ]; then
      # Normal prepare makes this exact absolute alias, including a dangling
      # one when npm has not yet written a lock. It is only another name for
      # the app-root input, never a second migration source.
      [ "$(readlink "$venv_lock")" = "$app_lock" ] || {
        echo "migration venv lock input is not a regular file: $lock_input" >&2; exit 1; }
    elif [ -e "$venv_lock" ]; then
      [ -f "$venv_lock" ] || {
        echo "migration venv lock input is not a regular file: $lock_input" >&2; exit 1; }
      venv_present=1
    fi
    if [ "$app_present" -eq 1 ] && [ "$venv_present" -eq 1 ]; then
      cmp -s -- "$app_lock" "$venv_lock" || {
        conflict="migration lock input conflicts with active venv: $lock_input"
        printf '%s\n' "$conflict" > "$scratch/diagnostic"
        echo "$conflict" >&2
        exit 1
      }
    fi
    if [ "$lock_input" = npm-shrinkwrap.json ] \
       && { [ "$app_present" -eq 1 ] || [ "$venv_present" -eq 1 ]; }; then
      printf '%s\n' "$SHRINKWRAP_UNSUPPORTED" > "$scratch/diagnostic"
      echo "$SHRINKWRAP_UNSUPPORTED" >&2
      exit 1
    fi
    if [ "$app_present" -eq 1 ]; then
      copy_regular_nofollow "$app_lock" "$scratch/$lock_input" \
          "$lock_input" || exit 1
    elif [ "$venv_present" -eq 1 ]; then
      copy_regular_nofollow "$venv_lock" "$scratch/$lock_input" \
          "$lock_input" || exit 1
    fi
  done
  package_lock_state "$scratch/package-lock.json"
  package_lock_status=$?
  if [ "$package_lock_status" -eq 3 ]; then
    printf '%s\n' "$PACKAGE_LOCK_V1_UNSUPPORTED" > "$scratch/diagnostic"
    echo "$PACKAGE_LOCK_V1_UNSUPPORTED" >&2
    exit 3
  elif [ "$package_lock_status" -ne 0 ]; then
    exit 1
  fi
  cd "$scratch" || exit 1
  "$BUN" install --lockfile-only --no-progress \
       --cache-dir="$scratch/.bun-cache" 2>&1 | tail -c 2000 > "$scratch/diagnostic"
  resolve_status="${PIPESTATUS[0]}"
  # Zero packages means bun wrote no lockfile, so there is nothing for the
  # privileged phase to validate. Migration is an explicit conversion, not
  # an install, so it takes its own typed refusal here rather than the
  # classic-npm route the install path takes.
  if [ "$resolve_status" -eq 0 ] && [ ! -f bun.lock ]; then
    printf '%s\n' "$NO_DEPENDENCIES" > "$scratch/diagnostic"
    echo "$NO_DEPENDENCIES" >&2
    exit 3
  fi
  exit "$resolve_status"
fi

dedupe_list() {
  # Reads `dedupe_input`, writes `dedupe_output`, order preserving.
  #
  # `printf … | sort -u` would have to come back through a process
  # substitution, and the cage has no /dev/fd: there the redirect fails,
  # `mapfile` never runs, and the array keeps what it already held. Both
  # lists here are populated before this point, so the effect was a
  # redundant per-instance loop rather than a missing one -- but the
  # construct cannot work in a cage at all, which is why it is banned
  # outright rather than worked around.
  # A here-string would only trade that for a writable TMPDIR. Bash alone
  # needs neither. Order is first-occurrence, not `sort -u`'s: root sorts
  # the file by path and this runs after stripping to install names, which
  # reorders. Nothing depends on it -- one `npm rebuild` takes the required
  # names as arguments, and the optional ones drive an independent loop.
  #
  # Quadratic in the number of buildable packages, which the tree walk
  # already bounds and which is a handful in practice.
  local candidate seen duplicate
  dedupe_output=()
  for candidate in ${dedupe_input[@]+"${dedupe_input[@]}"}; do
    duplicate=0
    for seen in ${dedupe_output[@]+"${dedupe_output[@]}"}; do
      [ "$seen" = "$candidate" ] || continue
      duplicate=1
      break
    done
    [ "$duplicate" -eq 0 ] && dedupe_output+=("$candidate")
  done
  # Without this the function returns 1 whenever the *last* element was a
  # duplicate, which no caller checks today and one would eventually.
  return 0
}

drop_optional_package() {
  # npm parity: an optional dependency whose install hook fails is removed
  # and the install carries on. The package is a private copy -- the root
  # phase detaches every buildable package before hand-over -- so this
  # removes no inode the shared store still owns.
  #
  # Returns non-zero if the directory survives: leaving an unbuilt native
  # package behind while reporting success would commit a tree the account
  # cannot use.
  local path="$1" install_name parent container target shim walked component \
        rest ancestors
  # `.bin` belongs to the `node_modules` container, never to a scope
  # directory inside it, so the container is what is left once the whole
  # install name -- scope included -- is taken off the end.
  install_name="${path##*/node_modules/}"
  parent="${path%"$install_name"}"
  parent="${parent%/}"
  container="$lib/node_modules"
  [ -n "$parent" ] && container="$lib/node_modules/$parent"
  # `rm -rf` resolves intermediate components, so a symlinked ancestor
  # would delete outside the tree. Root's walk is O_NOFOLLOW at every
  # component and can never emit such a path -- this is defence in depth,
  # and what the "re-check rather than trust the file" claim beside the
  # caller needs to be true. The leaf is not checked: `rm` unlinks a
  # symlink there and leaves its target alone.
  # Every component except the final one, so a scoped leaf's `@scope`
  # directory is walked too: it sits between the container and the leaf,
  # and `$parent` -- the path minus the *whole* install name -- skips it.
  # The leaf itself is deliberately excluded: `rm` unlinks a symlink there
  # and leaves its target, which is what npm would do.
  #
  # Parameter expansion rather than word splitting on IFS: an unquoted
  # `$ancestors` would also be glob-expanded, and a package may
  # legitimately be called `a*b`.
  ancestors="${path%/*}"
  [ "$ancestors" = "$path" ] && ancestors=""
  walked="$lib/node_modules"
  # The walk's own starting point counts: a symlinked `node_modules` takes
  # every removal outside the venv however clean the components below it.
  if [ -L "$walked" ]; then
    echo "refusing to remove optional dependency $path through a" \
         "symbolic link at node_modules" >&2
    return 1
  fi
  rest="$ancestors"
  while [ -n "$rest" ]; do
    component="${rest%%/*}"
    if [ "$component" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
    [ -n "$component" ] || continue
    walked="$walked/$component"
    if [ -L "$walked" ]; then
      echo "refusing to remove optional dependency $path through the" \
           "symbolic link $component" >&2
      return 1
    fi
  done
  rm -rf -- "$lib/node_modules/$path"
  # `-e` follows the link, so a dangling symlink left at that path would
  # read as removed while node still resolves it. `-L` catches that.
  if [ -e "$lib/node_modules/$path" ] || [ -L "$lib/node_modules/$path" ]; then
    echo "cannot remove optional dependency $path after its build" \
         "failed" >&2
    return 1
  fi
  # Only this package's own shims go. Another dangling link in the same
  # .bin is the account's business: we were asked to drop one package.
  if [ -d "$container/.bin" ]; then
    for shim in "$container/.bin"/*; do
      [ -L "$shim" ] || continue
      target="$(readlink -- "$shim")" || continue
      # `../lmdb/../other/cli.js` opens with the package's own prefix and
      # then leaves it, so anything that walks back out is not ours.
      case "$target" in
        *"/../"*|*/..) continue ;;
      esac
      case "$target" in
        "../$install_name"|"../$install_name"/*) rm -f -- "$shim" ;;
      esac
    done
  fi
  return 0
}


migration_rebuild=0
migration_scratch=""
classic_prepare=0
classic_conflict=0
if [ "$phase" = "migration-rebuild" ]; then
  transaction="${4:-}"
  [[ "$transaction" =~ ^\.cl-bun-migration-[0-9a-f]{32}$ ]] || {
    echo "invalid migration transaction" >&2; exit 2; }
  migration_scratch="$HOME/.cl-bun-migration/$transaction"
  [ -d "$migration_scratch" ] || { echo "migration scratch missing" >&2; exit 1; }
  migration_rebuild=1
  phase=rebuild
elif [ "$phase" = "prepare-classic" ]; then
  # Existing applications can legitimately have a Git-managed lock in the
  # application root and a different lock written by the historical
  # prefix-based npm path. Plain npm must remain available after upgrade,
  # while Shared Store delivery continues to reject that ambiguity.
  classic_prepare=1
  phase=prepare
fi

# npm invokes --script-shell as `<shell> -c <command>`. Re-entering this
# script only for that form lets npm suppress automatic companion hooks while
# the application command itself sees its normal npm configuration.
if [ "$phase" = "-c" ]; then
  unset npm_config_ignore_scripts
  exec "${CL_BUN_NPM_SCRIPT_SHELL:-/bin/sh}" "$@"
fi

major="${3:-}"
[ -n "$phase" ] && [ -n "$app_root" ] || {
  echo "usage: $0 <prepare|prepare-classic|resolve|deliver|rebuild> <app-root> [node-major]" >&2; exit 2; }

DELIVERY_CLIENT=/usr/share/l.v.e-manager/utils/cloudlinux_bun_client.py

# The delivery request needs neither a venv nor bun -- it is one sentence to
# a socket -- so it is answered before the interpreter discovery below,
# which would otherwise fail on an application the account has not built.
if [ "$phase" = "deliver" ]; then
  [ -x "$CL_PYTHON" ] || { echo "python runtime unavailable" >&2; exit 2; }
  exec "$CL_PYTHON" "$DELIVERY_CLIENT" "$app_root"
fi

# Which venv this install belongs to. A Node.js version change leaves the
# old `<venv_base>/<old>` beside the new one, so a listing of the directory
# has two answers and the lexically first is not the current one. The
# answer comes from whoever already knows: npm_wrapper's environment
# (`CL_VIRTUAL_ENV`, set by set_env_vars for exactly this application) or
# the panel hook's argument (the selector's recorded major). Only without
# either does the listing decide, and only when it has one entry.
venv_base="$HOME/nodevenv/$app_root"
if [ -n "${CL_VIRTUAL_ENV:-}" ] && [ -d "$CL_VIRTUAL_ENV" ]; then
  venv="$CL_VIRTUAL_ENV"
elif [ -n "$major" ]; then
  venv="$venv_base/$major"
else
  # A glob loop rather than mapfile over a process substitution: the cage
  # has no /dev/fd, and an empty array is fatal under `set -u` on bash 4.2.
  found=""
  count=0
  for candidate in "$venv_base"/[0-9]*; do
    [ -d "$candidate" ] || continue
    found="$found ${candidate##*/}"
    count=$((count + 1))
    venv="$candidate"
  done
  if [ "$count" -ne 1 ]; then
    echo "cannot tell which venv of $app_root to use: found${found:- none}" >&2
    exit 1
  fi
fi
[ -d "$venv" ] || { echo "no venv for $app_root at $venv" >&2; exit 1; }
lib="$venv/lib"

prepare_app_lockfile() {
  local name="$1" mode="$2" app_lock lib_lock app_kind lib_kind
  app_lock="$HOME/$app_root/$name"
  lib_lock="$lib/$name"
  if [[ -L "$app_lock" && "$classic_prepare" -eq 1 ]]; then
    # A linked lockfile is the account's own layout and plain npm reads it
    # as it always has. Keep the whole historical layout, like the
    # divergent-copies case below: refusing here left such an application
    # unable to `npm install` at all, even after `disable`.
    classic_conflict=1
    return 0
  elif [[ -L "$app_lock" || ( -e "$app_lock" && ! -f "$app_lock" ) ]]; then
    echo "refusing unexpected app lockfile: $app_lock" >&2
    return 1
  elif [[ -f "$app_lock" ]]; then
    app_kind=regular
  else
    app_kind=absent
  fi
  if [[ -L "$lib_lock" ]]; then
    if [[ "$(readlink "$lib_lock")" == "$app_lock" ]]; then
      lib_kind=linked
    else
      echo "refusing unexpected venv lockfile link: $lib_lock" >&2
      return 1
    fi
  elif [[ -e "$lib_lock" && ! -f "$lib_lock" ]]; then
    echo "refusing unexpected venv lockfile: $lib_lock" >&2
    return 1
  elif [[ -f "$lib_lock" ]]; then
    lib_kind=regular
  else
    lib_kind=absent
  fi
  case "$app_kind:$lib_kind" in
    absent:regular)
      [ "$mode" = preflight ] && return 0
      mv -- "$lib_lock" "$app_lock" || return 1
      ;;
    regular:regular)
      if ! cmp -s -- "$app_lock" "$lib_lock"; then
        if [ "$classic_prepare" -eq 1 ]; then
          classic_conflict=1
          return 0
        fi
        echo "conflicting lock files: $app_lock and $lib_lock" >&2
        return 1
      fi
      [ "$mode" = preflight ] && return 0
      rm -f -- "$lib_lock" || return 1
      ;;
    regular:absent)
      [ "$mode" = preflight ] && return 0
      ;;
    absent:absent)
      return 0
      ;;
    regular:linked)
      return 0
      ;;
    absent:linked)
      [ "$mode" = preflight ] && return 0
      # Older wrappers created this exact dangling alias. npm 10 refuses
      # to open it, so let npm materialize a regular prefix lock and
      # canonicalize that file after the command succeeds.
      rm -f -- "$lib_lock" || return 1
      return 0
      ;;
    *)
      echo "refusing unexpected lockfile state: $app_lock $lib_lock" >&2
      return 1
      ;;
  esac
  ln -s -- "$app_lock" "$lib_lock"
}

prepare_app_lockfiles() {
  local lockfile
  for lockfile in package-lock.json npm-shrinkwrap.json yarn.lock; do
    prepare_app_lockfile "$lockfile" preflight || return 1
  done
  # Preserve the complete historical lockfile layout when any pair is
  # ambiguous. This keeps classic npm compatible without partly
  # canonicalizing unrelated inputs before the command runs.
  [ "$classic_conflict" -eq 1 ] && return 0
  for lockfile in package-lock.json npm-shrinkwrap.json yarn.lock; do
    prepare_app_lockfile "$lockfile" apply || return 1
  done
}

# The selector's activate script references unset variables, which is
# fatal under `set -u` -- relax it for the duration of the source only.
set +u
# shellcheck disable=SC1090
source "$venv/bin/activate" 2>/dev/null || true
set -u

# Both package managers work with the venv as their directory while the
# manifest lives in the application root, so the venv has to point at it.
# npm_wrapper does this before every npm run, and a freshly created
# application has only bin/ -- no lib/, no link. Measured without this on a
# new account: `cd <venv>/lib` fails, resolve exits 1, the hook falls back
# to plain npm, and that npm run creates both -- so delivery never covered
# an account's *first* install, the one install the shared cache is most
# likely to serve in full. Idempotent, and done here rather than root-side
# on purpose: this phase already runs as the account, so linking inside its
# own home stays an ordinary unprivileged operation.
if [ "$migration_rebuild" -eq 0 ]; then
  # Classic npm mutations call `prepare` under their app lock. It needs a
  # place for lockfile aliases, but must not touch the application's active
  # node_modules tree (update/uninstall/rebuild operate on that tree).
  mkdir -p "$lib" || { echo "cannot create $lib" >&2; exit 1; }
  if [ "$phase" = resolve ]; then
    # Engine incapability like a linked package.json, and the same route:
    # delivery canonicalises these names and must not follow or replace the
    # account's link, so name it before preparation refuses it outright.
    for linked_lock in package-lock.json npm-shrinkwrap.json yarn.lock; do
      if [ -L "$HOME/$app_root/$linked_lock" ]; then
        printf '%s\n' 'cl-bun:unsupported-manifest symlink'
        exit 3
      fi
    done
  fi
  prepare_app_lockfiles || exit 1
  if [ "$phase" = prepare ]; then
    exit 0
  fi

  mkdir -p "$lib/node_modules" || { echo "cannot create $lib/node_modules" >&2; exit 1; }
  ln -sf "$HOME/$app_root/package.json" "$lib/package.json" || {
    echo "cannot link package.json into $lib" >&2; exit 1; }

# Node resolves modules from <app_root>/node_modules, while the tree we
# deliver lives in the venv. npm_wrapper links the two on every install --
# and the panel path, the only entry point this feature supports, never
# reaches the wrapper. Without this a delivered application cannot load a
# single dependency: measured on an application installed only through the
# delivery, `require('express')` fails with MODULE_NOT_FOUND while the tree
# sits complete in the venv.
  app_node_modules="$HOME/$app_root/node_modules"
  if [ -L "$app_node_modules" ]; then
    [ "$(readlink "$app_node_modules")" = "$lib/node_modules" ] || {
      echo "$app_node_modules exists and is not the selected venv link" >&2; exit 1; }
  elif [ -e "$app_node_modules" ]; then
  # Same rule npm_wrapper enforces: a real directory there is the account's
  # own tree and must not be silently replaced. Refusing drops the install
  # onto the ordinary npm path, which reports the same condition.
    echo "$app_node_modules exists and is not a symlink" >&2; exit 1
  fi
  if [ ! -e "$app_node_modules" ] && [ ! -L "$app_node_modules" ]; then
    ln -s "$lib/node_modules" "$app_node_modules" || {
      echo "cannot link node_modules into $HOME/$app_root" >&2; exit 1; }
  fi
fi

cd "$lib" || exit 1

migration_command() {
  if [ "$migration_rebuild" -eq 1 ]; then
    "$@" 2>&1 | tail -c 2000 > "$migration_scratch/diagnostic"
    return "${PIPESTATUS[0]}"
  fi
  "$@" >/dev/null 2>&1
}

rebuild_env_state="$lib/.cl-bun-rebuild-env"

is_rebuild_env_name() {
  case "$1" in
    npm_config_build_from_source|npm_config_arch|npm_config_platform|\
    npm_config_target|npm_config_runtime|npm_config_nodedir|npm_config_devdir|\
    npm_config_jobs|npm_config_python|npm_config_node_gyp|\
    NPM_CONFIG_BUILD_FROM_SOURCE|NPM_CONFIG_ARCH|NPM_CONFIG_PLATFORM|\
    NPM_CONFIG_TARGET|NPM_CONFIG_RUNTIME|NPM_CONFIG_NODEDIR|NPM_CONFIG_DEVDIR|\
    NPM_CONFIG_JOBS|NPM_CONFIG_PYTHON|NPM_CONFIG_NODE_GYP|\
    CC|CXX|CPP|AR|LD|MAKE|PYTHON|NODE_GYP_FORCE_PYTHON)
      return 0
      ;;
  esac
  return 1
}

save_rebuild_environment() {
  local name temporary
  temporary="$rebuild_env_state.$$"
  (umask 077; set -o noclobber; : > "$temporary") || return 1
  for name in $(compgen -e); do
    if is_rebuild_env_name "$name"; then
      printf '%s=%s\0' "$name" "${!name}" >> "$temporary" || {
        rm -f -- "$temporary"
        return 1
      }
    fi
  done
  if [ -e "$rebuild_env_state" ] || [ -L "$rebuild_env_state" ]; then
    [ -f "$rebuild_env_state" ] && [ ! -L "$rebuild_env_state" ] || {
      rm -f -- "$temporary"
      return 1
    }
  fi
  mv -f -- "$temporary" "$rebuild_env_state"
}

restore_rebuild_environment() {
  local entry name
  [ -f "$rebuild_env_state" ] && [ ! -L "$rebuild_env_state" ] || return 0
  trap 'rm -f -- "$rebuild_env_state"' EXIT
  while IFS= read -r -d '' entry; do
    [[ "$entry" == *=* ]] || continue
    name="${entry%%=*}"
    is_rebuild_env_name "$name" || continue
    export "$entry"
  done < "$rebuild_env_state"
}

case "$phase" in
  resolve)
    if [ -f "$HOME/$app_root/npm-shrinkwrap.json" ] \
       && [ ! -L "$HOME/$app_root/npm-shrinkwrap.json" ]; then
      # Engine incapability like a v1 lockfile or an unsupported manifest
      # field, and the same route: exit 3 with its own sentinel and classic
      # npm installs. A published CLI pins its tree this way, and refusing
      # left such an application unable to install at all -- while being
      # told to use the npm that never ran. Migration keeps its own typed
      # refusal, which is an explicit conversion, not an install.
      printf '%s\n' 'cl-bun:unsupported-npm-shrinkwrap'
      exit 3
    fi
    package_lock_state "$HOME/$app_root/package-lock.json"
    package_lock_status=$?
    if [ "$package_lock_status" -eq 3 ]; then
      printf '%s\n' 'cl-bun:legacy-package-lock-v1'
      exit 3
    elif [ "$package_lock_status" -ne 0 ]; then
      exit 1
    fi
    # Name the refusal here rather than letting bun fail first on a manifest
    # feature the root validator would refuse anyway. A manifest that could
    # not be read names no field and stays an ordinary failure.
    unsupported_field="$(unsupported_manifest_field "$HOME/$app_root/package.json")"
    unsupported_status=$?
    if [ "$unsupported_status" -eq 3 ]; then
      # Engine incapability, not a delivery failure: npm workspaces is
      # mainstream and a monorepo root's bare `npm install` is exactly what
      # the wrapper routes here. Take the `lockfileVersion 1` route -- exit
      # 3, named by its own sentinel, and classic npm installs.
      printf '%s %s\n' 'cl-bun:unsupported-manifest' "$unsupported_field"
      exit 3
    elif [ "$unsupported_status" -ne 0 ]; then
      exit 1
    fi
    # `npm ci` (npm_wrapper's CL_BUN_COMMAND) installs package-lock.json and
    # nothing else. bun prefers a bun.lock it finds over package-lock.json
    # and silently re-resolves a package.json that drifted from it unless
    # frozen (measured, bun 1.4.0), so resolve from package-lock.json alone
    # and refuse drift; without one there is nothing exact to resolve. Any
    # failure here leaves the verdict to classic `npm ci`, and root still
    # compares the result with package-lock.json before delivering it.
    frozen=()
    if [ "${CL_BUN_COMMAND:-}" = ci ]; then
      [ -f "$HOME/$app_root/package-lock.json" ] || exit 1
      rm -f -- bun.lock || exit 1
      frozen=(--frozen-lockfile)
    fi
    # The privileged delivery starts rebuild in a fresh account cage while
    # retaining the old tree for rollback. Preserve only non-secret native
    # build controls from the originating npm environment across that phase
    # boundary; registry credentials and arbitrary account variables never
    # enter this file.
    save_rebuild_environment || exit 1
    # Scratch state stays inside the venv, not in the account's dotfiles:
    # the shared cache is deliberately unreachable from the cage.
    #
    # Not `exec`: bun's own diagnosis (a registry error, a typo, an auth
    # failure) is the only thing that tells the account why resolution
    # failed, so the output is captured -- bounded by `tail` -- and echoed
    # on failure. This half already runs as the account, so echoing its own
    # bun output leaks no privileged state. stdout stays clean because the
    # wrapper and the hook parse it for the phase sentinels above.
    resolve_output="$(set -o pipefail
                      "$BUN" install --lockfile-only \
                          ${frozen[@]+"${frozen[@]}"} --no-progress \
                          --cache-dir="$lib/.bun-cache" 2>&1 | tail -c 2000)"
    resolve_status=$?
    if [ "$resolve_status" -ne 0 ]; then
      # Same reasoning as the no-dependencies route below: no exit that
      # leaves this install to classic npm may leave native build settings
      # in the venv for a rebuild that never comes.
      rm -f -- "$rebuild_env_state"
      # A failed `ci` resolve goes to classic `npm ci`, whose own error is
      # the one the account should read.
      if [ "${CL_BUN_COMMAND:-}" != ci ]; then
        echo 'Shared node_modules Store: dependency resolution failed:' >&2
        [ -n "$resolve_output" ] && printf '%s\n' "$resolve_output" >&2
      fi
      exit "$resolve_status"
    fi
    # Bun writes no lockfile at all when the manifest resolves to zero
    # packages, and deletes one it wrote earlier ("No packages! Deleted
    # empty lockfile"). There is then nothing for the privileged phase to
    # read, and it used to fail the install on the missing file -- which is
    # what the panel's "Run NPM Install" does on a freshly created
    # application. Same route as the other inputs this engine cannot serve:
    # exit 3 with its own sentinel, and classic npm performs the no-op
    # install. A tree delivered before losing its last dependency is
    # detached by the wrapper's classic path, as on every other exit 3.
    if [ ! -f bun.lock ]; then
      # The other three sentinels exit before `save_rebuild_environment`
      # and so leave nothing behind. This one cannot: an empty resolution
      # is what reveals the case, and that is only knowable once bun has
      # run. So it clears the file it already wrote, rather than leaving
      # native-build settings in the venv for a rebuild this route never
      # reaches -- classic npm installs from here.
      rm -f -- "$rebuild_env_state"
      printf '%s\n' 'cl-bun:no-dependencies'
      exit 3
    fi
    exit 0
    ;;
  rebuild)
    # This phase already runs inside npm_wrapper's app lock. Use the real
    # alt-node npm directly so a manual `npm rebuild` can be protected by the
    # wrapper without the internal lifecycle work trying to lock/unshare its
    # own freshly delivered tree again.
    NPM="${CL_NODEHOME:-}/usr/bin/npm"
    [ -x "$NPM" ] || { echo "node npm unavailable at $NPM" >&2; exit 1; }
    if [ "$migration_rebuild" -eq 0 ]; then
      restore_rebuild_environment
    fi
    # npm parity: the npm path runs dependency lifecycle scripts
    # unconditionally, and here they run as the user. The root phase
    # leaves the short list of packages that actually carry an install
    # hook; rebuilding the whole tree instead is the dominant cost of the
    # flow. An absent list means "unknown", so rebuild everything.
    if [ -f "$lib/.bun-buildable" ]; then
      mapfile -t entries < "$lib/.bun-buildable"
      # Everything before the section header must rebuild; everything after
      # it is an optional dependency, which npm itself drops rather than
      # failing the install when its hook fails. A file written before the
      # section existed has no header, so every entry stays required.
      pkgs=()
      optional_paths=()
      in_optional=0
      # `${a[@]+"${a[@]}"}`: bash 4.2 (CL7) treats "${a[@]}" of an empty
      # array as unbound, and this script runs under `set -u`. `entries` is
      # empty for an empty manifest and `optional_paths` whenever there is
      # no optional section, which is the ordinary case.
      for entry in ${entries[@]+"${entries[@]}"}; do
        if [ "$in_optional" -eq 0 ] && [ "$entry" = "$BUILDABLE_OPTIONAL_SECTION" ]; then
          in_optional=1
          continue
        fi
        if [ "$in_optional" -eq 1 ]; then
          # Root produced these paths from its own walk, but this phase
          # deletes by them, so re-check them here rather than trust the
          # file: relative, and no parent traversal.
          case "$entry" in
            ''|/*|*/../*|../*|*/..) echo "unsafe buildable entry: $entry" >&2; exit 1 ;;
            ..) echo "unsafe buildable entry: $entry" >&2; exit 1 ;;
          esac
          optional_paths+=("$entry")
        else
          pkgs+=("$entry")
        fi
      done
      if [ "${#pkgs[@]}" -ne 0 ]; then
        # The root phase records paths for private copies. npm 6 does not
        # rebuild directory arguments; names select every installed version.
        # Keep @scope/name intact. All buildable instances are private now.
        for i in "${!pkgs[@]}"; do
          pkgs[i]="${pkgs[i]##*/node_modules/}"
        done
        dedupe_input=(${pkgs[@]+"${pkgs[@]}"})
        dedupe_list
        pkgs=(${dedupe_output[@]+"${dedupe_output[@]}"})
        migration_command "$NPM" rebuild -- "${pkgs[@]}" || exit $?
      fi
      # Required first, then the tolerated names. npm builds one dependency
      # graph in its own order; this cannot, because a required failure has
      # to stop the transaction before anything optional is removed. A
      # package that needs an optional sibling built first would therefore
      # differ from npm -- none is known, and npm does not promise that
      # order either.
      # Grouped by install name, and one `npm rebuild` per name: a name
      # selector is name-wide -- it rebuilds every instance carrying it --
      # so a failure cannot be attributed to one instance. npm 8 and 10 do
      # accept a `./path` spec that would select just one, but npm 6 does
      # not, and this has to behave the same on every major we deliver to.
      # Root only tolerates a name when *every* buildable instance of it is
      # optional (`_required_buildable`), so dropping all of them removes
      # nothing npm would have kept, and never leaves a broken instance
      # behind. One rebuild per name also keeps a single failure from
      # hiding every later optional package.
      optional_names=()
      for optional_path in ${optional_paths[@]+"${optional_paths[@]}"}; do
        optional_names+=("${optional_path##*/node_modules/}")
      done
      dedupe_input=(${optional_names[@]+"${optional_names[@]}"})
      dedupe_list
      optional_names=(${dedupe_output[@]+"${dedupe_output[@]}"})
      for optional_name in ${optional_names[@]+"${optional_names[@]}"}; do
        migration_command "$NPM" rebuild -- "$optional_name" && continue
        for optional_path in ${optional_paths[@]+"${optional_paths[@]}"}; do
          [ "${optional_path##*/node_modules/}" = "$optional_name" ] || continue
          drop_optional_package "$optional_path" || exit 1
        done
        echo "optional dependency $optional_name failed to build and was" \
             "removed; the application runs without it" >&2
      done
    else
      migration_command "$NPM" rebuild || exit $?
    fi

    # `npm rebuild` replays dependency hooks but not this application's
    # install lifecycle. Only invoke declared lifecycle events so a pure-JS
    # application keeps the no-npm shortcut. JSON parsing is done by the
    # venv node already required for npm; the emitted names come from this
    # fixed list, not from the manifest.
    lifecycle_events=$(node -e '
      const fs = require("fs");
      const events = ["preinstall", "install", "postinstall", "prepublish",
                      "preprepare", "prepare", "postprepare"];
      const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
      const scripts = manifest.scripts;
      if (scripts && typeof scripts === "object" && !Array.isArray(scripts)) {
        for (const event of events) {
          if (typeof scripts[event] === "string") console.log(event);
        }
      }
    ' "$lib/package.json") || exit $?
    [ -n "$lifecycle_events" ] || exit 0

    # `--ignore-scripts` makes npm run only the explicitly requested event,
    # rather than also running pre<event>/post<event> companions. npm passes
    # that config into the script environment, though, so re-enter this
    # script through its documented --script-shell hook to clear it before
    # the application command starts. Preserve a user-configured script-shell
    # (or npm's /bin/sh default) for the command itself.
    lifecycle_shell=$("$NPM" config get script-shell 2>/dev/null) || exit $?
    case "$lifecycle_shell" in
      ''|null|undefined) lifecycle_shell=/bin/sh ;;
    esac
    for lifecycle_script in $lifecycle_events; do
      if [ "$migration_rebuild" -eq 1 ]; then
        migration_command env CL_BUN_NPM_SCRIPT_SHELL="$lifecycle_shell" \
          "$NPM" run --ignore-scripts --script-shell "$0" \
          "$lifecycle_script" --if-present || exit $?
      else
        CL_BUN_NPM_SCRIPT_SHELL="$lifecycle_shell" \
        "$NPM" run --ignore-scripts --script-shell "$0" \
            "$lifecycle_script" --if-present >/dev/null 2>&1 || exit $?
      fi
    done
    ;;
  *)
    echo "unknown phase: $phase" >&2; exit 2
    ;;
esac
utils/cpanel_api.py000064400000026614152725704130010370 0ustar00# coding:utf-8

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2020 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import absolute_import
from __future__ import print_function
from lvemanager.helpers import exit_with_error, run_command
import json
import re
from abc import abstractmethod
from clcommon.lib.whmapi_lib import WhmApiRequest, WhmApiError
import sys

OWNER_ADMIN = 'admin'
OWNER_RESELLER = 'reseller'
OWNER_USER = 'user'
UAPI_IGNORED_ERRORS = ["The event UAPI::LangPHP::php_set_vhost_versions was handled successfully."]

def get_cpanel_api_class(owner, username=None):
    """
    Factory method that returns
    certain class depending on
    owner's type
    :param owner: Can be 'admin', 'reseller' or 'user'
    :param username: required when owner == 'reseller' so the UAPI call is
                     bound to the reseller's cPanel account
    :return:
    """
    if owner == OWNER_ADMIN:
        return CPanelAdminApi()
    if owner == OWNER_RESELLER:
        # Reseller path runs as root (see drop_permission in
        # cloudlinux_cli.py:157); UAPI inherits no implicit scope from
        # cpsrvd env, so the username must be passed explicitly so the
        # call is bound to the reseller's own cPanel account.
        if not username:
            exit_with_error('Reseller cpanel-api call requires username')
        return CPanelUserApi(scoped_user=username)
    return CPanelUserApi()


class CPanelApi(object):
    """
    Abstract method that defines abstract methods
    that must be implemented and common methods of
    the derived classes
    """

    RETURN_JSON = '--output=json'
    ALLOWED_OPERATIONS = {}

    @abstractmethod
    def check_operation_allowed(self, called_method):
        """
        Checks whether operation is allowed to perform
        :param called_method:
        :return:
        """
        raise NotImplementedError('Method check_operation_allowed must be implemented!')

    @abstractmethod
    def prepare_running_command(self, called_method, params, return_json):
        """
        Depending on the passed arguments,
        the method builds running command
        """
        raise NotImplementedError('Method prepare_running_command must be implemented!')

    @abstractmethod
    def parse_result(self, called_method, result):
        """
        Some results are parsed before sending back
        to the called. This method parses it and returns
        transformed result.
        """
        raise NotImplementedError('Method parse_result must be implemented!')

    @staticmethod
    def check_method_allowed(method_name, methods):
        """
        CPanel API contains several methods and
        not all of them if supported by this utility.
        """
        for method_object in methods:
            if method_name == method_object['method']:
                return True
        return False

    def check_for_errors(self, result):
        """
        Checks whether CPanel API returned
        error or not
        :param result:
        :return:
        """
        raise NotImplementedError('Method check_for_errors must be implemented!')

    @staticmethod
    def get_method_parser(method_name, methods):
        """
        Some result must be parsed before
        they are sent back to the caller. This
        method extracts the method that must be executed
        upon the result to transform it.
        """
        for method_object in methods:
            if method_name == method_object['method']:
                return method_object.get('parser', None)

    def get_ignore_errors(self, method_name, methods):
        """
        return ignore error flag for method
        :return:
        """
        for method_object in methods:
            if method_name == method_object['method']:
                return method_object.get('ignore_errors', None)

    @staticmethod
    def parse_vhost_versions(result):
        """
        Method parses the result of the method 'parse_vhost_versions'.
        The host will be inherited when its field 'sys_default'
        inside 'php_version_source' is equal to 1.
        """
        parsed_result = []
        for r in result:
            parsed_result.append({
                'version': r.get('version'),
                'host': r.get('vhost'),
                'php_fpm': True if r.get('php_fpm') else False,
                'inherited': True if r.get('php_version_source', {}).get('sys_default') == 1 else False
            })
        return parsed_result

    def run(self, called_method, params, return_json=True):
        """
        Default method which first checks whether operation
        is allowed then if allowed runs prepared command.
        After receiving the result, it will call parse_result method
        to transform result into desired format.
        """
        if self.check_operation_allowed(called_method):
            prepared_command = self.prepare_running_command(called_method, params, return_json)

            code, output, std_err = run_command(prepared_command, return_full_output=True)
            if code != 0:
                exit_with_error(std_err or 'output of the command: %s\n%s' % (prepared_command, output))

            try:
                result = json.loads(output)
                return self.parse_result(called_method, result)
            except ValueError as e:
                exit_with_error(e)
        else:
            exit_with_error('Not allowed operation: {}'.format(called_method))


class CPanelAdminApi(CPanelApi):
    COMMAND = '/usr/local/cpanel/bin/whmapi1'
    ALLOWED_OPERATIONS = [
        {
            'method': 'php_get_vhost_versions',
            'parser': lambda result: CPanelAdminApi.parse_vhost_versions(result)
        },
        {'method': 'php_get_system_default_version'},
        {'method': 'php_set_vhost_versions'},
        {'method': 'php_get_installed_versions'}
    ]

    def check_operation_allowed(self, called_method):
        return self.check_method_allowed(called_method, self.ALLOWED_OPERATIONS)

    def prepare_running_command(self, called_method, params, return_json):
        transformed_params = {}
        for param in params:
            key, value = param.split('=')
            transformed_params[key] = value
        return transformed_params

    def parse_result(self, called_method, result):
        parser = self.get_method_parser(called_method, self.ALLOWED_OPERATIONS)
        if parser is not None:
            return parser(result)
        return result

    @staticmethod
    def parse_vhost_versions(result):
        parsed_result = []
        for r in result['versions']:
            parsed_result.append({
                'version': r.get('version'),
                'host': r.get('vhost'),
                'user': r.get('account'),
                'php_fpm': True if r.get('php_fpm') else False,
                'inherited': True if r.get('php_version_source', {}).get('sys_default') == 1 else False
            })
        return parsed_result

    def run(self, called_method, params, return_json=True):
        params = self.prepare_running_command(called_method, params, return_json)
        if self.check_operation_allowed(called_method):
            try:
                return self.parse_result(called_method, WhmApiRequest(called_method).with_arguments(**params).call())
            except WhmApiError as e:
                print(e)
                sys.exit(1)
        else:
            exit_with_error('Not allowed operation: {}'.format(called_method))


class CPanelUserApi(CPanelApi):
    COMMAND = '/usr/bin/uapi'
    USER_BINDING_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]*$')
    ALLOWED_OPERATIONS = {
        'LangPHP': [
            {'method': 'php_set_vhost_versions'},
            {
                'method': 'php_get_installed_versions',
                'ignore_errors': True,
            },
            {
                'method': 'php_get_vhost_versions',
                'parser': lambda result: CPanelApi.parse_vhost_versions(result),
                'ignore_errors': True,
            },
            {
                'method': 'php_get_system_default_version',
                'ignore_errors': True,
            }
        ]
    }

    def __init__(self, scoped_user=None):
        # scoped_user pins the UAPI call to a specific cPanel account by
        # prepending `--user=<scoped_user>` to argv. Set for the reseller
        # path (see get_cpanel_api_class) so a reseller cannot influence
        # state on accounts other than their own. Left None for the
        # `owner == 'user'` path where the process already drops uid to
        # the user and UAPI infers scope from kernel identity.
        if scoped_user is not None and not self.USER_BINDING_RE.match(scoped_user):
            exit_with_error('Invalid scoped_user: {}'.format(scoped_user))
        self.scoped_user = scoped_user

    def check_class_allowed(self, class_name):
        """
        CPanel UAPI requires to pass class name and
        only few of classes are supported by this utility.
        """
        return class_name in self.ALLOWED_OPERATIONS.keys()

    def check_operation_allowed(self, called_method):
        """
        Checks whether operation is allowed or not.
        By CPanel UAPI supports several operations
        but only few of them can be called via this utility.
        """
        class_name, method_name = self.extract_class_and_method(called_method)
        if self.check_class_allowed(class_name):
            return self.check_method_allowed(method_name, self.ALLOWED_OPERATIONS[class_name])
        return False

    @staticmethod
    def extract_class_and_method(called_method):
        try:
            class_name, method_name = called_method.split('::')
            return class_name, method_name
        except ValueError:
            exit_with_error('Invalid method is passed: {}'.format(called_method))

    def prepare_running_command(self, called_method, params, return_json):
        class_name, method_name = self.extract_class_and_method(called_method)
        argv = [self.COMMAND]
        if self.scoped_user:
            # Bind the call to a specific cPanel account; mirrors the
            # `cpapi2 --user=$CURRENT_USER` shape used by
            # cpanel/cgi/CloudLinux.pm:282-283 for the same reason.
            argv.append('--user=' + self.scoped_user)
        argv += [class_name, method_name] + params
        if return_json:
            argv.append(self.RETURN_JSON)
        return argv

    def parse_result(self, called_method, result):
        class_name, method_name = self.extract_class_and_method(called_method)
        ignore_errors = self.get_ignore_errors(method_name, self.ALLOWED_OPERATIONS[class_name])
        self.check_for_errors(result, ignore_errors)
        parser = self.get_method_parser(method_name, self.ALLOWED_OPERATIONS[class_name])
        if parser is not None:
            return parser(result['result']['data'])
        return result['result']['data']

    def check_for_errors(self, result, ignore_errors = False):
        errors = result['result'].get('errors')
        if not errors:
            return
        for error in errors:
            # TODO: The next line is workaround of cPanel issue CPANEL-35122
            # see https://support.cpanel.net/hc/en-us/articles/1500004317181-PHP-Selector-change-to-CloudLinux-PHP-version-reports-a-false-error
            if error in UAPI_IGNORED_ERRORS:
                continue
            exit_with_error(' '.join(errors), ignore_errors=ignore_errors)
utils/node_wrapper000075500000000416152725704130010326 0ustar00#!/bin/bash

if [[ $EUID -eq 0 ]]; then
   echo "This program is not intended to be run as root." 1>&2
   exit 1
fi

CWD=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
source ${CWD}/activate
eval "$(${CWD}/set_env_vars.py nodejs)"

exec "${CL_NODEHOME}/usr/bin/node" "$@"utils/libcloudlinux.py000064400000127055152725704130011153 0ustar00# coding:utf-8

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import copy
import sys
import json
import argparse
import socket
import base64
import os
import subprocess
import re
from enum import Enum
from dataclasses import dataclass
from typing import Callable, List, Optional

from past.builtins import basestring, unicode  # noqa
from future.utils import iteritems
from clcommon.utils import silence_stdout_until_process_exit, get_cl_version, is_ubuntu
from cllicense import CloudlinuxLicenseLib
from cpanel_api import get_cpanel_api_class
from clcommon.lib.cledition import is_cl_solo_edition, is_container
from clcommon.cpapi import is_hitting_max_accounts_limit, get_main_username_by_uid

LVEMANAGER_PLUGIN_NAMES = {
    "python_selector": "Python Selector",
    "nodejs_selector": "Node.js Selector",
    "php_selector": "PHP Selector",
    "resource_usage": "Resource Usage",
    "wpos": "AccelerateWP",
}
PASSENGER_DEPEND_PLUGINS = ["python_selector", "nodejs_selector"]
DEFAULT_PLUGIN_NAME = "CloudLinux Manager"

CAGEFS_ENTER_PROXIED_BIN = "/usr/bin/cagefs_enter.proxied"
if not os.path.exists(CAGEFS_ENTER_PROXIED_BIN):
    CAGEFS_ENTER_PROXIED_BIN = "/bin/cagefs_enter.proxied"

LVE_SUWRAPPER_BIN = "/usr/bin/lve_suwrapper"


def parse_lve_id(value):
    """
    Return `value` as a non-negative int when it is a usable lve id (an int
    or an ASCII decimal string), else None. Never raises.

    CLOS-6970: panel plugins send user_info['lve-id'] and it ends up as an
    lve_suwrapper argument; '' / ' ' / 'abc' / None / bools are not ids.
    ASCII only: str.isdigit() is also true for '²' or Arabic-Indic digits,
    which int() rejects or reads as a different number.
    """
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value if value >= 0 else None
    if isinstance(value, str):
        text = value.strip()
        if text.isascii() and text.isdigit():
            return int(text)
    return None


def is_blank(value):
    """True for None and for strings that are empty or whitespace only."""
    return value is None or (isinstance(value, str) and value.strip() == "")


def is_json(data):
    try:
        json.loads(data)
        return True
    except ValueError as error:
        return False


class EnterFlagType(Enum):
    """Enum for different types of enter flags"""

    IO_AND_MEMORY_LIMIT = "io_and_memory_limit"
    CPU_LIMIT = "cpu_limit"
    MAX_ENTER = "max_enter"


class EnterTool(Enum):
    """Enum for different enter tools"""

    CAGEFS_ENTER_PROXIED = "cagefs_enter.proxied"
    LVE_SUWRAPPER = "lve_suwrapper"


@dataclass
class EnterFlags:
    """Abstraction layer for enter flags that can be converted between different tools"""

    no_io_and_memory_limit: bool = False
    no_cpu_limit: bool = False
    no_max_enter: bool = False

    def to_cagefs_args(self) -> List[str]:
        """Convert to cagefs_enter.proxied format"""
        args = []
        if self.no_io_and_memory_limit:
            args.append("--no-io-and-memory-limit")
        if self.no_cpu_limit:
            args.append("--no-cpu-limit")
        if self.no_max_enter:
            args.append("--no-max-enter")
        return args

    def to_lve_suwrapper_args(self) -> List[str]:
        """Convert to lve_suwrapper format"""
        args = []
        if self.no_io_and_memory_limit:
            args.append("-m")
        if self.no_cpu_limit:
            args.append("-c")
        if self.no_max_enter:
            args.append("-e")
        return args

    @classmethod
    def no_limits(cls) -> "EnterFlags":
        """Create flags for no limits"""
        return cls(no_io_and_memory_limit=True, no_cpu_limit=True, no_max_enter=True)

    @classmethod
    def all_limits(cls) -> "EnterFlags":
        """Create flags for all limits"""
        return cls(no_io_and_memory_limit=False, no_cpu_limit=False, no_max_enter=False)


class CloudlinuxCliBase(object):
    request_data = {}
    result = None
    available_request_params = [
        "owner",
        "command",
        "method",
        "params",
        "user_info",
        "mockJson",
        "attachments",
        "plugin_name",
        "lang",
    ]
    # Keys every request payload must carry. Validated by
    # check_required_params() from parsing_request_data(), before any
    # consumer dereferences them, so a malformed payload returns JSON -- the
    # utilities' stdout contract, which the panel plugins json_decode() --
    # instead of a Python traceback (CLOS-6971).
    required_request_params = [
        "owner",
        "command",
    ]
    NOT_FLAGGED_PARAMS = [
        "config-files",
        "content",
        "passenger-log-file",
        "ignore-list",
        "wp-path",
        "upgrade-url",
    ]
    license_is_checked = False
    current_plugin_name = ""
    licence = CloudlinuxLicenseLib()

    def __init__(self):
        self.skip_cagefs_check = False
        self.user_info = {}
        self.parsing_request_data()
        self.check_xss()
        self.drop_permission()
        self.command_methods = {
            "spa-ping": self.spa_ping,
            "cloudlinux-top": self.cl_top,
            "cloudlinux-selector": self.cl_selector,
            "cloudlinux-statistics": self.cl_statistics,
            "cloudlinux-charts": self.cl_chart,
            "cloudlinux-quota": self.cl_quota,
            "cpanel-api": self.cpanel_api,
            "cloudlinux-xray-user-manager": self.cl_xray_user_manager,
            "cloudlinux-statsnotifier": self.cl_statsnotifier,
            "cloudlinux-awp-user": self.cloudlinux_awp_user,
            "cl-smart-advice-user": self.cl_smart_advice_user,
            "cl-install-plugin": self.cl_install_plugin,
        }

    def check_xss(self):
        for key in self.request_data.keys():
            if key not in self.available_request_params:
                self.exit_with_error("BAD REQUEST 1:" + key)

        # Read once via .get(): "command" is a required key (see
        # required_request_params) but check_xss() must not raise KeyError if
        # it is absent -- with no command none of the per-command exemptions
        # below apply and the strict `else` branch is the correct fail-safe.
        command = self.request_data.get("command")

        for name, val in iteritems(self.request_data):
            if isinstance(val, dict):  # if post key is "params"
                for key, inner_value in iteritems(val):
                    self.check_param_key(key)
                    if (
                        command == "cloudlinux-packages"
                        and name == "params"
                        and key == "package"
                    ):
                        self.request_data[name][key] = self.escape_param_value(
                            inner_value
                        )
                    elif command == "cloudlinux-support":
                        pass
                    elif (
                        command == "cloudlinux-selector"
                        and name == "params"
                        and key == "options"
                    ):
                        pass
                    elif (
                        command == "cloudlinux-selector"
                        and name == "params"
                        and key == "env-vars"
                    ):
                        # env-vars arrives as JSON.stringify(dict). The
                        # shell-char filter in check_param_value would reject
                        # legitimate values like $PATH. validate_env_vars
                        # rejects CR/LF after json.loads; the values are no
                        # longer written to .htaccess (except for LiteSpeed
                        # older than 6.0.10), and set_env_vars.py
                        # \xHH-escapes them for the wrappers' eval, so they
                        # arrive literally (CLOS-7896).
                        pass
                    elif (
                        command == "lvectl"
                        and name == "params"
                        and key == "stdin"
                    ):
                        pass
                    elif (
                        command == "cloudlinux-xray-manager"
                        and name == "params"
                        and key == "url"
                    ):
                        pass
                    elif (
                        command == "cloudlinux-xray-user-manager"
                        and name == "params"
                        and key == "url"
                    ):
                        pass
                    elif (
                        command == "wmt-api"
                        and name == "params"
                        and key == "config-change"
                    ):
                        pass
                    elif (
                        command == "cloudlinux-xray-manager"
                        and name == "params"
                        and key == "email"
                    ):
                        pass
                    elif (
                        command == "cloudlinux-awp-admin"
                        and name == "params"
                        and key == "upgrade-url"
                    ):
                        pass
                    else:
                        self.check_param_value(inner_value)
            else:
                self.check_param_value(val)

    def get_env(self):
        """
        Get env for subprocess call
        """
        env_copy = os.environ.copy()
        if self.request_data.get("lang"):
            lang = self.request_data.get("lang")
            if not re.match(r"^[a-z]{2}$", lang):
                lang = "en"
            env_copy["LC_ALL"] = lang
        return env_copy

    def get_server_ip(self):
        """
        Get the server's IP address.
        """
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            return s.getsockname()[0]
        except Exception as e:
            return None

    def check_param_key(self, key):
        # Reject any key that begins with "-": cpanel_api() renders keys
        # to argv with ``add_dash=False`` (libcloudlinux.py:480), so a
        # caller-supplied "--user" would be emitted verbatim into the
        # /usr/bin/uapi argv as "--user=victim", smuggling a global UAPI
        # option past check_operation_allowed (cpanel_api.py:231) which
        # only gates class/method. Requiring a leading word character
        # also defangs add_dash=True paths where a key like "-foo" could
        # be rendered as "---foo=...".
        if not re.search(r"^\w[\w\-]*$", key):
            self.exit_with_error("BAD REQUEST 2")

    def check_param_value(self, val):
        # The character set below intentionally only contains shell
        # metacharacters that could enable command/argument injection
        # when a value is passed to a subprocess. HTML/JS-relevant
        # characters (``<``, ``>``, ``'``, ``"``) are deliberately NOT
        # filtered here: this is the CLI input boundary, not a render
        # boundary. XSS is handled by output-escaping in the SPA and in
        # any cgi/template layer that emits HTML; rejecting those chars
        # on input would break legitimate payloads — e.g. the Options
        # tab posts JSON-stringified settings that contain literal
        # ``"``, and a previous attempt to add ``<>'"`` to this regex
        # (commit 1bc596507, reverted) broke reseller-tests-ui's
        # "Options tab → Testing saving of config" suite by making
        # every save return HTTP 503.
        if isinstance(val, basestring):
            if re.search("[`\|\$;&\n]", val, re.M):
                self.exit_with_error("BAD REQUEST 3")

    def escape_param_value(self, val):
        chars = "\\\"'"
        for c in chars:
            val = val.replace(c, "\\" + c)
        return val

    def main(self):
        command = self.request_data["command"]
        endpoint = self.command_methods.get(command)
        allowed_methods = [
            "cloudlinux-license",
            "external-info",
            "spa-get-user-info",
        ]  # not requires license check
        if endpoint:
            if not self.license_is_checked and command not in allowed_methods:
                self.check_license()
            if "mockJson" in self.request_data:
                self.spa_mock(self.request_data["mockJson"])
            endpoint()
        else:
            if command:
                self.exit_with_error("No such module " + command)
            else:
                self.exit_with_error("Command not defined")

    def parsing_request_data(self):
        """
        parsing entry data, encode it from base64 to dictionary
        :return:
        """
        parser = argparse.ArgumentParser()
        parser.add_argument("--data")
        parser.add_argument("--skip-cagefs-check", action="store_true", default=False)
        try:
            arguments = parser.parse_args()
        except:
            self.exit_with_error("Unknown param in request")

        if arguments.data:
            data_in_base64 = arguments.data
            data_in_json = base64.b64decode(data_in_base64).decode("utf-8")
            try:
                self.request_data = json.loads(data_in_json)
                self.skip_cagefs_check = arguments.skip_cagefs_check
            except ValueError:
                self.exit_with_error("Need json-array")
            self.check_required_params()
            self.user_info = self.get_user_info()
            self.define_current_plugin()
        else:
            self.exit_with_error("No --data param in request")

    def check_required_params(self):
        """
        Reject a payload that omits a required contract key.

        Runs before get_user_info() so the failure is reported through
        exit_with_error() (JSON on stdout, exit 1) rather than escaping as
        KeyError from whichever consumer dereferences the key first.
        """
        missing = [
            key for key in self.required_request_params
            if key not in self.request_data
        ]
        if missing:
            self.exit_with_error("BAD REQUEST 4:" + ",".join(missing))

    # Scanner note (F-54, CLOS-5568, 2026-07-17): --data-supplied user_info
    # flagged as spoofable local-user auth-bypass. Refuted: identity here is
    # internal plumbing, not a privilege boundary -- every downstream target
    # (/var/lve/lvestats2.db 0644, /proc/lve/list 0444, /var/lve/json-stats/
    # *.json 0644; /usr/sbin/cloudlinux-{statistics,top,chart} non-suid and
    # honor --id verbatim) is already baseline-readable to any local shell.
    def get_user_info(self):
        """
        user_info as the panel plugin sent it, healed from the process
        identity when the plugin could not resolve it.

        For owner=user the worker IS the user, so the effective uid is the
        authoritative identity whenever the plugin's value is unusable.
        CLOS-6970: only None used to trigger the heal; the DirectAdmin plugin
        sent 'lve-id': '' (SpaManager::getUID() answered '' when ext-posix was
        unavailable) and str('') went into the lve_suwrapper argv as an empty
        lve_id. Blank and non-numeric values now heal exactly like None.

        The heal only makes sense when the process really is the user: under
        the root-side cloudlinux-cli.py (which admits owner=user for a few
        commands) the euid is 0, and "root" is never the right answer there -
        the unusable value is left alone for the callee's own validation.
        """
        user_info = self.request_data.get("user_info") or {}
        euid = os.geteuid()
        if self.request_data.get("owner") == "user" and user_info and euid != 0 and (
            any(is_blank(value) for value in user_info.values())
            or ("lve-id" in user_info and parse_lve_id(user_info["lve-id"]) is None)
        ):
            username = get_main_username_by_uid(euid)
            user_info = {"username": username, "lve-id": euid}
        return user_info

    def cl_top(self):
        list_to_request = self.prepair_params_for_command()
        command = ["/usr/sbin/cloudlinux-top"] + list_to_request
        try:
            p = subprocess.Popen(
                command,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )
            result, err = p.communicate()
            exitcode = p.returncode
        except Exception as e:
            self.exit_with_error(
                "Can't run %(command)s",
                context={"command": " ".join(command)},
            )
            return

        if self.request_data.get("owner") == "user":
            json_result = {}
            try:
                json_result = json.loads(result)
            except Exception:
                self.exit_with_error(result or err)

            if json_result.get("result") != "success":
                self.exit_with_error(
                    json_result.get("result"),
                    json_result.get("context"),
                    ignore_errors=True,
                )
        print(result)
        silence_stdout_until_process_exit()
        sys.exit(exitcode)

    def cl_quota(self):
        list_to_request = self.prepair_params_for_command()
        result = self.run_util(
            "/usr/bin/cl-quota", *list_to_request, ignore_errors=True
        )
        print(result)

    def cl_xray_user_manager(self):
        list_to_request = self.prepair_params_for_command()
        list_to_request.remove("--json")
        result = self.run_util(
            "/opt/alt/php-xray/cloudlinux-xray-user-manager",
            *list_to_request,
            ignore_errors=False,
        )
        print(result)

    def cl_smart_advice_user(self):
        cli_command = "/opt/alt/php-xray/cl-smart-advice-user"
        list_to_request = self.prepair_params_for_command(with_json=False)
        # Workaround to run the command in background
        if "--async" in list_to_request:
            subprocess.Popen(
                [cli_command, *list_to_request],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )
            self.exit_with_success()
        result = self.run_util(cli_command, *list_to_request, ignore_errors=False)
        print(result)

    def cpanel_api(self):
        owner = self.request_data.get("owner")
        method = self.request_data.pop("method")
        list_to_request = self.prepair_params_for_command(
            with_json=False, add_dash=False
        )
        # For the reseller path the factory requires the caller's cPanel
        # username so the UAPI call is bound to that account (mirrors the
        # `cpapi2 --user=$CURRENT_USER` pattern in cpanel/cgi/CloudLinux.pm).
        # user_info is populated by the WHM CGI wrapper from REMOTE_USER /
        # TEAM_OWNER and is not attacker-controlled.
        username = (self.user_info or {}).get("username")
        cpanel_api = get_cpanel_api_class(owner, username=username)
        self.exit_with_success({"data": cpanel_api.run(method, list_to_request)})

    # Keys accepted from external request for cloudlinux-charts.
    # Strict allowlist: lvechart's argparse permits unique-prefix abbreviations
    # (e.g. ``--o`` resolves to ``--output``), so a deny-by-prefix check on the
    # rendered argv is not sufficient. Limited to the set actually used by the
    # SPA chart callers; extend deliberately if a new client needs more.
    CHART_ALLOWED_PARAM_KEYS = frozenset((
        "format", "period", "from", "to", "id", "style", "domain",
    ))

    def cl_chart(self):
        # cl_chart does not use ``method`` for command dispatch; if the field
        # is present, ``prepair_params_for_command`` would split it on spaces
        # and inject the resulting tokens directly into the ``lvechart`` argv
        # before parameter rendering, sidestepping the param-key allowlist.
        if "method" in self.request_data:
            self.exit_with_error("BAD REQUEST 2")
        params = self.request_data.get("params") or {}
        for key in params:
            if key not in self.CHART_ALLOWED_PARAM_KEYS:
                self.exit_with_error("BAD REQUEST 2")
        # When the caller is a panel user, pin the chart scope to the
        # server-authenticated lve-id (populated by the CGI wrapper from
        # REMOTE_USER / TEAM_OWNER, not by the client). Without this pin
        # a user session could smuggle ``id=<victim-lve>`` verbatim into
        # the /usr/sbin/lvechart argv and read another tenant's chart
        # data. Mirrors the analogous pin already in cl_statistics().
        if self.request_data.get("owner") == "user":
            params = self.request_data.setdefault("params", {}) or {}
            self.request_data["params"] = params
            params["id"] = str(self.user_info["lve-id"])
        list_to_request = self.prepair_params_for_command()
        try:
            list_to_request.remove("--json")
        except ValueError:
            pass
        list_to_request.insert(0, "/usr/sbin/lvechart")
        try:
            response = subprocess.check_output(list_to_request, shell=False, text=True, stderr=subprocess.PIPE)
        except subprocess.CalledProcessError as error:
            self.exit_with_error((error.stderr or error.output or '').strip() or 'Chart is unavailable')
        print(json.dumps({"result": "success", "chart": response}))
        silence_stdout_until_process_exit()
        sys.exit(0)

    def drop_permission(self):
        """
        Drop permission to users, if owner of script is user
        :return:
        """
        data = self.request_data
        # Presence only, on purpose. A reseller is addressed by username in
        # the root-side CLI (--for-reseller) and is not necessarily a system
        # user, so panels legitimately send a non-numeric or empty lve-id for
        # owner=reseller (Plesk resellers: the reseller statistics page broke
        # with "User id does not specified" when CLOS-6970 briefly validated
        # the value here). The lve-id is only ever an lve_suwrapper argument
        # in cloudlinux_cli_user.py, which validates it itself.
        if data.get("owner") in ["reseller", "user"] and (
            "lve-id" not in self.user_info or "username" not in self.user_info
        ):
            self.exit_with_error("User id does not specified")

    def prepair_params_for_command(
        self, with_json=True, escaped_strings=False, add_dash=True
    ):
        """
        Method that converts given dict of parameters
        into list of strings that should be passed
        as arguments command-line application
        :param with_json: add --json argument
        :param escaped_strings: ONLY FOR BACKWARDS COMPATIBILITY!
                                SHOULD BE False FOR ALL NEW METHODS!
        :param add_dash: if we need to add dashes to params
        :return:
        """
        value_template = "--{0}={1}" if add_dash else "{0}={1}"
        data = copy.deepcopy(self.request_data)
        list_to_request = []
        if "method" in data:
            # ``method`` is split on spaces and each token becomes its own
            # argv element ahead of the validated params block. A reseller
            # caller controlling ``method`` could otherwise smuggle e.g.
            # "get --user victim" past check_xss (which only filters
            # shell-meta values), turning the downstream selector / UAPI
            # argv into an attacker-controlled flag injection. Restrict
            # each token to subcommand-name shape: letters, digits,
            # dot, dash, underscore, colon (for cPanel class::method),
            # and explicitly no leading dash.
            for method in data["method"].split(" "):
                if not method:
                    continue
                if not re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.:\-]*$", method):
                    self.exit_with_error("BAD REQUEST 2")
                list_to_request.append(method)

        if "params" not in data:
            data["params"] = {}
        if "json" not in data["params"] and with_json:
            data["params"]["json"] = ""

        for param, value in iteritems(data["params"]):
            if param != "additional-params":
                # TODO: looks like we can remove option escaped_strings
                # and always use value.encode('utf-8') here
                # same goal may be reached using utils.byteify(json.loads(...))
                # but to do that, we need some tests covering unicode params
                # (especially for cloudlinux-packages)
                # unfortunately, we do not have one ;(
                # THIS IS NEEDED ONLY FOR CL-PACKAGES UTILITY
                if value and escaped_strings is True:
                    list_to_request.append(
                        value_template.format(
                            param, value.encode("unicode-escape").decode()
                        )
                    )
                elif (
                    value or param in self.NOT_FLAGGED_PARAMS
                ) and escaped_strings is False:
                    list_to_request.append(value_template.format(param, value))
                else:
                    list_to_request.append("--{0}".format(param))
        if self.request_data["owner"] == "reseller":
            list_to_request.append(
                "--for-reseller={0}".format(self.user_info["username"])
            )

        if (
            "additional-params" in data["params"]
            and data["params"]["additional-params"] != ""
        ):
            list_to_request.append("--")
            for param in data["params"]["additional-params"].split():
                list_to_request.append("{0}".format(param))

        return list_to_request

    def is_edition_migration_available(self):
        # check if edition migration is supported
        return os.path.isfile("/usr/sbin/clncheck")

    def update_license(self):
        # Register by broken license
        with open(os.devnull, "w") as devnull:
            clnreg_cmd = ["/usr/sbin/clnreg_ks", "--force"]
            if self.is_edition_migration_available():
                clnreg_cmd.append("--migrate-silently")
            subprocess.call(clnreg_cmd, stderr=devnull, stdout=devnull, shell=False)
            subprocess.call(
                ["/usr/bin/cldetect", "--update-license"],
                stderr=devnull,
                stdout=devnull,
                shell=False,
            )

        self.check_license(False)

    def check_license(self, with_recovery=True):
        if not self.kernel_is_supported():
            if self.request_data["owner"] in ["reseller"]:
                self.exit_with_error(
                    code=503,
                    error_id="ERROR.not_available_plugin",
                    context={
                        "pluginName": LVEMANAGER_PLUGIN_NAMES.get(
                            self.current_plugin_name, DEFAULT_PLUGIN_NAME
                        )
                    },
                    icon="disabled",
                )
            elif self.request_data["owner"] in ["admin"]:
                self.exit_with_error("Kernel is not supported")

        if is_hitting_max_accounts_limit():
            if self.request_data["owner"] == "admin":
                self.exit_with_error("ERROR.hitting_max_accounts_limit")
            if self.request_data["owner"] == "user":
                self.exit_with_error(
                    code=503,
                    error_id="ERROR.not_available_plugin",
                    context={
                        "pluginName": LVEMANAGER_PLUGIN_NAMES.get(
                            self.current_plugin_name, DEFAULT_PLUGIN_NAME
                        )
                    },
                    icon="disabled",
                )
        if not self.licence.get_license_status():
            if self.request_data["owner"] in ["reseller", "user"]:
                interpreter = "nodejs"
                if self.request_data.get("params") and self.request_data["params"].get(
                    "interpreter"
                ):
                    interpreter = self.request_data["params"]["interpreter"]
                pluginNames = {
                    "reseller": "CloudLinux Manager",
                    "user": {
                        "python": "Python Selector",
                        "nodejs": "Node.js Selector",
                    }.get(interpreter, "Node.js Selector"),
                }
                self.exit_with_error(
                    code=503,
                    error_id="ERROR.not_available_plugin",
                    context={
                        "pluginName": LVEMANAGER_PLUGIN_NAMES.get(
                            self.current_plugin_name, DEFAULT_PLUGIN_NAME
                        )
                    },
                    icon="disabled",
                )
            else:
                if with_recovery:
                    self.update_license()
                else:
                    self.exit_with_error("License is not valid")
        else:
            self.license_is_checked = True

    def exit_with_error(
        self,
        error_string="",
        context=None,
        code=None,
        error_id=None,
        icon=None,
        ignore_errors=False,
    ):
        result = {"result": error_string}
        if context:
            result["context"] = context
        if code:
            result["code"] = code
        if error_id:
            result["error_id"] = error_id
        if icon:
            result["icon"] = icon
        if ignore_errors:
            result["ignore"] = ignore_errors
        print(json.dumps(result))
        sys.exit(1)

    def exit_with_success(self, response=None):
        data = copy.deepcopy(response) if response else {}
        data["result"] = "success"
        print(json.dumps(data))
        sys.exit(0)

    def cl_statistics(self):
        # When the caller is a panel user, pin the LVE scope to the
        # server-authenticated lve-id (populated by the CGI wrapper from
        # REMOTE_USER / TEAM_OWNER, not by the client). The reseller branch
        # of prepair_params_for_command already appends a server-side
        # `--for-reseller=<authenticated-username>` for the same utility
        # family — without an analogous pin for owner='user' a request body
        # could smuggle `id=<victim-lve>` (or `user=<victim>`) verbatim into
        # the /usr/sbin/cloudlinux-statistics argv and read another tenant's
        # data, defeating the lve_suwrapper drop. See CHART_ALLOWED_PARAM_KEYS
        # for the analogous expectation that `id` is an argv-trusted scope.
        if self.request_data.get("owner") == "user":
            params = self.request_data.setdefault("params", {}) or {}
            self.request_data["params"] = params
            params["id"] = str(self.user_info["lve-id"])
            params.pop("user", None)
        list_to_request = self.prepair_params_for_command()
        command = ["/usr/sbin/cloudlinux-statistics"] + list_to_request
        try:
            p = subprocess.Popen(
                command,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )
            result, err = p.communicate()
        except Exception as e:
            self.exit_with_error(
                "Can't run %(command)s",
                context={"command": " ".join(command)},
            )
            return

        result = self.adjust_statistics_output(result)
        if result:
            print(result, end="")
        if p.returncode != 0 and err:
            print(err, end="", file=sys.stderr)
        silence_stdout_until_process_exit()
        sys.exit(p.returncode)

    def adjust_statistics_output(self, output):
        """Hook for panel-specific rewriting of cloudlinux-statistics output."""
        return output

    def spa_mock(self, file):
        file_path = "/usr/share/l.v.e-manager/spa/src/jsons/%s.json" % (file)
        # check if passed file param doesn't use relative path. E.g.: '../../file'
        if os.path.realpath(file_path) != file_path:
            self.exit_with_error("BAD REQUEST 3")
        with open(file_path, "r") as f:
            print(f.read())
        sys.exit(0)

    def get_lve_version(self):
        try:
            ver = subprocess.check_output(
                "cat /proc/lve/list | grep -Po '^\d{1,2}:'",
                shell=True,
                executable="/bin/bash",
                text=True,
            ).strip()
            return int(ver[:-1])
        except:
            return 0

    def get_cloudlinux_version(self):
        return subprocess.check_output(
            "uname -r | grep -Po 'el\d\w?'",
            shell=True,
            executable="/bin/bash",
            text=True,
        ).strip()

    # Common methods

    def spa_ping(self):
        self.exit_with_success()

    def cl_selector(self):
        try:
            from clselector.cl_selector import CloudlinuxSelector
        except Exception as e:
            self.exit_with_error(f"Module unavailable: {e}")
        if (
            self.user_info.get("username")
            and "interpreter" in self.request_data["params"]
            and self.request_data["params"]["interpreter"] == "php"
        ):
            self.check_php_selector_user_availablility()

        list_to_request = self.prepair_params_for_command()
        cll = CloudlinuxSelector()
        # ``cloudlinux-selector`` returns a status rather than exiting for
        # ordinary command failures.  This process is the cPanel endpoint
        # after the CageFS re-exec, so dropping a nonzero value turns a failed
        # npm resolution into an HTTP-successful command.  Keep successful
        # commands on their established fall-through path.
        exit_code = cll.run(list_to_request)
        if exit_code:
            sys.exit(exit_code)

    def check_php_selector_user_availablility(self):
        """
        Additional check only for php selector
        :return:
        """
        try:
            LIBDIR = "/usr/share/cagefs"
            sys.path.append(LIBDIR)
            import cagefsctl

            if not cagefsctl.cagefs_is_enabled or not cagefsctl.is_user_enabled(
                self.user_info["username"]
            ):
                raise RuntimeError("Cagefs is disabled or missing")

        except (ImportError, RuntimeError):
            self.exit_with_error(
                code=503,
                error_id="ERROR.cagefsDisabled",
            )
        from clselect.clselectexcept import BaseClSelectException

        try:
            from clselect import ClSelect

            ClSelect.check_multiphp_system_default_version()
        except BaseClSelectException:
            self.exit_with_error(
                code=503,
                error_id="ERROR.systemVersionAltPHP",
            )

    def define_current_plugin(self):
        self.current_plugin_name = self.request_data.get("plugin_name")

    def is_error_response_default(self, json_result):
        return (
            json_result.get("result") != "success" and json_result.get("success") != 1
        )

    def run_util(self, name, *args, **kwargs):
        command = [name] + list(args)
        error_checker = kwargs.get("error_checker", self.is_error_response_default)
        try:
            p = subprocess.Popen(
                command,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                env=kwargs.get("env", self.get_env()),
            )
            (result, err) = p.communicate(kwargs.pop("stdin", None))
            is_error = p.returncode != 0 or not is_json(result)
            if not is_error:
                json_result = json.loads(result)

                is_error = error_checker(json_result)
            if is_error:
                result = result + err
                if is_json(result):
                    json_result = json.loads(result)
                    if json_result.get("message"):
                        json_result["result"] = json_result.pop("message")
                        result = json.dumps(json_result)
                if kwargs.get("ignore_errors", False):
                    # Check new result concatenated with error
                    if is_json(result):
                        result = json.loads(result)
                        result["ignore"] = True
                        result = json.dumps(result)
                    else:
                        result = self.ignored_error_message(result)
                print(result)
                exit(1)
            return result
        except Exception as e:
            self.exit_with_error(
                "Can't run %(command)s",
                context={"command": " ".join(command)},
                ignore_errors=True,
            )

    def ignored_error_message(self, message):
        return json.dumps({"result": message, "ignore": True})

    def kernel_is_supported(self):
        try:
            if is_container():
                return True
            if is_cl_solo_edition(skip_jwt_check=True):
                # Ubuntu uses standard kernel,
                # CL9 uses Alma kernel which doesn't have 'lve' in its name
                if is_ubuntu() or get_cl_version() in ["cl9", "cl10"]:
                    return True
                uname = subprocess.check_output(
                    "uname -r", shell=True, executable="/bin/bash", text=True
                )
                return "lve" in uname
            else:
                f = open("/proc/lve/list", "r")
                line = f.readline()
                f.close()
                return bool(line)
        except IOError:
            return False

    def cl_statsnotifier(self):
        list_to_request = self.prepair_params_for_command(with_json=True)
        command = ["/usr/sbin/cloudlinux-statsnotifier"] + list_to_request
        try:
            p = subprocess.Popen(
                command,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )
            result, err = p.communicate()
        except Exception as e:
            self.exit_with_error(
                "Can't run %(command)s",
                context={"command": " ".join(command)},
            )
            return

        if result:
            print(result, end="")
        if p.returncode != 0 and err:
            print(err, end="", file=sys.stderr)
        sys.exit(p.returncode)

    def cloudlinux_awp_user(self):
        cli_command = "/usr/bin/cloudlinux-awp-user"
        list_to_request = self.prepair_params_for_command(with_json=False)
        result = self.run_util(cli_command, *list_to_request, ignore_errors=False)
        print(result)

    def cl_install_plugin(self):
        """
        This method is needed just for dev server to allow work with mocks
        """
        self.exit_with_success()


# user cli parts


class CommandType(Enum):
    HEAVY = "heavy"
    SIMPLE = "simple"


class ConfigLimitValue(Enum):
    ALL = "all"  # limit all requests
    HEAVY = "heavy"  # don't limit white-listed 'simple' requests
    UNLIMITED = "unlimited"  # don't limit at all

    @classmethod
    def _missing_(cls, value):
        return cls.ALL


@dataclass
class Rule:
    callable: Callable
    result: CommandType


class LimitStrategyBase:
    """
    Base limits strategy to decide - run incoming request with or without cagefs limits
    Strategy execution means that used script (cloudlinux_cli_user.py) will be re-executed with (or not)
        additional enter flags for the selected utility
    """

    enter_flags: EnterFlags
    enter_tool: EnterTool = (
        EnterTool.CAGEFS_ENTER_PROXIED
    )  # Default to cagefs_enter.proxied

    def execute(
        self, uid: int, command: str, args: List[str], request_data: dict
    ) -> Optional[int]:
        full_command = self.get_full_command(uid, command, args, request_data)
        p = subprocess.Popen(full_command)
        p.communicate()
        return p.returncode

    def get_full_command(
        self, uid: int, command: str, args: List[str], request_data: dict
    ) -> List[str]:
        cmd = [*sys.argv, f"--skip-cagefs-check"]

        if self.enter_tool == EnterTool.LVE_SUWRAPPER:
            lve_id = parse_lve_id(uid)
            if lve_id is None:
                # CLOS-6970: str('') is '' — lve_suwrapper would receive an
                # empty lve_id and print its usage text instead of running
                # the command. Callers validate earlier; this is the last line.
                raise ValueError(
                    "refusing to run lve_suwrapper with an unusable lve_id: %r" % (uid,)
                )
            return [
                LVE_SUWRAPPER_BIN,
                "-n",  # bypass cagefs namespace
                *self.enter_flags.to_lve_suwrapper_args(),
                str(lve_id),
                *cmd,
            ]
        else:
            return [CAGEFS_ENTER_PROXIED_BIN, *self.enter_flags.to_cagefs_args(), *cmd]


class BypassStrategy(LimitStrategyBase):
    """
    Strategy which bypasses both lve and
    cagefs and executes commands direcly.
    """

    def execute(
        self, uid: int, command: str, args: List[str], request_data: dict
    ) -> Optional[int]:
        return


class AllLimitStrategy(LimitStrategyBase):
    """
    Strategy to limit all commands
    """

    enter_flags = EnterFlags.all_limits()


class NoLimitStrategy(LimitStrategyBase):
    """
    Strategy to don't limit all commands
    """

    enter_flags = EnterFlags.no_limits()


class LimitStrategyHeavy(LimitStrategyBase):
    """
    Strategy to don't limit whitelisted commands
    By default - all commands are HEAVY and will be limited
    Add `rules` to mark any command as SIMPLE and run without limits
    """

    enter_flags = EnterFlags.all_limits()

    default_rule = Rule(callable=lambda args: True, result=CommandType.HEAVY)
    rules = {
        "cloudlinux-selector": [
            Rule(callable=lambda args: "get" in args, result=CommandType.SIMPLE),
            Rule(callable=lambda args: "start" in args, result=CommandType.SIMPLE),
            Rule(callable=lambda args: "restart" in args, result=CommandType.SIMPLE),
            Rule(callable=lambda args: "stop" in args, result=CommandType.SIMPLE),
        ],
        # the following lve-stats utilities allow user to monitor his load
        # they should be available disregarding the load on the server
        "cloudlinux-top": [
            Rule(callable=lambda args: True, result=CommandType.SIMPLE),
        ],
        "cloudlinux-statistics": [
            Rule(callable=lambda args: True, result=CommandType.SIMPLE),
        ],
        "cloudlinux-charts": [
            Rule(callable=lambda args: True, result=CommandType.SIMPLE),
        ],
        "spa-get-user-info": [
            Rule(callable=lambda args: True, result=CommandType.SIMPLE),
        ],
    }

    def _check_rules(self, command: str, args: List[str]) -> CommandType:
        command_type = None
        for rule in self.rules.get(command, []) + [self.default_rule]:
            if rule.callable(args):
                command_type = rule.result
                break
        if command_type == CommandType.SIMPLE:
            self.enter_flags = EnterFlags.no_limits()
        else:
            self.enter_flags = EnterFlags.all_limits()
        return command_type

    def get_full_command(
        self, uid: int, command: str, args: List[str], request_data: dict
    ) -> List[str]:
        self._check_rules(command, args)
        return super().get_full_command(uid, command, args, request_data)
utils/cloudlinux_cli_user.py000075500000132334152725704130012350 0ustar00# coding:utf-8

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import print_function
from __future__ import division
from __future__ import absolute_import

import json
import logging
import subprocess
import os
import pwd
import sys

from libcloudlinux import (
    CloudlinuxCliBase,
    parse_lve_id,
    LVEMANAGER_PLUGIN_NAMES,
    DEFAULT_PLUGIN_NAME,
    PASSENGER_DEPEND_PLUGINS,
    AllLimitStrategy,
    NoLimitStrategy,
    LimitStrategyHeavy,
    LimitStrategyBase,
    ConfigLimitValue,
    BypassStrategy,
    EnterTool,
)
from clselector.clpassenger_detectlib import is_clpassenger_active
from clcommon import ClPwd
from clcommon.utils import is_litespeed_running
from clcommon.lib.cledition import is_cl_solo_edition
from cldetectlib import get_param_from_file
from clcommon.const import Feature
from clcommon.cpapi import is_panel_feature_supported


CONFIG = "/etc/sysconfig/cloudlinux"
SMART_ADVICE_USER_CLI = "/opt/alt/php-xray/cl-smart-advice-user"
CAGEFS_ENTER = "/usr/bin/cagefs_enter"
ISOLATECTL = "/usr/sbin/isolatectl"
# A hung isolatectl must not hold the panel request (and its worker) forever.
ISOLATECTL_TIMEOUT = 60
CAGEFSCTL_USER = "/usr/sbin/cagefsctl-user"
ISOLATES_LIST_TIMEOUT = 600
# cloudlinux-statistics result for a --domain that lve-stats has no samples for
NO_DOMAIN_MATCH_PREFIX = "No domain matching "
PERCENTS_STATS_MODE_FLAG = (
    "/opt/cloudlinux/flags/enabled-flags.d/percentage-user-stats-mode.flag"
)

# NB: this logger's out is stderr, result JSON out is stdout - so with active logger web will not work properly
#       because of stderr redirection 2>&1
#       so it is MUST be silent(NOTSET) in normal situation
# also it is not possible to use file logger here - script works inside the cagefs with user's rights
logger = logging.getLogger(__name__)
logger.setLevel(logging.NOTSET)
init_formatter = logging.Formatter(
    "[%(asctime)s] %(funcName)s:%(lineno)s - %(message)s"
)
cagefs_formatter = logging.Formatter(
    "{cagefs} [%(asctime)s] %(funcName)s:%(lineno)s - %(message)s"
)
h = logging.StreamHandler()
h.setFormatter(init_formatter)
logger.addHandler(h)


logger.debug("cli start")


def _link_isolated_aliases(domains_info, domain_docroots, isolated):
    """
    Mark cPanel alias names of isolated sites (CLOS-7500).

    A parked domain, or the subdomain behind an addon domain, shares the
    document root of the site it aliases, so its HTTP traffic runs in that
    site's domain LVE, while isolatectl and lve-stats know only the site
    name. Such a name gets ``aliasOf: <site>`` and the site lists it in
    ``aliases``. ``isolated`` keeps its per-name isolatectl meaning.
    """
    def normalize(docroot):
        return os.path.normpath(docroot) if docroot else None

    site_by_docroot = {}
    for domain, docroot in domain_docroots:
        docroot = normalize(docroot)
        if domain in isolated and docroot and docroot not in site_by_docroot:
            site_by_docroot[docroot] = domain
    by_name = {info["domain"]: info for info in domains_info}
    for domain, docroot in domain_docroots:
        site = site_by_docroot.get(normalize(docroot))
        if not site or site == domain or domain in isolated:
            continue
        by_name[domain]["aliasOf"] = site
        by_name[site].setdefault("aliases", []).append(domain)


class CloudlinuxCliUser(CloudlinuxCliBase):
    limit_strategy: LimitStrategyBase

    def __init__(self):
        self.web_resource_limit_mode = ConfigLimitValue.HEAVY
        limit_mode = get_param_from_file(
            CONFIG, "web_resource_limit_mode", "=", ConfigLimitValue.HEAVY.value
        )
        self.web_resource_limit_mode = ConfigLimitValue(limit_mode)
        super(CloudlinuxCliUser, self).__init__()
        self.command_methods.update(
            {
                "spa-get-domains": self.spa_user_domains,
                "spa-get-homedir": self.spa_user_homedir,
                "cloudlinux-snapshots": self.cl_snapshots,
                "spa-get-user-info": self.spa_get_user_info,
                "site-isolation": self.site_isolation,
            }
        )

    def __init_limit_strategy(self):
        """
        Set default strategy from the `CONFIG` values
        """
        if self.skip_cagefs_check:
            # update log format to easier log review
            logger.handlers[0].setFormatter(cagefs_formatter)

        # Isolation enable/disable can move the user's LVE, killing its
        # processes. Keep the request outside it so the reply survives.
        isolation_toggle = (
            self.request_data.get("command") == "site-isolation"
            and self.request_data.get("method") in ("enable", "disable")
        )
        if isolation_toggle or not is_panel_feature_supported(Feature.LVE):
            self.limit_strategy = BypassStrategy()
        else:
            self.limit_strategy = {
                ConfigLimitValue.ALL: AllLimitStrategy,
                ConfigLimitValue.HEAVY: LimitStrategyHeavy,
                ConfigLimitValue.UNLIMITED: NoLimitStrategy,
            }.get(self.web_resource_limit_mode, LimitStrategyHeavy)()

        # we cannot use cagefs when it is not available
        if not is_panel_feature_supported(Feature.CAGEFS):
            self.limit_strategy.enter_tool = EnterTool.LVE_SUWRAPPER

        # some commands do not work inside cagefs, but we can still limit them with lve
        if self.__is_cagefs_incompatible_command():
            self.limit_strategy.enter_tool = EnterTool.LVE_SUWRAPPER

        logger.debug(
            f"Limits strategy inited as {self.limit_strategy.__class__}"
            f"\n\tBecause of:"
            f"\n\tself.web_resource_limit_mode: {self.web_resource_limit_mode}"
        )

    def set_limit_strategy(self, strategy: LimitStrategyBase):
        logger.debug(f"Limit strategy is explicitly set to {strategy.__class__}")
        self.limit_strategy = strategy

    def __is_cagefs_incompatible_command(self):
        """
        Returns True if command is not compatible with CageFS
        """
        data = self.request_data

        # phpselector commands
        if data.get("params", {}).get("interpreter") == "php":
            return True

        if data.get("command") in {
            # TODO: https://cloudlinux.atlassian.net/browse/CLOS-3561
            "cloudlinux-statistics",
            "cloudlinux-top",
            "cloudlinux-snapshots",
            "cloudlinux-charts",
            "cloudlinux-statsnotifier",
            # this command cannot run inside cagefs because it needs access to /dev/vdaX
            "cloudlinux-quota",
            # needs access to server-wide isolation state and ClUserSelect symlinks
            "site-isolation",
        }:
            logger.debug("Executable command found in the exclusive list")
            return True
        return False

    def drop_permission(self):
        """
        Drop permission to users, if owner of script is user
        :return:
        """
        logger.debug(
            "drop permissions start"
            f"\n\targv is: {sys.argv}"
            f"\n\trequest data is: {self.request_data}"
        )
        self.__init_limit_strategy()
        data = self.request_data
        if data["owner"] != "user":
            self.exit_with_error("User not allowed")
        super(CloudlinuxCliUser, self).drop_permission()

        args = self.prepair_params_for_command()
        logger.debug(f"prepared args is: {args}")

        if data.get("command"):
            if self.skip_cagefs_check:
                logger.debug("cagefs skipped: --skip-cagefs-check arg found")
            else:
                # CLOS-6970: the lve-id becomes the lve_suwrapper lve_id
                # argument. get_user_info() already healed an unusable panel
                # value from the euid; anything still unusable here must be a
                # clean JSON error, never an empty argv element.
                lve_id = parse_lve_id(self.user_info["lve-id"])
                if lve_id is None:
                    self.exit_with_error("User id does not specified")
                # if rc is None - script won't enter the cagefs
                # otherwise - command is executed in the cagefs
                rc = self.limit_strategy.execute(
                    lve_id, data["command"], args, self.request_data
                )
                if rc is not None:
                    logger.debug(f"command executed inside of the cagefs with rc: {rc}")
                    sys.exit(rc)
                else:
                    logger.debug(
                        f"cagefs skipped: strategy is {self.limit_strategy.__class__}"
                    )

        # skip checking plugin availability on spa-get-user-info
        if data.get("command") != "spa-get-user-info":
            self.check_plugin_availability()
        logger.debug("drop permissons end")

    def spa_user_domains(self):
        print(json.dumps({"result": "success", "list": self.get_user_domains()}))
        sys.exit(0)

    def spa_user_homedir(self):
        print(json.dumps({"result": "success", "homedir": self.get_user_homedir()}))
        sys.exit(0)

    def spa_get_user_info(self):
        try:
            print(
                json.dumps(
                    {
                        "result": "success",
                        "domains": self.get_user_domains(),
                        "homedir": self.get_user_homedir(),
                        "is_litespeed_running": is_litespeed_running(),
                        "is_cl_solo_edition": is_cl_solo_edition(skip_jwt_check=True),
                        "smart_advice": os.path.isfile(SMART_ADVICE_USER_CLI),
                        "is_lve_supported": is_panel_feature_supported(Feature.LVE),
                        "user_stats_mode": self.get_stats_mode(),
                        "normalized_user_cpu": get_param_from_file(
                            CONFIG, "normalized_user_cpu", "=", "Y"
                        ).upper() == "Y",
                        "server_ip": self.get_server_ip(),
                    }
                )
            )
        except Exception as e:
            self.exit_with_error(f"Module unavailable: {e}")
        sys.exit(0)

    def get_user_domains(self):
        return [x[0] for x in self.get_user_domain_docroots()]

    def get_user_domain_docroots(self):
        """(domain, document root) pairs of the panel user, main domain first."""
        try:
            from clcommon.cpapi import userdomains
        except Exception as e:
            self.exit_with_error(f"Module unavailable: {e}")
        return userdomains(self.user_info["username"])

    def get_stats_mode(self):
        if os.path.isfile(PERCENTS_STATS_MODE_FLAG):
            return "percent"
        return "default"

    def get_user_homedir(self):
        try:
            pwdir = ClPwd().get_homedir(self.user_info["username"])
            return pwdir + "/"
        except KeyError:
            self.exit_with_error("No such user")

    def cl_snapshots(self):
        if self.request_data.get("owner") == "user":
            params = self.request_data.setdefault("params", {}) or {}
            self.request_data["params"] = params
            params["id"] = str(self.user_info["lve-id"])
            params.pop("user", None)
        list_to_request = self.prepair_params_for_command()
        try:
            output = self.run_util("/usr/sbin/lve-read-snapshot", *list_to_request)
        except subprocess.CalledProcessError as processError:
            output = processError.output
        try:
            result = json.loads(output)
        except:
            self.exit_with_error(output)
            return
        self.exit_with_success({"data": result["data"]})
        sys.exit(0)

    def site_isolation(self):
        method = self.request_data.get("method")
        params = self.request_data.get("params", {}) or {}

        if method == "get-status":
            self._site_isolation_get_status()
        elif method in ("enable", "disable"):
            self._site_isolation_toggle(method, params)
        elif method == "sync":
            self._site_isolation_sync()
        elif method == "get-domain-versions":
            self._site_isolation_get_domain_versions()
        elif method == "limits-list":
            self._site_isolation_limits_list()
        elif method == "limits-set":
            self._site_isolation_limits_set(params)
        elif method == "limits-reset":
            self._site_isolation_limits_reset(params)
        else:
            self.exit_with_error("Unknown method: " + str(method))

    def _site_isolation_get_status(self):
        username = self.user_info.get("username")
        feature_available = False
        # Whether the isolation status could actually be read. A failed read is
        # NOT the same as the administrator denying isolation, so it must be
        # reported separately instead of being collapsed into allowed=False
        # (which the UI would otherwise render as "denied by your server
        # administrator"). The real underlying error is returned in statusError
        # so the UI can show it as technical details rather than swallowing it
        # to a sink nobody can read. See CLPRO-3214.
        # NB: do not log to this script's stderr logger here (see module header)
        # — it would either be discarded or corrupt the JSON the SPA reads.
        status_available = True
        status_error = None
        try:
            from clcagefslib.domain import (
                is_website_isolation_feature_available,
                is_website_isolation_allowed_server_wide,
                is_website_isolation_allowed_for_user,
            )
            feature_available = is_website_isolation_feature_available()
            server_allowed = is_website_isolation_allowed_server_wide()
            user_allowed = (
                is_website_isolation_allowed_for_user(username)
                if server_allowed else False
            )
        except Exception as e:
            # The read path raised (e.g. clcagefslib import or a query), which
            # can happen transiently right after a stack upgrade until cagefs is
            # re-synced. Surface it as "status unavailable" plus the real error
            # instead of masking it as an admin denial.
            user_allowed = False
            status_available = False
            status_error = str(e) or e.__class__.__name__

        if status_available and user_allowed:
            p, list_result = self._isolates_list()
            if list_result.get("result") != "success":
                # The in-cage listing did not succeed: treat the status as
                # unavailable rather than as an admin denial, and pass the
                # tool's own error message (falling back to raw output) to the UI.
                user_allowed = False
                status_available = False
                isolated = set()
                status_error = (
                    list_result.get("result")
                    or (p.stderr or p.stdout or "").strip()
                    or "site-isolation list exited with code %s" % p.returncode
                )
            else:
                isolated = set(list_result.get("enabled_sites", []))
        else:
            isolated = set()

        try:
            from clselect.clselectdomains import (
                get_all_selector_compatible_domains_flat,
            )
            selector_domains = get_all_selector_compatible_domains_flat()
        except Exception:
            logging.debug(
                'get_all_selector_compatible_domains_flat failed',
                exc_info=True,
            )
            selector_domains = set()

        domain_docroots = self.get_user_domain_docroots()
        domains_info = [
            {
                "domain": d,
                "isolated": d in isolated,
                "selectorCompatible": d in selector_domains,
            }
            for d, _ in domain_docroots
        ]
        _link_isolated_aliases(domains_info, domain_docroots, isolated)
        from lvd_feature import get_lvd_status
        result = {
            "featureAvailable": feature_available,
            "statusAvailable": status_available,
            "allowed": user_allowed,
            # LVD (per-domain LVE) status; see lvd_feature.get_lvd_status.
            **get_lvd_status(),
            "domains": domains_info,
        }
        if status_error:
            result["statusError"] = status_error
        self.exit_with_success(result)

    @staticmethod
    def _isolates_list():
        """Run the in-cage `cagefsctl-user isolates-list` of this user."""
        args = [CAGEFS_ENTER, CAGEFSCTL_USER, "isolates-list"]
        try:
            p = subprocess.run(
                args, capture_output=True, text=True, timeout=ISOLATES_LIST_TIMEOUT,
            )
        except subprocess.TimeoutExpired:
            p = subprocess.CompletedProcess(
                args, 124, stdout="",
                stderr=f"site-isolation list timed out after {ISOLATES_LIST_TIMEOUT} seconds",
            )
            return p, {}
        try:
            list_result = json.loads(p.stdout)
        except (json.JSONDecodeError, ValueError):
            list_result = {}
        if not isinstance(list_result, dict):
            list_result = {}
        return p, list_result

    def adjust_statistics_output(self, output):
        """
        An isolated domain without samples yet (freshly enabled) is unknown to
        lve-stats, so `cloudlinux-statistics --domain` answers "No domain
        matching ...", which the panel would turn into HTTP 503. For a site in
        the user's own isolation roster that means "no data yet": answer with
        the empty success an account without samples gets (CLOS-7500).
        """
        domain = str((self.request_data.get("params") or {}).get("domain") or "").strip()
        if not domain:
            return output
        try:
            data = json.loads(output)
        except (TypeError, ValueError):
            return output
        if not isinstance(data, dict) or not str(data.get("result", "")).startswith(
                NO_DOMAIN_MATCH_PREFIX):
            return output
        _, list_result = self._isolates_list()
        if list_result.get("result") != "success" or \
                domain not in (list_result.get("enabled_sites") or []):
            return output
        empty = {"result": "success"}
        if "timestamp" in data:
            empty["timestamp"] = data["timestamp"]
        empty["user"] = []
        if "mySqlGov" in data:
            empty["mySqlGov"] = data["mySqlGov"]
        return json.dumps(empty) + "\n"

    def _site_isolation_toggle(self, method, params):
        domain = params.get("domain")
        if not domain:
            self.exit_with_error("ERROR.missing_domain_parameter")
            return
        # Enforce the administrator deny gate on the write path.
        try:
            from clcagefslib.domain import (
                is_website_isolation_allowed_server_wide,
                is_website_isolation_allowed_for_user,
            )
            from clcagefslib.fs import get_user_var_cagefs_path
            username = self.user_info.get("username")
            if not is_website_isolation_allowed_server_wide() or \
                    not is_website_isolation_allowed_for_user(username):
                self.exit_with_error("Site isolation is not allowed for this user")
                return
        except ImportError:
            # Fail closed when the predicate library is unavailable.
            self.exit_with_error("Site isolation feature is unavailable")
            return
        user_domains = self.get_user_domains()
        if domain not in user_domains:
            self.exit_with_error("ERROR.domain_not_belong_to_user")
            return
        # Use the existing nolve/root CageFS alias directly: cagefs_enter
        # would put the response channel back into the LVE being moved.
        # Authenticate as the OS caller, never as a request-supplied user.
        username = pwd.getpwuid(os.getuid()).pw_name
        if username != self.user_info.get("username"):
            self.exit_with_error("ERROR.site_isolation_caller_mismatch")
            return
        token_path = os.path.join(
            get_user_var_cagefs_path(username), ".cagefs", ".cagefs.token")
        try:
            with open(token_path) as token_file:
                token = token_file.read().strip()
        except OSError:
            self.exit_with_error("ERROR.cagefs_token_unreadable")
            return
        if not token:
            self.exit_with_error("ERROR.cagefs_token_unreadable")
            return
        # The toggle moves the domain to another PHP Selector version (its
        # per-domain selector slot appears or disappears), so the MultiPHP
        # sync steps aside first (see ClUserSelect.set_version) and a mirror
        # pass follows, so the domain's pin follows its new source of truth
        # instead of stranding the old one. A bulk action defers all but
        # its last toggle, so the account converges once instead of once
        # per domain (see defer_requested): a deferred toggle ends its
        # change without a pass. Paced: a tenant's action, throttled like
        # the door's.
        from clselect import multiphp_sync_client
        try:
            change = multiphp_sync_client.release_for_change(
                username, reason="isolation-toggle", paced=True)
        except multiphp_sync_client.HandBackFailed as e:
            self.exit_with_error("Isolation was not changed: %s" % e)
            return
        try:
            # --no-multiphp-sync: cagefs asks the sync to re-mirror the
            # account itself (cagefs >= 7.6.50); this caller converges it,
            # once per bulk action instead of once per domain
            self.run_util(
                "/usr/sbin/proxyexec", "-c", "cagefs.sock", username,
                os.getcwd(), "CAGEFSCTL_USER", str(os.getpid()),
                "isolates-" + method, "--domain", domain, "--no-multiphp-sync",
                env={"CAGEFS_TOKEN": token})
        finally:
            if multiphp_sync_client.defer_requested(params):
                multiphp_sync_client.end_change(username, change)
            else:
                multiphp_sync_client.notify_change(
                    username, reason="isolation-toggle", paced=True,
                    after_change=change)
        self.exit_with_success({"domain": domain})

    def _site_isolation_sync(self):
        """Converge this account's MultiPHP pins after a bulk isolation
        action.

        Every toggle in such an action defers its own dispatch — one
        complete account mirror, and the Apache rebuild a writing pass
        carries, per domain otherwise — so the caller asks for a single
        pass here once it is done. That includes when one of its toggles
        failed, which is why the convergence cannot ride on the last one:
        the domains already changed would keep their old pins, and serve
        the previous PHP version, until some later trigger.

        Gated exactly like the toggle it follows. The pass runs as root
        and a writing one rebuilds Apache configuration, and unlike a
        toggle this needs no state change to do it — ungated it would be
        the cheapest storm trigger on the box, callable in a loop. A
        denied account cannot reach a bulk action anyway, so the gate
        costs it nothing. Concurrency is bounded separately: every
        trigger takes the engine's non-blocking per-account guard, so
        overlapping calls leave a request and return instead of each
        running a pass.
        """
        username = self.user_info.get("username")
        # mirrors the toggle's gate, including its fail-closed ImportError
        try:
            from clcagefslib.domain import (
                is_website_isolation_allowed_server_wide,
                is_website_isolation_allowed_for_user,
            )
            if not is_website_isolation_allowed_server_wide() or \
                    not is_website_isolation_allowed_for_user(username):
                self.exit_with_error("Site isolation is not allowed for this user")
        except ImportError:
            self.exit_with_error("Site isolation feature is unavailable")
        from clselect import multiphp_sync_client
        # the UI reloads its rows on success, so a pass that could not be
        # dispatched has to be said: the rows would otherwise show stale
        # pins with nothing telling the user why. Only a pass that was
        # due: where the mirror does not apply (the sync off, as it
        # ships, or another panel) nothing is dispatched and nothing is
        # out of step
        if not multiphp_sync_client.notify_change(
                username, reason="isolation-bulk", paced=True) and \
                multiphp_sync_client.mirror_applies():
            self.exit_with_error(
                "MultiPHP versions could not be synchronized; see "
                "/var/log/cloudlinux/cl-multiphp-sync.log")
        self.exit_with_success({})

    def _domain_versions_answer(self, domain_versions, mirroring):
        """Every get-domain-versions answer says whether the mirror runs:
        the SPA reads a MultiPHP pin as the Selector's only then
        (CLOS-6947), and only one the sync wrote, which the sync's door
        answers for the caller's account (multiphpSyncPins; decided
        2026-10-12: a version set in MultiPHP Manager stays MultiPHP's even
        at the Selector's). No answer from the door is null, and the page
        goes by the version, as before."""
        from clselect import multiphp_sync_client
        self.exit_with_success({
            "domainVersions": domain_versions,
            "multiphpSyncMirroring": mirroring,
            "multiphpSyncPins": (multiphp_sync_client.owned_pins()
                                 if mirroring else None)})

    def _site_isolation_limits_list(self):
        """Proxy `isolatectl limits list` (run inside CageFS) and return
        the per-domain limits scoped to the calling user, plus the
        ``account`` LVE caps read from pylve for the caller's own uid
        (``lvectl_paneluserslimits.load_for_uid``) in the same
        utility-native units the upstream utility uses.

        The shape is intentionally identical to the admin
        ``limits-list-all`` per-user row so ShareCalc on the SPA side
        can join domain → account without unit-conversion logic.

            {"result": "success",
             "account": {"cpu", "pmem", "vmem", "io", "iops", "ep", "nproc"},
             "domains": [{"name", "lve_id", "limits"}, ...]}
        """
        try:
            p = subprocess.run(
                [ISOLATECTL, "limits", "list"],
                capture_output=True, text=True, timeout=ISOLATECTL_TIMEOUT,
            )
        except (OSError, subprocess.TimeoutExpired):
            self.exit_with_error("SITE_ISOLATION.lvd_backend_unavailable_user")
            return
        try:
            result = json.loads(p.stdout)
        except (json.JSONDecodeError, ValueError):
            self.exit_with_error("ERROR.isolatectl_invalid_json")
            return
        if result.get("result") != "success":
            # Pass-through (see _site_isolation_limits_set for the
            # rationale on letting upstream English propagate).
            self.exit_with_error(
                result.get("message")
                or result.get("result")
                or "ERROR.isolatectl_failed")
            return

        # `load_for_uid` returns None when pylve.lve_info fails. Report it as
        # `accountAvailable: false`; `account` is then all zeros, which the
        # SPA must not read as "every cap unlimited".
        from lvectl_paneluserslimits import load_for_uid
        uid = self.user_info.get("lve-id") or os.geteuid()
        account = load_for_uid(uid)

        self.exit_with_success({
            "account": account or {
                "cpu": 0, "pmem": 0, "vmem": 0,
                "io": 0, "iops": 0, "ep": 0, "nproc": 0,
            },
            "accountAvailable": account is not None,
            "domains": result.get("domains", []),
        })

    # VMEM is retained in the API schema but is not an effective share limit.
    _SHARE_LIMIT_FIELDS = ("cpu", "pmem", "io", "iops", "ep", "nproc")
    _PMEM_PAGE_BYTES = 4096

    @staticmethod
    def _human_limit_bound(field, bound):
        """Render the exclusive bound for "must be less than {{max}}".

        Mirrors the SPA's ``DIM_FORMATTERS[dim].display()`` (share-calc.ts,
        LVE Limits units via ``Helper.reformatValue``): CPU centi-percent
        as percent, PMEM bytes and IO KB/s scaled by 1024 up to G / G/s,
        at most two decimals; counts as plain integers.
        """
        def num(v):
            return ('%.2f' % v).rstrip('0').rstrip('.')
        if field == 'cpu':
            return num(bound / 100) + '%'
        units = {'pmem': ('B', 'K', 'M', 'G'),
                 'io': ('KB/s', 'MB/s', 'G/s')}.get(field)
        if not units:
            return str(bound)
        value = float(bound)
        for unit in units[:-1]:
            if value / 1024 < 1:
                return num(value) + unit
            value /= 1024
        return num(value) + units[-1]

    def _site_isolation_limits_set(self, params):
        """Proxy `isolatectl limits set --domain <d> [--cpu N] [--pmem N] ...`.

        params = {
            "domain": "blog.devuser.com",  # required, must belong to user
            "limits": {"cpu": 5000, "pmem": 536870912, ...},  # six share limits;
                                                              # object or JSON string
        }

        Unit contract (see docs/design/resource-shares.md):
          cpu  centi-percent (2500 = 25%)
          pmem bytes
          io   KB/s
          iops, ep, nproc counts
        """
        domain = params.get("domain")
        if not domain:
            self.exit_with_error("ERROR.missing_domain_parameter")
            return
        if domain not in self.get_user_domains():
            self.exit_with_error("ERROR.domain_not_belong_to_user")
            return
        if not self._ensure_resource_shares_writable(domain):
            return
        # A JSON string survives every panel's single-level form bridge.
        # Retain object input for existing callers.
        raw_limits = params.get("limits")
        if isinstance(raw_limits, str):
            try:
                raw_limits = json.loads(raw_limits)
            except ValueError:
                self.exit_with_error("ERROR.invalid_limit_value")
                return
        if not isinstance(raw_limits, dict) or not any(
                field in raw_limits for field in self._SHARE_LIMIT_FIELDS):
            self.exit_with_error("ERROR.missing_or_empty_limits")
            return

        argv = [ISOLATECTL, "limits", "set", "--domain", domain]
        for field in self._SHARE_LIMIT_FIELDS:
            if field not in raw_limits:
                continue
            try:
                if isinstance(raw_limits[field], bool) or not isinstance(raw_limits[field], (int, str)):
                    raise ValueError("limit must be an integer")
                value = int(raw_limits[field])
            except (TypeError, ValueError):
                self.exit_with_error("ERROR.invalid_limit_value")
                return
            if value < 0:
                self.exit_with_error("ERROR.negative_limit")
                return
            # Current lve-utils reads every persisted dimension as int32,
            # including memory in bytes. Larger writes invalidate the whole
            # account config on its next read. Refuse before invoking it.
            if value > 2 ** 31 - 1:
                self.exit_with_error("ERROR.limit_out_of_range", context={
                    "max": self._human_limit_bound(field, 2 ** 31)})
                return
            # isolatectl stores PMEM in 4 KiB pages (bytes // 4096); a
            # positive sub-page value would silently become 0 = inherit.
            if field == "pmem" and 0 < value < self._PMEM_PAGE_BYTES:
                self.exit_with_error("ERROR.invalid_limit_value")
                return
            argv.extend(["--" + field, str(value)])

        try:
            p = subprocess.run(argv, capture_output=True, text=True,
                               timeout=ISOLATECTL_TIMEOUT)
        except (OSError, subprocess.TimeoutExpired):
            self.exit_with_error("SITE_ISOLATION.lvd_backend_unavailable_user")
            return
        try:
            result = json.loads(p.stdout)
        except (json.JSONDecodeError, ValueError):
            self.exit_with_error("ERROR.isolatectl_invalid_json")
            return
        if result.get("result") != "success":
            # The upstream `isolatectl`/lvectl message itself is English
            # and operator-targeted (e.g. "Domain limits are not
            # supported by this kernel"). Surface it as-is so the user
            # sees the specific reason rather than a generic key — these
            # paths are already gated by the kernel-feature probe so
            # day-to-day users won't hit them.
            self.exit_with_error(
                result.get("message")
                or result.get("result")
                or "ERROR.isolatectl_failed")
            return
        self.exit_with_success({
            "domain": domain,
            "limits": result.get("limits", {}),
        })

    def _site_isolation_limits_reset(self, params):
        """Reset a domain's per-site limits to inherit the account-LVE values.

        `isolatectl limits set` treats explicit zero values as cleared
        per-domain overrides. Clear the six effective share limits, retaining
        legacy VMEM and the domain's isolation state.
        """
        domain = params.get("domain")
        if not domain:
            self.exit_with_error("ERROR.missing_domain_parameter")
            return
        if domain not in self.get_user_domains():
            self.exit_with_error("ERROR.domain_not_belong_to_user")
            return
        # Clearing limits remains available after admission is closed. This
        # writes zeros even without an allocation; Shared records may persist.
        if not self._ensure_resource_shares_writable(
                domain, allow_when_disabled=True):
            return

        argv = [ISOLATECTL, "limits", "set", "--domain", domain]
        for field in self._SHARE_LIMIT_FIELDS:
            argv.extend(["--" + field, "0"])
        try:
            p = subprocess.run(argv, capture_output=True, text=True,
                               timeout=ISOLATECTL_TIMEOUT)
        except (OSError, subprocess.TimeoutExpired):
            self.exit_with_error("SITE_ISOLATION.lvd_backend_unavailable_user")
            return
        try:
            result = json.loads(p.stdout)
        except (json.JSONDecodeError, ValueError):
            self.exit_with_error("ERROR.isolatectl_invalid_json")
            return
        if result.get("result") != "success":
            self.exit_with_error(
                result.get("message")
                or result.get("result")
                or "ERROR.isolatectl_failed")
            return

        self.exit_with_success({"domain": domain})

    def _domain_has_resource_share(self, domain):
        """Return True when isolatectl reports a positive effective share limit.

        This preserves edits through the panel while admission is off. The
        isolatectl view comes from tenant-writable ~/.lve/domains.json, so it
        cannot prove that an allocation was previously admitted by an admin.
        The flag controls the panel workflow, not privileged admission.
        A hung isolatectl raises ``subprocess.TimeoutExpired`` to the caller,
        which reports the backend as unavailable.
        """
        try:
            p = subprocess.run(
                [ISOLATECTL, "limits", "list", "--domain", domain],
                capture_output=True, text=True, timeout=ISOLATECTL_TIMEOUT)
        except OSError:
            return False
        if p.returncode != 0:
            return False
        try:
            result = json.loads(p.stdout)
        except (json.JSONDecodeError, ValueError):
            return False
        if not isinstance(result, dict) or result.get("result") != "success":
            return False
        rows = result.get("domains", [])
        if not isinstance(rows, list):
            return False
        for row in rows:
            if not isinstance(row, dict):
                continue
            if row.get("name") != domain:
                continue
            limits = row.get("limits") or {}
            if not isinstance(limits, dict):
                continue
            for field in self._SHARE_LIMIT_FIELDS:
                try:
                    if int(limits.get(field, 0)) > 0:
                        return True
                except (TypeError, ValueError, OverflowError):
                    continue
        return False

    def _ensure_resource_shares_writable(
            self, domain, allow_when_disabled=False):
        """Fail closed before a direct limits write bypasses SPA gates."""
        try:
            from clcagefslib.domain import (
                is_website_isolation_allowed_server_wide,
                is_website_isolation_allowed_for_user,
                get_websites_with_enabled_isolation,
            )
            username = self.user_info.get("username")
            if not is_website_isolation_allowed_server_wide() or \
                    not is_website_isolation_allowed_for_user(username):
                self.exit_with_error(
                    "Site isolation is not allowed for this user")
                return False
            if domain not in get_websites_with_enabled_isolation(username):
                self.exit_with_error("Site isolation is not enabled for this domain")
                return False
        except (ImportError, OSError, ValueError):
            self.exit_with_error("Site isolation feature is unavailable")
            return False

        from lvd_feature import (
            is_lvd_backend_available,
            is_lvd_kernel_capable,
        )
        if not is_lvd_kernel_capable():
            self.exit_with_error("SITE_ISOLATION.lvd_kernel_unsupported")
            return False
        # Every write (set, edit of an existing share, reset) goes through
        # isolatectl and the SUID helpers; without them it cannot succeed.
        if not is_lvd_backend_available():
            self.exit_with_error("SITE_ISOLATION.lvd_backend_unavailable_user")
            return False

        try:
            from lvd_config import is_enabled
            admission_enabled = is_enabled()
        except Exception:
            self.exit_with_error("SITE_ISOLATION.lvd_backend_unavailable_user")
            return False
        if allow_when_disabled:
            return True

        # The admin flag is admission-only: existing limits remain editable.
        # "Existing" comes from tenant-writable state (see the helper above),
        # so this is a panel policy rather than a tenant security boundary.
        if not admission_enabled:
            try:
                has_share = self._domain_has_resource_share(domain)
            except subprocess.TimeoutExpired:
                self.exit_with_error("SITE_ISOLATION.lvd_backend_unavailable_user")
                return False
            if has_share:
                return True
            self.exit_with_error("SITE_ISOLATION.lvd_admin_disabled")
            return False
        return True

    def _site_isolation_get_domain_versions(self):
        from clselect.multiphp_sync_client import mirroring_on
        # with the mirror off, as the package ships, every answer is the one
        # it always was
        mirroring = mirroring_on()
        try:
            from clcagefslib.domain import (
                is_website_isolation_allowed_server_wide,
                is_website_isolation_allowed_for_user,
            )
            username = self.user_info.get("username")
            if not is_website_isolation_allowed_server_wide() or \
                    not is_website_isolation_allowed_for_user(username):
                self._domain_versions_answer({}, mirroring)
        except Exception:
            self._domain_versions_answer({}, mirroring)

        p = subprocess.run(
            [CAGEFS_ENTER, CAGEFSCTL_USER, "isolates-list"],
            capture_output=True, text=True,
        )
        try:
            list_result = json.loads(p.stdout)
        except (json.JSONDecodeError, ValueError):
            list_result = {}
        isolated = set(list_result.get("enabled_sites", []))
        if not isolated:
            self._domain_versions_answer({}, mirroring)

        from clselect import ClUserSelect
        from clcommon.cpapi import docroot
        username = self.user_info.get("username")
        user_selector = ClUserSelect("php")
        domain_versions = {}
        account = None
        for domain_name in isolated:
            try:
                if mirroring:
                    # the website's own selection, or the account's version
                    # it follows: get_version answers `native` for a website
                    # that selected nothing, and the SPA would take that for
                    # the website's own version (CLOS-6947)
                    version = user_selector._website_selected_version(
                        username, docroot(domain_name)[0])
                    if not version:
                        if account is None:
                            account = user_selector.get_version(username)[0]
                        version = account
                else:
                    ver_info = user_selector.get_version(username, domain_name)
                    version = ver_info[0] if ver_info else None
                if version:
                    domain_versions[domain_name] = version
            except Exception:
                logger.debug(
                    "get-domain-versions: failed to get version "
                    "for domain %s of user %s",
                    domain_name, username, exc_info=True,
                )
        self._domain_versions_answer(domain_versions, mirroring)

    def check_plugin_availability(self):
        plugin_names = {
            "nodejs_selector": "Node.js Selector",
            "python_selector": "Python Selector",
        }
        selector_enabled = True
        manager = None
        try:
            if self.current_plugin_name == "nodejs_selector":
                from clselect.clselectnodejs.node_manager import NodeManager

                manager = NodeManager()
            if self.current_plugin_name == "python_selector":
                from clselect.clselectpython.python_manager import PythonManager

                manager = PythonManager()
            if manager:
                selector_enabled = manager.selector_enabled
        except:
            selector_enabled = False
        if not selector_enabled:
            self.exit_with_error(
                code=503,
                error_id="ERROR.not_available_plugin",
                context={
                    "pluginName": plugin_names.get(self.current_plugin_name, "Plugin")
                },
                icon="disabled",
            )
        plugin_available_checker = {
            "nodejs_selector": self._plugin_available_nodejs,
            "python_selector": self._plugin_available_python,
            "php_selector": self._plugin_available_php,
            "resource_usage": self._plugin_available_resource_usage,
        }.get(self.current_plugin_name)
        if plugin_available_checker:
            plugin_available = plugin_available_checker()
        else:
            plugin_available = True

        if (
            not is_clpassenger_active()
            and self.current_plugin_name in PASSENGER_DEPEND_PLUGINS
        ):
            self.exit_with_error(
                code=503,
                error_id="ERROR.not_available_passenger",
                context={
                    "pluginName": LVEMANAGER_PLUGIN_NAMES.get(
                        self.current_plugin_name, DEFAULT_PLUGIN_NAME
                    )
                },
                icon="disabled",
            )
        if not plugin_available:
            self.exit_with_error(
                code=503,
                error_id="ERROR.not_available_plugin",
                context={
                    "pluginName": LVEMANAGER_PLUGIN_NAMES.get(
                        self.current_plugin_name, DEFAULT_PLUGIN_NAME
                    )
                },
                icon="disabled",
            )

    def _plugin_available_nodejs(self):
        try:
            from clselect.clselectnodejs.node_manager import NodeManager

            manager = NodeManager()
            if not manager.selector_enabled or not is_clpassenger_active():
                return False
        except:
            return False
        return True

    def _plugin_available_python(self):
        try:
            from clselect.clselectpython.python_manager import PythonManager

            manager = PythonManager()
            if not manager.selector_enabled or not is_clpassenger_active():
                return False
        except:
            return False
        return True

    def _plugin_available_php(self):
        try:
            from clselect.clselectphp.php_manager import PhpManager

            manager = PhpManager()
            if not manager.selector_enabled:
                return False
        except:
            return False
        return True

    def _plugin_available_resource_usage(self):
        return True
utils/cloudlinux-selector.py000075500000001216152725704130012273 0ustar00#!/opt/cloudlinux/venv/bin/python3 -sbb
# -*- coding: utf-8 -*-
# cloudlinux-selector Utility to check Cloudlinux license
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2022 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import sys

from clselector.cl_selector import CloudlinuxSelector


def main(argv):
    """
    Main run function
    """
    cll = CloudlinuxSelector()
    return cll.run(argv)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
utils/set_env_vars.py000075500000017322152725704130010772 0ustar00#!/opt/cloudlinux/venv/bin/python3 -sbb
# CLOS-5528 (F-14): -s skips the user-site directory so an accidental root
# invocation of this script (or a compromised user home) cannot inject a
# ~/.local/lib/pythonX.Y/site-packages module that shadows a stdlib name.
# All shipped callers (commons/bin/{python,node,npm}_wrapper) already reject
# EUID==0 and this script is not setuid, not sudoers-aliased, not called
# from RPM scriptlets — the flag is defence-in-depth. -bb (warn-then-error
# on implicit bytes/str mix) is preserved. Combined short flags parse
# correctly under Python (-sbb == -s -b -b).
# -*- coding: utf-8 -*-

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import os
import re
import sys
import getpass

from future.utils import iteritems

from clselect.clselectnodejs.apps_manager import ApplicationsManager as NodeJsAppsManager
from clselect.clselectpython.apps_manager import ApplicationsManager as PythonAppsManager, get_venv_rel_path
from clselect.utils import get_using_realpath_keys

# `export NAME=...` accepts only these names; for anything else bash prints
# the whole NAME=value word in its error message, or runs the name as code.
_SHELL_NAME = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')
# Names bash keeps for itself (readonly, dynamic or integer; "Shell
# Variables" in the bash manual, checked on bash 4.2 to 5.2). An export
# cannot reliably set them as given, a readonly one makes a POSIX-mode eval
# exit, and an integer one (OPTIND, RANDOM, ...) evaluates the value as
# arithmetic, which runs any $(...) in an array subscript. Do not prune a
# name because `export NAME=x` seems to work.
_BASH_OWN_NAMES = frozenset((
    'BASHOPTS', 'BASHPID', 'BASH_ALIASES', 'BASH_ARGC', 'BASH_ARGV', 'BASH_CMDS',
    'BASH_COMMAND', 'BASH_LINENO', 'BASH_SOURCE', 'BASH_SUBSHELL', 'BASH_VERSINFO',
    'DIRSTACK', 'EPOCHREALTIME', 'EPOCHSECONDS', 'EUID', 'FUNCNAME', 'GROUPS',
    'HISTCMD', 'LINENO', 'OPTIND', 'PIPESTATUS', 'PPID', 'RANDOM', 'SECONDS',
    'SHELLOPTS', 'SHLVL', 'SRANDOM', 'UID', '_'))
# How each value byte is written inside $'...': itself or a two-digit \xHH.
_LITERAL_BYTES = frozenset(b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-./:')
_BYTE_TEXT = tuple(chr(byte) if byte in _LITERAL_BYTES else '\\x%02x' % byte for byte in range(256))


def get_app_name(interpreter):
    """
    Get application name via CL_APP_ROOT variable
    :param interpreter: interpreter name
    :return: str application name
    """
    if os.environ.get('CL_APP_ROOT'):
        return os.environ['CL_APP_ROOT']
    elif interpreter == 'python':
        abs_venv_path = os.environ['VIRTUAL_ENV']
        user_home = os.environ['HOME']
        # /home/<username>/virtualenv/<name>/<version> ->
        # /virtualenv/<name>
        vevn_rel_path = os.path.dirname(abs_venv_path).replace(user_home + '/', '', 1)
        # if VENV_REL_PATH contains _ we cannot
        # clearly define app_root and should guess
        if '_' in vevn_rel_path:
            # scanner-triage: set_env_vars.py hard-rejects root at __main__ via is_root()
            # and is installed 0755 with no setuid, so a spoofed getpass.getuser() only
            # reads files with the attacker's own UID — no elevation. User-side interpreter
            # invocations also run inside CageFS where other users' /home is invisible.
            username = getpass.getuser()
            # in python CL_APP_ROOT is not set, we must guess by env path
            for app_root in PythonAppsManager().get_user_config_data(username):
                _, rel_path = get_venv_rel_path(username, app_root)
                if rel_path == vevn_rel_path:
                    return app_root
            return None
        else:
            return vevn_rel_path.replace('virtualenv/', '', 1)
    else:
        raise NotImplementedError(
            'I don\'t know how to get app_root for %s' % interpreter)


def get_env_vars(_app_name, interpreter):
    """
    Get environment variables from user config for given application name
    :param _app_name: application name
    :param interpreter: interpreter name
    :return: dict {ENV_VAR_NAME: VALUE}
    """
    _env_vars = {}
    username = getpass.getuser()
    try:
        full_app_config = get_app_full_conf(username, _app_name, interpreter)
        if interpreter == 'nodejs':
            _env_vars['NODE_ENV'] = full_app_config['app_mode']
        _env_vars.update(full_app_config['env_vars'])
    except KeyError:
        pass
    return _env_vars


def _encode_value(var):
    """
    UTF-8 bytes of the value, or None if an environment cannot hold it (NUL)
    or it cannot be encoded (a lone surrogate from a \\udXXX JSON escape).
    """
    try:
        value = str(var).encode('utf-8')
    except UnicodeEncodeError:
        return None
    return None if b'\0' in value else value


def set_env_vars(dict_env_vars):
    """
    Write one `export NAME=...` line per variable to stdout. A value made
    only of [A-Za-z0-9_./:-] is written plain; any other value is written as
    $'...' with every byte outside that set as a two-digit \\xHH escape.
    The output must survive LiteSpeed's UNQUOTED `eval $(set_env_vars.py python)`
    (lswsgi_wrapper, not ours) as well as our wrappers' `eval "$(...)"`:
    do not "simplify" it to shlex.quote(). A quoted value needs a bash
    consumer; plain values also work in POSIX sh.
    Invalid names, names bash keeps for itself and values _encode_value
    rejects are skipped; stderr carries only their count.
    See docs/design/selector-architecture.md, "Application environment variables".
    :param dict_env_vars: dict with environment variables, NODE_ENV first
    :return: None
    """
    records = []
    skipped = 0
    for key, var in iteritems(dict_env_vars):
        value = _encode_value(var)
        if value is None or not _SHELL_NAME.fullmatch(key) or key in _BASH_OWN_NAMES:
            skipped += 1
            continue
        if all(byte in _LITERAL_BYTES for byte in value):
            # plain, so that a POSIX sh consumer (no $'...') reads it right too
            records.append("export {}={}\n".format(key, value.decode('ascii')))
        else:
            records.append("export {}=$'{}'\n".format(key, ''.join(_BYTE_TEXT[byte] for byte in value)))
    if skipped:
        print('set_env_vars.py: skipped {} environment variable(s) that cannot be passed to the '
              'application (invalid name or unsupported value)'.format(skipped), file=sys.stderr)
    # Bytes, so that the output does not depend on the locale's encoding.
    sys.stdout.buffer.write(''.join(records).encode('ascii'))


def is_root():
    return os.geteuid() == 0


def get_app_full_conf(user, app, interpreter):
    if interpreter == 'nodejs':
        manager = NodeJsAppsManager()
    elif interpreter == 'python':
        manager = PythonAppsManager()
    else:
        raise NotImplementedError()
    full_user_config = manager.get_user_config_data(user)
    if not full_user_config:
        print("User config was not found or empty", file=sys.stderr)
        sys.exit(0)
    return get_using_realpath_keys(user, app, full_user_config)


if __name__ == "__main__":
    if is_root():
        print("This program is not intended to be run as root.", file=sys.stderr)
        sys.exit(1)
    args = sys.argv
    if len(args) < 2:
        print("Interpreter is not passed.", file=sys.stderr)
        sys.exit(1)
    app_name = get_app_name(sys.argv[1])
    if app_name is None:
        print("Unknown application.", file=sys.stderr)
        sys.exit(1)
    env_vars = get_env_vars(app_name, sys.argv[1])
    set_env_vars(env_vars)
utils/npm_wrapper000075500000116334152725704130010202 0ustar00#!/bin/bash

if [[ $EUID -eq 0 ]]; then
   echo "This program is not intended to be run as root." 1>&2
   exit 1
fi

error_msg="Cloudlinux NodeJS Selector demands to store node modules for application in separate folder \
(virtual environment) pointed by symlink called \"node_modules\". That's why application should not contain \
folder/file with such name in application root"

CWD=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
source "${CWD}/activate"
eval "$(${CWD}/set_env_vars.py nodejs)"

venv_node_modules="${CL_VIRTUAL_ENV}/lib/node_modules"
nodejs_npm="$CL_NODEHOME/usr/bin/npm"

# set_env_vars did not resolve this invocation to an application (its
# diagnostics go to stderr, so eval saw nothing): there is no venv to deliver
# into and the paths below would be nonsense like "$HOME//node_modules". Run
# plain npm and get out, before any bun-delivery logic.
if [[ -z "${CL_APP_ROOT:-}" ]]; then
    exec "${nodejs_npm}" "$@"
fi

app_node_modules="${HOME}/${CL_APP_ROOT}/node_modules"

invalidate_shared_node_modules_marker() {
    # A failed plain npm can leave a conservative false-negative; never claim
    # the delivered tree is still intact once npm was allowed to mutate it.
    rm -f "${venv_node_modules}/.cl-shared-node-modules.json"
}

# bun install-time delivery (docs/design/nodejs-bun-delivery.md).
#
# This wrapper is where an account's own `npm install` lands -- a person
# over SSH, or an agent in a shell, which is the workload a shared cache
# exists for: an agent redeploys dozens of times an afternoon and every one
# of those installs re-downloads what the server already has.
#
# The account cannot link out of the cache itself (the kernel refuses a hard
# link to a file it does not own), so the middle phase is asked of a root
# daemon over a unix socket. The account keeps the phases that must stay
# unprivileged: resolution runs with its own registry configuration, and the
# native builds run as itself.
#
#     resolve (account) -> deliver (root, over the socket) -> rebuild (account)
#
# If any step fails -- daemon stopped, a busy application -- the install
# fails and says why, rather than quietly becoming a plain npm install.
# Explicit administrator disable/exclusion policy selects classic npm, and so
# does an input this engine cannot serve (a resolve sentinel, or a source or
# package shape root refused), each named on stderr;
# every other refusal remains loud. Silently
# choosing a different installer is what let two delivery defects
# sit behind healthy-looking numbers while this feature was built; an install that looks like it
# succeeded is worse than one that stops.
BUN_USER_PHASE="/usr/share/l.v.e-manager/utils/bun_user_phase"
BUN_DELIVERY_CONF="/opt/cl-bun/etc/node-modules-storage.json"
NODE_MODULES_STORAGE_PYTHON="/opt/cloudlinux/venv/bin/python3"
NODE_MODULES_STORAGE_READER="${NODE_MODULES_STORAGE_PYTHON}"
DELIVERY_CLIENT="/usr/share/l.v.e-manager/utils/cloudlinux_bun_client.py"

close_selector_lock_copy_for_npm() {
    # Selector itself keeps the same open-file-description locked until the
    # npm operation and its restart/health phases finish.  Drop only this
    # wrapper child's descriptor before npm starts, otherwise a lifecycle
    # script that backgrounds a process could inherit the application lock
    # indefinitely after Selector returns.
    local selector_fd="${CL_SELECTOR_APP_LOCK_FD:-}"
    local lock_file="$(dirname "${CL_VIRTUAL_ENV}")/.lock"
    [[ "${selector_fd}" =~ ^[0-9]+$ ]] || return 0
    [ -e "/proc/self/fd/${selector_fd}" ] || return 0
    [ "$(stat -Lc '%d:%i' "/proc/self/fd/${selector_fd}" 2>/dev/null)" \
        = "$(stat -Lc '%d:%i' "${lock_file}" 2>/dev/null)" ] || return 0
    eval "exec ${selector_fd}>&-"
}

discard_restored_private_marker() {
    # Account backups materialize hardlinks as ordinary private files but may
    # copy the delivery marker. With no server configuration, discard only a
    # regular account-owned marker after a bounded walk proves the whole tree
    # contains no shared regular inode or unsafe node type. A restored
    # root-owned single-link file is private and npm can replace its directory
    # entry without gaining write access to the inode.
    # Any ambiguity leaves the marker in place for privileged fail-closed
    # unshare handling below.
    local modules="${venv_node_modules}"
    local marker="${modules}/.cl-shared-node-modules.json"
    local account_uid unsafe scan_status
    [ ! -e "${BUN_DELIVERY_CONF}" ] || return 1
    [ -d "${modules}" ] && [ ! -L "${modules}" ] || return 1
    [ -f "${marker}" ] && [ ! -L "${marker}" ] || return 1
    account_uid=$(id -u) || return 1
    [ "$(stat -c '%u' -- "${marker}" 2>/dev/null)" = "${account_uid}" ] \
        || return 1
    command -v timeout >/dev/null 2>&1 || return 1
    # Only a foreign-owned linked inode can be a store inode: with
    # fs.protected_hardlinks=1 the account cannot link a file it does not
    # own, so its own linked bytes (`cp -al`, `rsync -aH` restores) are
    # private however many links they carry.
    unsafe=$(timeout 5 find "${modules}" -xdev \
        \( \( -type f -links +1 ! -uid "${account_uid}" \) \
           -o \( ! -type d ! -type f ! -type l \) \) \
        -print -quit 2>/dev/null)
    scan_status=$?
    [ "${scan_status}" -eq 0 ] && [ -z "${unsafe}" ] || return 1
    rm -f -- "${marker}"
}

# A self-contained account restore has private bytes even when its copied
# telemetry marker says the source application used the Store. Retire that
# stale marker only after the private-tree proof above; a failed proof keeps
# the Shared Store-aware recovery path.
if [ ! -e "${BUN_DELIVERY_CONF}" ] \
        && [ -e "${venv_node_modules}/.cl-shared-node-modules.json" ]; then
    discard_restored_private_marker || true
fi

unverified_sharing_scan() {
    # The bounded scan could not answer and there is no marker. Failing
    # closed here would put every npm call of a markerless application on
    # the heavy path -- including root unshare and its budget -- on every
    # disabled-config host, and under LVE IO a large tree loses that race
    # routinely. Only a configuration that may enable delivery keeps the
    # conservative verdict.
    config_may_enable_delivery && return 0
    return 1
}

scan_shared_tree() {
    local modules="${CL_VIRTUAL_ENV}/lib/node_modules"
    local account_uid shared_file scan_status
    [ -e "${modules}/.cl-shared-node-modules.json" ] && return 0
    [ -d "${modules}" ] || return 1
    command -v timeout >/dev/null 2>&1 || { unverified_sharing_scan; return $?; }
    account_uid=$(id -u) || { unverified_sharing_scan; return $?; }
    # pnpm, `cp -l` and backup tools may create account-owned hardlinks.
    # Shared Store cache inodes stay foreign (normally root-owned), so only
    # those ambiguous links need privileged fail-closed recovery.
    shared_file=$(timeout 5 find "${modules}" -xdev -type f -links +1 \
        ! -uid "${account_uid}" \
        -print -quit 2>/dev/null)
    scan_status=$?
    [ "${scan_status}" -eq 0 ] || { unverified_sharing_scan; return $?; }
    [ -n "${shared_file}" ]
}

shared_tree_verdict=''

shared_tree_may_exist() {
    # One scan per process: the compatibility gate and the pre-unshare check
    # ask the same question, and the answer is a bounded walk of the whole
    # tree. Nothing between them changes this tree -- the app lock is held
    # before the second caller runs.
    if [ -z "${shared_tree_verdict}" ]; then
        scan_shared_tree
        shared_tree_verdict=$?
    fi
    return "${shared_tree_verdict}"
}

config_may_enable_delivery() {
    [ -e "${BUN_DELIVERY_CONF}" ] || return 1
    [ -r "${BUN_DELIVERY_CONF}" ] || return 0
    grep -q '"enabled"[[:space:]]*:[[:space:]]*true' "${BUN_DELIVERY_CONF}" 2>/dev/null || return 1
    return 0
}

is_bare_npm_ci_spelling() {
    case "${1:-}" in
        ci|clean-install|ic|install-clean|isntall-clean) return 0 ;;
    esac
    return 1
}

is_npm_ci() {
    is_bare_npm_ci_spelling "$1" && return 0
    case "${1:-}" in
        install-ci-test|cit|clean-install-test|sit) return 0 ;;
    esac
    return 1
}

expose_app_lockfiles_for_ci() {
    # `npm ci` installs exactly the pins of the application's lockfile, and
    # with the venv as its prefix npm reads that lockfile -- and its project
    # `.npmrc` -- from venv lib. When the application root has a regular npm
    # lockfile, venv lib names exactly it: a differing copy an earlier prefix
    # install wrote there describes the tree `npm ci` replaces. A linked
    # application lockfile is a historical layout whose target may be the
    # venv copy itself, so it is never followed into a deletion. Without a
    # regular one, npm reads what venv lib has -- the pins of the previous
    # install there -- or fails with its own error. The application's
    # regular `.npmrc` is linked the same way unless venv lib already has
    # one, which the account wrote and keeps.
    local name app_file venv_file app_has_lock=false
    for name in npm-shrinkwrap.json package-lock.json; do
        app_file="${HOME}/${CL_APP_ROOT}/${name}"
        [[ -f "${app_file}" && ! -L "${app_file}" ]] && app_has_lock=true
    done
    for name in npm-shrinkwrap.json package-lock.json; do
        [ "${app_has_lock}" = true ] || break
        app_file="${HOME}/${CL_APP_ROOT}/${name}"
        venv_file="${CL_VIRTUAL_ENV}/lib/${name}"
        [[ "${app_file}" -ef "${venv_file}" ]] && continue
        rm -f -- "${venv_file}" || return 1
        if [[ -f "${app_file}" && ! -L "${app_file}" ]]; then
            ln -s -- "${app_file}" "${venv_file}" || return 1
        fi
    done
    app_file="${HOME}/${CL_APP_ROOT}/.npmrc"
    venv_file="${CL_VIRTUAL_ENV}/lib/.npmrc"
    if [[ -f "${app_file}" && ! -L "${app_file}" \
          && ! -e "${venv_file}" && ! -L "${venv_file}" ]]; then
        ln -s -- "${app_file}" "${venv_file}" || return 1
    fi
}

ci_aside=''

set_aside_app_node_modules_for_ci() {
    # The self-healing half of the CLOS-7870 gap: `npm ci` run in the
    # application root before CLOS-7874 left a real node_modules there,
    # which every Selector install refuses. `npm ci` replaces node_modules
    # wholesale anyway, so that tree moves aside for the venv link; it is
    # removed once npm succeeded and put back if anything fails before.
    [[ -d "${app_node_modules}" && ! -L "${app_node_modules}" ]] || return 0
    # A SIGKILLed run (no trap) leaves its copy behind, and a later one may
    # get the same pid: never move a tree into it. The real directory then
    # stays, and the link check refuses as it always did.
    [[ ! -e "${app_node_modules}.cl-ci-$$" && ! -L "${app_node_modules}.cl-ci-$$" ]] \
        || return 0
    ci_aside="${app_node_modules}.cl-ci-$$"
    mv -T -- "${app_node_modules}" "${ci_aside}" || { ci_aside=''; return 1; }
    trap restore_app_node_modules_for_ci EXIT
}

restore_app_node_modules_for_ci() {
    [ -n "${ci_aside}" ] || return 0
    [ ! -L "${app_node_modules}" ] || rm -f -- "${app_node_modules}"
    mv -- "${ci_aside}" "${app_node_modules}"
}

run_legacy_npm() {
    # This is the exact pre-Shared-Store wrapper contract: only the historical
    # setup/list spellings target the Selector venv; everything else reaches
    # npm unchanged. Keep it in one helper so an absent and a disabled config
    # cannot drift apart again.
    #
    # Except `npm ci`: passed through, it replaced the application root's
    # node_modules link with a real directory, after which every Selector
    # install refused the application. It returns to take the locked setup
    # path below, as it does wherever the Store may be in use. Its test
    # variants stay with `install-test`, which this contract passes through.
    is_bare_npm_ci_spelling "${1:-}" && return 0
    if [[ "$*" =~ ^(install|i|add|list|la|ll)$ \
            || "$*" =~ ^(install|i|add|list|la|ll)[[:space:]].*$ ]]; then
        if [[ -L "${app_node_modules}" ]]; then
            rm -f "${app_node_modules}" \
                || { echo "Can't remove symlink ${app_node_modules}" >&2; exit 1; }
        elif [[ -d "${app_node_modules}" || -f "${app_node_modules}" ]]; then
            echo "${error_msg}" >&2
            exit 1
        fi
        mkdir -p "${venv_node_modules}" || exit 1
        ln -fs "${venv_node_modules}" "${app_node_modules}" || exit 1
        ln -sf "${HOME}/${CL_APP_ROOT}/package.json" \
            "${CL_VIRTUAL_ENV}/lib/package.json" || exit 1
        close_selector_lock_copy_for_npm
        exec "${nodejs_npm}" "$@" --prefix="${CL_VIRTUAL_ENV}/lib"
    fi
    close_selector_lock_copy_for_npm
    exec "${nodejs_npm}" "$@"
}

# Default-off compatibility is an explicit early branch.  On a server where
# delivery is not enabled and the application has no marker or shared inode,
# preserve the wrapper shipped before this feature: only setup/list commands
# target the Selector venv; every other npm command is an untouched exec. A
# config written only to change limits must not permanently wrap every app.
# Ambiguous or actually shared trees remain on the fail-closed recovery path.
if [ ! -e "${BUN_DELIVERY_CONF}" ] \
        && [ ! -e "${venv_node_modules}/.cl-shared-node-modules.json" ]; then
    run_legacy_npm "$@"
fi

# An administrator may publish a disabled config merely to tune limits. That
# must not opt every untouched application into the new lock/prepare path.
# Only a delivery marker or a foreign-owned linked inode needs the
# compatibility/recovery path below. Canonical Selector links and lockfiles
# predate Shared Store and must not opt an ordinary application into it.
if [ -e "${BUN_DELIVERY_CONF}" ] \
        && ! config_may_enable_delivery \
        && ! shared_tree_may_exist; then
    run_legacy_npm "$@"
fi

bun_delivery_state() {
    # 0 means ask the authenticated daemon for this account's policy, 1 means
    # the plain npm path because the global feature is off, and 2 means the
    # public configuration reader could not run. Account names are never
    # present in this CageFS-readable file.
    #
    # Refuse-on-error exists so a delivery that should happen is never
    # silently skipped -- which is a reason only where the config plausibly
    # says enabled. The file is written by cl-node-modules-storage alone,
    # so absent, or with no plain `"enabled": true` in it, is the fleet
    # that never turned the feature on: that is decided right here in
    # bash, and a broken interpreter or venv cannot turn every account's
    # `npm install` into a refusal there. Anything that *looks* enabled
    # goes to the authoritative reader (a nested "enabled" in an unknown
    # key must not enable the feature by grep), and only there does a
    # reader that cannot run refuse.
    [ -e "$1" ] || return 1
    grep -q '"enabled"[[:space:]]*:[[:space:]]*true' "$1" 2>/dev/null || return 1
    # Isolated mode keeps the account's environment and working directory
    # out of Python imports.
    "${NODE_MODULES_STORAGE_READER}" -Ibb -c '
import sys
try:
    from clselector import node_modules_storage
    config = node_modules_storage.read_public_config(sys.argv[1])
    applies = config["enabled"] and node_modules_storage.nodejs_selector_supported()
except Exception as error:
    sys.stderr.write("Node modules storage configuration error: %s\\n" % error)
    sys.exit(2)
sys.exit(0 if applies else 1)
' "$1"
}

bun_delivery_run() (
    lock_classic || return $?
    # A freshly created application has only <venv>/<major>/bin, and the
    # daemon finds the venv by its library directory: without this the very
    # first `npm install` over SSH is refused before anything is installed.
    # The classic and resolve paths create the same account-owned directory.
    mkdir -p "${CL_VIRTUAL_ENV}/lib" || return 1
    "${NODE_MODULES_STORAGE_PYTHON}" "${DELIVERY_CLIENT}" \
        policy "${CL_APP_ROOT}"
    policy_status=$?
    [ "${policy_status}" -eq 0 ] || return "${policy_status}"
    # A Store `npm ci` resolves the application's own lockfile.
    if [ "${CL_BUN_COMMAND:-}" = ci ]; then
        expose_app_lockfiles_for_ci || return 1
    fi
    # The phase keeps stdout clean for exactly these sentinels and says
    # nothing about them on stderr; bun's own diagnosis of a failed install
    # resolve goes to stderr and reaches the account unchanged.
    resolve_sentinel="$("${BUN_USER_PHASE}" resolve "${CL_APP_ROOT}")"
    resolve_status=$?
    # Exit 3 is the classic-npm route, but the phase also propagates bun's
    # own status: without the sentinel a future bun exit 3 would send every
    # account to classic npm silently. The code alone is not the contract.
    if [ "${resolve_status}" -eq 3 ] \
       && { [ "${resolve_sentinel}" = 'cl-bun:legacy-package-lock-v1' ] \
            || [ "${resolve_sentinel}" = 'cl-bun:unsupported-npm-shrinkwrap' ] \
            || [ "${resolve_sentinel}" = 'cl-bun:no-dependencies' ] \
            || case "${resolve_sentinel}" in
                 'cl-bun:unsupported-manifest '*) true ;;
                 *) false ;;
               esac; }; then
        return 3
    fi
    if [ "${resolve_status}" -ne 0 ] && [ "${CL_BUN_COMMAND:-}" = ci ]; then
        # A drifted, missing or unreadable lockfile is npm's to refuse, with
        # its own error -- or to install, where only bun could not.
        return 3
    fi
    if [ "${resolve_status}" -ne 0 ]; then
        # The phase has already printed bun's own diagnosis; name the phase
        # so the account knows npm never ran and the tree is unchanged.
        echo "Shared node_modules Store: resolve phase failed;" \
             "npm install was not run" 1>&2
        return 1
    fi
    # Delivery now keeps the old tree in its privileged transaction while it
    # invokes the account-side rebuild; only a successful rebuild commits.
    # The client's exit 3 is its own (never bun's): root refused a source or
    # package shape it does not serve with the tree untouched -- or answered
    # disabled/excluded after a policy change since the preflight -- and the
    # install takes the classic route. Like the no-dependencies sentinel,
    # that route must not leave resolve's native-build settings for a
    # rebuild that never runs. The client names such a reason on stdout; an
    # `npm ci` refused for one is remembered (ci_classic_remembered).
    deliver_reason="$("${BUN_USER_PHASE}" deliver "${CL_APP_ROOT}")"
    case $? in
        0)
            # Delivered: an earlier refusal's memo is stale.
            rm -f -- "${CI_CLASSIC_MEMO}"
            return 0
            ;;
        3)
            rm -f -- "${CL_VIRTUAL_ENV}/lib/.cl-bun-rebuild-env"
            if [ "${CL_BUN_COMMAND:-}" = ci ] && [ -n "${deliver_reason}" ]; then
                ci_input_hashes > "${CI_CLASSIC_MEMO}" \
                    || rm -f -- "${CI_CLASSIC_MEMO}"
            fi
            return 3
            ;;
    esac
    return 1
)

CI_CLASSIC_MEMO="${CL_VIRTUAL_ENV}/lib/.cl-bun-ci-classic"

ci_input_hashes() {
    # The Store configuration too: an allowlist change may serve what a
    # foreign registry made the Store refuse.
    sha256sum -- "${HOME}/${CL_APP_ROOT}/package-lock.json" \
        "${HOME}/${CL_APP_ROOT}/package.json" "${BUN_DELIVERY_CONF}" 2>/dev/null
}

ci_classic_remembered() {
    # An application whose lockfile the Store refused for good -- inexact,
    # a git or bundled dependency, a foreign registry -- would pay a
    # delivery from the account's budget on every `npm ci` and end at
    # rate_limited. The account-owned memo only narrows the account's own
    # Store use: while the lockfile and manifest hash as they did when root
    # refused them, `npm ci` goes to classic npm without asking the daemon.
    local hashes
    [ -f "${CI_CLASSIC_MEMO}" ] && [ ! -L "${CI_CLASSIC_MEMO}" ] || return 1
    hashes="$(ci_input_hashes)" || return 1
    [ "${hashes}" = "$(cat -- "${CI_CLASSIC_MEMO}" 2>/dev/null)" ]
}

bun_delivery_needs_unshare() {
    # A marker or linked inode proves Shared Store may have created this tree.
    # A bounded-scan failure stays shared/fail-closed only while the
    # configuration may enable delivery; see unverified_sharing_scan.
    shared_tree_may_exist
}

lock_classic() {
    # All setup and classic mutation paths take this descriptor before they
    # inspect or alter the app-root node_modules link. Direct SSH npm inherits
    # this wrapper-owned descriptor; a Selector child drops only its inherited
    # duplicate before npm because the Selector parent keeps the same OFD.
    #
    # CL_BUN_VIA=selector tells the daemon this install was Selector-driven
    # (the account's panel UI runs Selector as the account and lands here),
    # so it is counted as a panel install, not an SSH one. It is set only once
    # the descriptor proved to be this application's lock; an account that
    # forges it moves only the attribution of an install it still pays for.
    local lock_file
    lock_file="$(dirname "${CL_VIRTUAL_ENV}")/.lock"
    unset CL_BUN_VIA
    if [[ "${CL_SELECTOR_APP_LOCK_FD:-}" =~ ^[0-9]+$ ]] \
            && [ -e "/proc/self/fd/${CL_SELECTOR_APP_LOCK_FD}" ] \
            && [ "$(stat -Lc '%d:%i' "/proc/self/fd/${CL_SELECTOR_APP_LOCK_FD}" 2>/dev/null)" \
                 = "$(stat -Lc '%d:%i' "${lock_file}" 2>/dev/null)" ]; then
        bun_lock_fd="${CL_SELECTOR_APP_LOCK_FD}"
        flock -n "${bun_lock_fd}" \
            || { echo "application is locked by another operation" >&2; return 1; }
        export CL_BUN_LOCK_FD="${bun_lock_fd}"
        export CL_BUN_VIA=selector
        return 0
    fi
    exec {bun_lock_fd}>"${lock_file}" || return 1
    flock -n "${bun_lock_fd}" || { echo "application is locked by another operation" >&2; return 1; }
    export CL_BUN_LOCK_FD="${bun_lock_fd}"
}

lock_and_prepare_classic() {
    lock_classic || return $?
    # The common user phase canonicalizes unambiguous legacy prefix lockfiles
    # while this app lock is held. Divergent regular copies are preserved for
    # classic npm. Do this before scanning or asking root to unshare.
    classic_canonical_locks=""
    classic_prepare_needed=false
    if [ ! -e "${BUN_DELIVERY_CONF}" ] \
            && [ ! -e "${venv_node_modules}/.cl-shared-node-modules.json" ]; then
        return 0
    fi
    classic_prepare_needed=true
    "${BUN_USER_PHASE}" prepare-classic "${CL_APP_ROOT}" || return 1
    local lockfile app_lock venv_lock
    for lockfile in package-lock.json npm-shrinkwrap.json yarn.lock; do
        app_lock="${HOME}/${CL_APP_ROOT}/${lockfile}"
        venv_lock="${CL_VIRTUAL_ENV}/lib/${lockfile}"
        if [[ -L "${venv_lock}" && "$(readlink "${venv_lock}")" == "${app_lock}" ]]; then
            classic_canonical_locks="${classic_canonical_locks} ${lockfile}"
        fi
    done
}

finish_classic_lockfiles() {
    # npm writes prefix lockfiles with rename(2), replacing a canonical
    # symlink with a regular file. Only aliases observed after the locked
    # preflight are authoritative here; a historical divergent pair was not
    # an alias and remains untouched for classic compatibility.
    local lockfile app_lock venv_lock
    [ "${classic_prepare_needed:-false}" = true ] || return 0
    # Validate every observed alias before moving any npm output. A malformed
    # later lock must not leave an earlier app-root lock partly updated.
    for lockfile in ${classic_canonical_locks:-}; do
        app_lock="${HOME}/${CL_APP_ROOT}/${lockfile}"
        venv_lock="${CL_VIRTUAL_ENV}/lib/${lockfile}"
        if [[ -L "${venv_lock}" ]]; then
            [[ "$(readlink "${venv_lock}")" == "${app_lock}" ]] || return 1
        elif [[ -f "${venv_lock}" ]]; then
            [[ ! -L "${app_lock}" && ( ! -e "${app_lock}" || -f "${app_lock}" ) ]] || return 1
        elif [[ -e "${venv_lock}" ]]; then
            return 1
        elif [[ -e "${app_lock}" || -L "${app_lock}" ]]; then
            [[ -f "${app_lock}" && ! -L "${app_lock}" ]] || return 1
        fi
    done
    for lockfile in ${classic_canonical_locks:-}; do
        app_lock="${HOME}/${CL_APP_ROOT}/${lockfile}"
        venv_lock="${CL_VIRTUAL_ENV}/lib/${lockfile}"
        if [[ -L "${venv_lock}" ]]; then
            [[ "$(readlink "${venv_lock}")" == "${app_lock}" ]] || return 1
            continue
        fi
        if [[ -f "${venv_lock}" ]]; then
            [[ ! -L "${app_lock}" && ( ! -e "${app_lock}" || -f "${app_lock}" ) ]] || return 1
            mv -f -- "${venv_lock}" "${app_lock}" || return 1
            ln -s -- "${app_lock}" "${venv_lock}" || return 1
        elif [[ -e "${venv_lock}" ]]; then
            return 1
        elif [[ -f "${app_lock}" && ! -L "${app_lock}" ]]; then
            ln -s -- "${app_lock}" "${venv_lock}" || return 1
        elif [[ -e "${app_lock}" || -L "${app_lock}" ]]; then
            return 1
        fi
    done
    "${BUN_USER_PHASE}" prepare-classic "${CL_APP_ROOT}"
}

bun_unshare_before_classic() {
    # A classic npm mutation and the optional unshare are one transaction:
    # take the same app lock before even scanning, then let exec npm inherit
    # this descriptor. This keeps Selector/delivery from swapping the tree
    # while npm changes it.
    lock_and_prepare_classic || return $?
    unshare_after_classic_lock
}

unshare_after_classic_lock() {
    bun_delivery_needs_unshare || return 0
    "${NODE_MODULES_STORAGE_PYTHON}" "${DELIVERY_CLIENT}" unshare "${CL_APP_ROOT}"
}

validate_setup_node_modules_link() {
    if [[ -L "${app_node_modules}" ]]; then
        if [[ "$(readlink "${app_node_modules}")" != "${venv_node_modules}" ]]; then
            echo "${error_msg}" >&2
            return 1
        fi
    elif [[ -e "${app_node_modules}" ]]; then
        echo "${error_msg}" >&2
        return 1
    fi
}

ensure_setup_node_modules_link() {
    validate_setup_node_modules_link || return $?
    if [[ ! -e "${app_node_modules}" && ! -L "${app_node_modules}" ]]; then
        mkdir -p "${venv_node_modules}" || return 1
        ln -s "${venv_node_modules}" "${app_node_modules}" || return 1
    fi
    ln -sf "${HOME}/${CL_APP_ROOT}/package.json" "${CL_VIRTUAL_ENV}/lib/package.json"
}

is_npm_command_name() {
    # npm accepts configuration flags before its command. Keep this list
    # broad enough to distinguish an option value from the command that
    # follows it; only the mutation classifiers below change behavior.
    case "${1:-}" in
        access|add|adduser|audit|bugs|cache|ci|cit|clean-install|clean-install-test|completion|config|ddp|dedupe|deprecate|diff|dist-tag|docs|doctor|edit|exec|explain|explore|find-dupes|fund|get|help|help-search|hook|i|ic|in|init|ins|inst|insta|instal|install|install-ci-test|install-clean|install-test|isnt|isnta|isntal|isntall|isntall-clean|it|la|link|list|ll|ln|login|logout|ls|org|outdated|owner|pack|ping|pkg|prefix|profile|prune|publish|query|rb|r|rebuild|remove|repo|restart|rm|root|run|run-script|s|se|search|set|shrinkwrap|sit|star|stars|start|stop|t|team|test|token|tst|un|uninstall|unlink|unpublish|unstar|up|update|upgrade|udpate|version|view|whoami|x)
            return 0
            ;;
    esac
    return 1
}

npm_option_takes_value() {
    # Options whose separate value may itself look like an npm command need
    # an explicit entry. Other unknown leading options are handled below by
    # consuming a following non-command token as their probable value.
    case "${1:-}" in
        -C|-w|--access|--before|--cache|--cafile|--cert|--cpu|--fetch-retries|--fetch-retry-factor|--fetch-retry-maxtimeout|--fetch-retry-mintimeout|--fetch-timeout|--https-proxy|--include|--install-strategy|--key|--libc|--location|--loglevel|--maxsockets|--node-options|--omit|--otp|--os|--prefix|--proxy|--registry|--scope|--script-shell|--tag|--userconfig|--workspace)
            return 0
            ;;
    esac
    return 1
}

classify_npm_command_args() {
    # Preserve the original argv for npm. Strip only leading global options
    # from the copy used to decide whether the invocation mutates the tree.
    local option
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --)
                shift
                break
                ;;
            --*=*|-?=*)
                shift
                ;;
            -*)
                option=$1
                shift
                if npm_option_takes_value "${option}"; then
                    [ "$#" -gt 0 ] && shift
                elif [ "$#" -gt 0 ] && [[ "$1" != -* ]] \
                        && ! is_npm_command_name "$1"; then
                    # npm configuration keys may be supplied as
                    # `--key value`. An unknown non-command token is the
                    # value; continue looking for the actual command.
                    shift
                fi
                ;;
            *)
                break
                ;;
        esac
    done
    npm_classification_args=("$@")
}

inspect_npm_mode_options() {
    # These options change whether npm can touch this application's
    # node_modules at all. Inspect the original argv up to `--`; unlike the
    # command classifier, this does not need to remove or reorder anything.
    npm_global_mode=false
    npm_preview_mode=false
    npm_lockfile_only=false
    npm_explicit_prefix=false
    # npm takes the last prefix it is given; so do we, or an account could
    # name this venv first and something else last and be handled as this
    # application while npm installed elsewhere (or the reverse).
    npm_prefix_value=
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --)
                break
                ;;
            -g|--global|--global=true|--location=global)
                npm_global_mode=true
                ;;
            --location)
                shift
                if [ "${1:-}" = global ]; then
                    npm_global_mode=true
                fi
                ;;
            --prefix|-C)
                npm_explicit_prefix=true
                shift
                npm_prefix_value="${1:-}"
                ;;
            --prefix=*|-C=*)
                npm_explicit_prefix=true
                npm_prefix_value="${1#*=}"
                ;;
            --dry-run|--dry-run=true|--help|--help=true|-h|-\?)
                npm_preview_mode=true
                ;;
            --package-lock-only|--package-lock-only=true)
                npm_lockfile_only=true
                ;;
        esac
        [ "$#" -gt 0 ] && shift
    done
}

npm_prefix_targets_application() {
    # An explicit prefix that resolves to this application's venv IS this
    # application, whatever npm calls it: `npm install --prefix
    # ~/nodevenv/app/22/lib pkg` mutates exactly the tree a bare install
    # does. Treating every explicit prefix as "somewhere else" let that
    # through with no application lock, no unshare of a still-shared tree,
    # and a marker left claiming the tree was intact.
    local prefix="${1:-}" resolved venv_resolved
    [ -n "${prefix}" ] || return 1
    [ -n "${CL_VIRTUAL_ENV:-}" ] || return 1
    # `realpath` is coreutils and is in every cage. If it is not, we cannot
    # tell the two apart, and the safe answer is the locked application path
    # rather than an unprotected mutation of a possibly shared tree.
    resolved="$(realpath -m -- "${prefix}" 2>/dev/null)" || return 0
    venv_resolved="$(realpath -m -- "${CL_VIRTUAL_ENV}" 2>/dev/null)" || return 0
    [ -n "${resolved}" ] && [ -n "${venv_resolved}" ] || return 0
    case "${resolved}" in
        "${venv_resolved}"|"${venv_resolved}"/*)
            return 0
            ;;
    esac
    return 1
}

is_setup_command() {
    case "${1:-}" in
        install|i|in|ins|inst|insta|instal|isnt|isnta|isntal|isntall|add|ci|clean-install|ic|install-clean|isntall-clean|install-test|it|install-ci-test|cit|clean-install-test|sit)
            return 0
            ;;
    esac
    return 1
}

is_read_only_setup_command() {
    case "${1:-}" in
        list|la|ll) return 0 ;;
    esac
    return 1
}

is_shared_node_modules_install() {
    case "${1:-}" in
        install|i) [ "$#" -eq 1 ]; return ;;
    esac
    is_bare_npm_ci_spelling "${1:-}" || return 1
    shift
    # Output-only flags change nothing the delivery installs; npm still gets
    # them unchanged whenever the route ends on classic npm.
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --no-audit|--no-fund|--silent|--quiet|--no-progress|--progress=false|--loglevel=*) ;;
            --loglevel) shift; [ "$#" -gt 0 ] || return 1 ;;
            *) return 1 ;;
        esac
        shift
    done
}

is_classic_npm_mutation() {
    case "${1:-}" in
        install|i|in|ins|inst|insta|instal|isnt|isnta|isntal|isntall|add|ci|clean-install|ic|install-clean|isntall-clean|install-test|it|install-ci-test|cit|clean-install-test|sit|update|upgrade|up|udpate|uninstall|unlink|remove|rm|r|un|prune|dedupe|ddp|link|ln|rebuild|rb)
            return 0
            ;;
        audit)
            local audit_arg
            shift
            for audit_arg in "$@"; do
                case "$audit_arg" in
                    fix|--fix) return 0 ;;
                esac
            done
            return 1
            ;;
    esac
    return 1
}

# Only the Store route below exports CL_BUN_COMMAND=ci; an inherited value
# must not narrow anything else.
unset CL_BUN_COMMAND
classify_npm_command_args "$@"
inspect_npm_mode_options "$@"

# Global npm and a prefix outside this venv have a different target; help/dry-run
# does not change this application tree. Preserve npm's original argv and avoid
# any application lock, unshare, marker, link, or prefix side effect.
npm_explicit_application_prefix=false
if [ "${npm_explicit_prefix}" = true ] \
        && npm_prefix_targets_application "${npm_prefix_value}"; then
    npm_explicit_application_prefix=true
fi
if [ "${npm_global_mode}" = true ] \
        || [ "${npm_preview_mode}" = true ] \
        || { [ "${npm_explicit_prefix}" = true ] \
             && [ "${npm_explicit_application_prefix}" = false ]; }; then
    close_selector_lock_copy_for_npm
    exec "${nodejs_npm}" "$@"
fi

# Everything below runs npm against this application. It normally names the
# venv library as the prefix itself; when the account already named a prefix
# inside this venv, npm keeps the argv it was given -- appending a second
# --prefix would silently retarget theirs.
npm_prefix_args=(--prefix="${CL_VIRTUAL_ENV}/lib")
if [ "${npm_explicit_application_prefix}" = true ]; then
    npm_prefix_args=()
fi

# package-lock-only changes dependency metadata but not node_modules. Keep it
# serialized with delivery and canonicalize unambiguous legacy lock inputs,
# but do not detach a shared tree or invalidate its marker.
if [ "${npm_lockfile_only}" = true ] \
        && is_classic_npm_mutation "${npm_classification_args[@]}"; then
    lock_and_prepare_classic || exit $?
    # npm resolves the selected prefix from venv lib, so expose the same
    # application manifest as the ordinary setup path without creating or
    # detaching node_modules.
    ln -sf "${HOME}/${CL_APP_ROOT}/package.json" \
        "${CL_VIRTUAL_ENV}/lib/package.json" || exit 1
    close_selector_lock_copy_for_npm
    "${nodejs_npm}" "$@" "${npm_prefix_args[@]}"
    npm_status=$?
    finish_classic_lockfiles
    prepare_status=$?
    if [ "${npm_status}" -ne 0 ]; then
        exit "${npm_status}"
    fi
    exit "${prepare_status}"
fi

# Commands that populate the venv have a common setup path. Only the two
# canonical bare install spellings and a bare `npm ci` (with its four
# aliases) can use Bun delivery; every other alias remains a classic npm
# mutation under the app lock.
if is_read_only_setup_command "${npm_classification_args[@]}"; then
    # Metadata queries can run while Selector owns the application flock.
    # They must not canonicalize inputs, alter links, or detach a tree.
    close_selector_lock_copy_for_npm
    exec "${nodejs_npm}" "$@" "${npm_prefix_args[@]}"
elif is_setup_command "${npm_classification_args[@]}"; then
    # A bare `install`/`i` is the whole tree, which is what the cache can
    # serve, and so is a bare `npm ci` when root proves it exact. Named
    # packages, uninstall and update change package.json first and stay on
    # the plain path, like any argument.
    if is_shared_node_modules_install "$@"; then
        bun_delivery_state "${BUN_DELIVERY_CONF}"
        delivery_state=$?
        case "${delivery_state}" in
            0)
                # Resolve and the client deliver `npm ci` exactly or not at
                # all.
                is_npm_ci "$1" && export CL_BUN_COMMAND=ci
                if [ "${CL_BUN_COMMAND:-}" != ci ] || ! ci_classic_remembered; then
                    bun_delivery_run
                    delivery_status=$?
                    [ "${delivery_status}" -eq 3 ] || exit "${delivery_status}"
                fi
                ;;
            1)
                ;;
            *)
                echo "Node modules storage could not be checked; refusing to run npm." 1>&2
                exit "${delivery_state}"
                ;;
        esac
    fi

    if is_classic_npm_mutation "${npm_classification_args[@]}"; then
        lock_and_prepare_classic || exit $?
        if is_npm_ci "${npm_classification_args[@]}"; then
            set_aside_app_node_modules_for_ci || exit 1
        fi
        validate_setup_node_modules_link || exit $?
        # npm 10's `ci` removes every entry of node_modules before it
        # installs and never writes into an existing inode: copying a
        # delivered tree private first would only feed npm's removal. A
        # refusal before that removal leaves the tree shared and its marker
        # valid; a failure part-way leaves the marker, which sends the next
        # mutation through the unshare below (shared_tree_may_exist).
        if ! is_npm_ci "${npm_classification_args[@]}"; then
            unshare_after_classic_lock || exit $?
            invalidate_shared_node_modules_marker
        fi
    else
        lock_classic || exit $?
    fi
    ensure_setup_node_modules_link || exit $?
    if is_npm_ci "${npm_classification_args[@]}"; then
        expose_app_lockfiles_for_ci || exit 1
    fi
    # With no existing lock input npm must create a regular prefix lock:
    # npm 10 rejects a dangling package-lock symlink. Keep this shell (and
    # therefore the application lock) around long enough to move the new
    # lock to the canonical app root and replace it with the venv alias.
    close_selector_lock_copy_for_npm
    "${nodejs_npm}" "$@" "${npm_prefix_args[@]}"
    npm_status=$?
    if [ "${npm_status}" -eq 0 ] && [ -n "${ci_aside}" ]; then
        rm -rf -- "${ci_aside}"
        ci_aside=''
    fi
    if [ "${npm_status}" -eq 0 ] && is_npm_ci "${npm_classification_args[@]}"; then
        invalidate_shared_node_modules_marker
    fi
    finish_classic_lockfiles
    prepare_status=$?
    if [ "${npm_status}" -ne 0 ]; then
        exit "${npm_status}"
    fi
    exit "${prepare_status}"
else
    if is_classic_npm_mutation "${npm_classification_args[@]}"; then
        bun_unshare_before_classic || exit $?
        invalidate_shared_node_modules_marker
    fi
    # npm invoked from the application root may replace its node_modules
    # symlink while updating a now-private delivered tree. Preserve only the
    # selector's exact link by making the venv library the npm prefix; a
    # user-created real directory (or another link) remains untouched.
    if is_classic_npm_mutation "${npm_classification_args[@]}" \
          && [[ -L "${app_node_modules}" \
          && "$(readlink "${app_node_modules}")" == "${venv_node_modules}" ]]; then
        close_selector_lock_copy_for_npm
        "${nodejs_npm}" "$@" "${npm_prefix_args[@]}"
        npm_status=$?
        finish_classic_lockfiles
        prepare_status=$?
        if [ "${npm_status}" -ne 0 ]; then
            exit "${npm_status}"
        fi
        exit "${prepare_status}"
    fi
    close_selector_lock_copy_for_npm
    exec "${nodejs_npm}" "$@"
fi
cl.python/yum_cache.dat000064400000000240152725704130011113 0ustar00alt-python27
alt-python33
alt-python34
alt-python35
alt-python36
alt-python37
alt-python38
alt-python39
alt-python310
alt-python311
alt-python312
alt-python313
cl.python/selector.json000064400000000076152725704130011206 0ustar00{
    "default_version": "3.7",
    "selector_enabled": true
}lvemanager-config.json000064400000000452152725704130011032 0ustar00{"ui_config": {"inodeLimits": {"showUserInodesUsage": false}, "uiSettings": {"hideRubyApp": false, "hideLVEUserStat": false, "hidePythonApp": false, "hideNodeJsApp": false, "hidePHPextensions": false, "hideDomainsTab": false, "hidePhpApp": false, "hideXrayApp": true, "hideAccelerateWPApp": true}}}