Your IP : 216.73.216.218


Current Path : /opt/cloudlinux/venv/lib/python3.11/site-packages/clcagefslib/webisolation/
Upload File :
Current File : //opt/cloudlinux/venv/lib/python3.11/site-packages/clcagefslib/webisolation/jail_config_builder.py

#!/opt/cloudlinux/venv/bin/python3 -sbb
# -*- coding: utf-8 -*-
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2025 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT
#
"""
Builder for website isolation jail mount configurations.

Collects user docroots and isolation settings, then generates
the complete jail mount configuration.
"""
import logging
import os
from pathlib import Path

from clcommon import ClPwd
from clcommon.cpapi import userdomains
from clcommon.cpapi.cpapiexceptions import NoPanelUser

from ..io import write_via_tmp
from . import config, jail_utils
from .docroot_validation import validate_docroot, validate_docroot_no_symlinks
from .jail_config import MountConfig
from .mount_config import IsolatedRootConfig
from .mount_ordering import build_docroot_tree, process_ordered_mounts
from .mount_types import MountType

# CLOS-6824: how many sibling aliases we will mirror for one docroot.
#
# Every alias becomes a mount entry, and jail.c fails the whole isolation
# setup for the user past DOMAIN_MOUNT_MAX_ENTRIES (8192) - so an unbounded
# count would let a tenant drop their own site back to account-level PHP by
# minting links. The cap makes the blast radius a constant; no panel emits
# more than one alias, so it is never reached in practice.
DOCROOT_ALIAS_MAX = 8

# Names panels are known to emit, taken FIRST when the cap has to choose.
#
# Not a gate: any sibling symlink resolving to the docroot is mirrored, since
# jail.c matches by shape and would otherwise match an alias this side never
# bound. This list only decides who survives a cap that only a tenant can
# trigger - without it, alphabetically earlier tenant-minted links evict the
# panel's own alias and bring back the very 500 this change removes.
DOCROOT_ALIAS_PRIORITY_NAMES = ("private_html", "httpsdocs")


def _docroot_symlink_aliases(docroot: str, homedir: str) -> list:
    """Sibling symlinks that resolve to ``docroot``.

    A panel may serve the same directory under a second name:
    DirectAdmin's classic layout points :443 at `private_html`, a symlink
    to `public_html`; Plesk's uses `httpsdocs`. lve-kmod jail.c matches
    such a request to the registered site, but the isolation skeleton
    binds only the REGISTERED docroot - so without an entry here the
    alias path does not exist inside the namespace and the request fails
    to open its own files (PHP "Failed opening required" -> HTTP 500)
    after the correct per-domain PHP has already been applied.

    Matched by SHAPE, not by name. jail.c resolves the request docroot's
    final component and accepts the result only when it is a sibling of
    the path as given, so the set it can match is exactly the set
    enumerated here. Keying on a fixed list of names instead would leave
    jail.c matching a request this side never mirrored - the 500 above,
    and strictly worse than the account-level fallback it replaced.

    Only a symlink whose target IS this registered docroot qualifies.
    A real directory of the same name is a genuinely distinct document
    root and must keep its own content; a symlink aimed anywhere else
    must keep missing, which is the CLOS-3655 boundary the matching-side
    fallback also honours - resolution may SELECT an already-registered
    site, never carry an unregistered path into isolation.

    That equality is also what confines the target to a DIRECTORY INSIDE
    THE USER'S HOME, so it is worth saying where the property comes from
    rather than leaving it implicit: this function never learns the home.
    By the time a docroot reaches the builder, write_jail_mounts_config()
    has run validate_docroot() and validate_docroot_no_symlinks(docroot,
    user_home) over it and dropped it otherwise - an O_NOFOLLOW walk from
    the resolved home that asserts, per component, no symlink and
    S_ISDIR. The docroot is therefore a real directory under the home,
    reached without traversing a link, and accepting only paths equal to
    it carries all of that across. A link onto a file, a dead path, the
    home itself, or anything outside the home resolves to something else
    and is dropped.

    The chain MAY leave the home as long as it ends on the docroot
    (`alias -> <hop outside the home> -> docroot`). Harmless: the alias is never
    dereferenced. The bind source is the validated docroot string and the
    mount target is composed from the docroot's own parent, so the link
    contributes a basename and nothing more - and mount_alias() rejects
    any composed target that escapes the overlay root regardless.

    The tenant owns this directory, so the listing is treated as
    untrusted: confined to ``homedir``, capped at DOCROOT_ALIAS_MAX,
    ordered so the same tree always yields the same config, and each name
    run through validate_docroot(). That last one matters - a name
    carrying a config metacharacter would otherwise reach the writer,
    where _reject_jail_config_metachars() raises and takes the user's
    OTHER sites down with it; skipping it here keeps the damage to the
    alias.

    The home confinement is checked here rather than inherited from the
    docroot. It holds automatically while the docroot is a strict
    descendant of the home - the scan is then inside the home - but NOT
    when the docroot IS the home: the scan moves to the home's parent,
    where the siblings are other accounts' homes. Those cannot be
    mirrored, mount_alias() rightly refuses the escaping target, and the
    refusal is a raise - so returning them would cost the user their
    whole isolation config over an alias that was never usable.

    Ordering puts DOCROOT_ALIAS_PRIORITY_NAMES first so a flood of
    tenant-minted links cannot push the panel's own alias out of the cap.

    A tenant can retarget or add a link after this runs. That is
    harmless: the bind SOURCE is always this already-validated docroot,
    so a swap can only change whether an alias is mirrored, never what
    gets bound. The alias contributes a name inside the tenant's own
    skeleton, not content.
    """
    parent = os.path.dirname(docroot.rstrip("/"))
    if not parent or parent == "/":
        return []

    # Lexical against BOTH home spellings, exactly as
    # _source_looks_tenant_writable() does. validate_docroot_no_symlinks()
    # deliberately accepts an operator-symlinked prefix (/home -> /home2)
    # unresolved, so a site can be registered under either spelling while
    # pw_dir carries the other. Confining against one of them silently
    # empties this list on that layout - and jail.c would still match the
    # request, so the alias path would be missing inside the namespace and
    # the HTTPS request would 500 after the site match succeeded.
    home_prefixes = tuple(
        p.rstrip("/") + "/" for p in (homedir, os.path.realpath(homedir))
    )
    if not any((parent + "/").startswith(p) for p in home_prefixes):
        return []

    target = os.path.realpath(docroot)
    try:
        with os.scandir(parent) as entries:
            candidates = sorted(
                (entry.path for entry in entries if entry.is_symlink()),
                key=lambda p: (
                    os.path.basename(p) not in DOCROOT_ALIAS_PRIORITY_NAMES,
                    p,
                ),
            )
    except OSError:
        return []

    aliases = []
    for candidate in candidates:
        if not any(candidate.startswith(p) for p in home_prefixes):
            continue
        # The docroot itself cannot be its own alias. Unreachable while
        # registration refuses symlinked docroots, but the bind would be
        # nonsense if that ever changed, so do not rely on it from here.
        if candidate == docroot:
            continue
        try:
            if os.path.realpath(candidate) != target:
                continue
            validate_docroot(candidate)
        except (OSError, ValueError):
            continue
        aliases.append(candidate)
        if len(aliases) == DOCROOT_ALIAS_MAX:
            logging.warning(
                "Docroot %s has more than %d alias symlinks; mirroring the "
                "first %d into the isolated view",
                docroot, DOCROOT_ALIAS_MAX, DOCROOT_ALIAS_MAX,
            )
            break
    return aliases


class JailMountsConfigBuilder:
    """
    Builder for generating jail mount configuration files.

    Collects docroots and isolation settings, then generates
    the mount configuration string for the jail.c implementation.
    """

    def __init__(self, user: str):
        self.user = user
        self._all_docroots: set[str] = set()
        self._isolated_docroots: set[str] = set()
        self._phpselector_docroots: set[str] = set()
        # F-14 (CLOS-5952) DiD bookkeeping: overlay paths whose
        # symlink-safety was verified during build(). Kept so callers
        # can re-run validation immediately before persisting the
        # generated config to disk (see revalidate() below and its
        # call site in write_jail_mounts_config).
        self._validated_paths: list[tuple[str, str, str]] = []

    def add_docroot(self, docroot: str) -> None:
        """Register a docroot path for the user."""
        self._all_docroots.add(docroot)

    def enable_isolation(self, docroot: str) -> None:
        """Mark a docroot as requiring isolation."""
        self._isolated_docroots.add(docroot)

    def enable_phpselector(self, docroot: str) -> None:
        """Enable per-domain PHP selector for a docroot."""
        self._phpselector_docroots.add(docroot)

    def build(self) -> str:
        """
        Generate the complete mount configuration.

        Returns:
            Configuration string in jail.c mount syntax.
        """
        pw = ClPwd().get_pw_by_name(self.user)
        homedir = pw.pw_dir
        uid, gid = pw.pw_uid, pw.pw_gid

        # Build docroot tree once for all isolated docroots
        tree = build_docroot_tree(self._all_docroots)

        # ~/.clwpos becomes a BIND source under the root-run jail mounter
        # (see mount_config.py:47 -> isolates.mounts -> bind(2) with
        # MS_BIND, which dereferences symlinks on the source). The tenant
        # owns their home and can replace .clwpos with a symlink aimed at
        # / or another tenant's home, escaping the isolated tree. Refuse
        # to bind .clwpos when the O_NOFOLLOW component walk under the
        # resolved homedir rejects any path component as a symlink or
        # a non-directory; skipping this single entry is safe (it only
        # exposes the AWP redis.sock) and leaves the rest of isolation
        # intact for the user. Same validator as
        # validate_docroot_no_symlinks used for panel docroots.
        awp_path = f"{homedir}/.clwpos"
        try:
            validate_docroot_no_symlinks(awp_path, homedir)
            awp_path_safe = True
        except ValueError as exc:
            logging.warning(
                "Skipping .clwpos bind mount for user %s: %s", self.user, exc,
            )
            awp_path_safe = False

        # Generate config for each isolated docroot
        generated_configs = []
        for docroot in sorted(self._isolated_docroots, key=len):
            split_storage_base = jail_utils.full_website_path(homedir, docroot)

            # F-10 (CLOS-5397): the per-website overlay base
            # `<homedir>/.cagefs/websites/<hash>` and its two ancestors
            # (`.cagefs`, `websites`) are all created uid-owned mode 0o750
            # inside `drop_privileges(user)` by create_overlay_storage_directory
            # (jail_utils.py:_mkdir_nofollow_under's O_NOFOLLOW discipline
            # only pins the creation moment). The tenant can therefore
            # `rmdir` / `rm -rf` any component and replace it with a
            # symlink at any subsequent moment. The IsolatedRootConfig
            # below feeds `<split_storage_base>/home` (not the base
            # itself) to the root-run jail mounter as a bind(2) source
            # string, and MS_BIND dereferences symlinks on the source.
            # Validate BOTH the base AND the `/home` bind-source child
            # independently: `os.path.realpath` only canonicalises the
            # exact path it is given (with strict=False any non-existent
            # tail is left in place), so a guard on the base alone
            # misses a tenant-planted symlink at the `home` child
            # (`ln -s /etc <hash>/home`). Same realpath+prefix shape as
            # the .clwpos guard above; refuse the whole isolated
            # docroot when either the resolved storage base or the
            # resolved home-overlay source escapes the resolved home
            # tree (skipping isolation for this one docroot is safe -
            # the rest of the user's websites continue to be isolated).
            home_overlay_source = f"{split_storage_base}/home"
            try:
                validate_docroot_no_symlinks(split_storage_base, homedir)
                validate_docroot_no_symlinks(home_overlay_source, homedir)
            except ValueError as exc:
                logging.warning(
                    "Skipping isolated docroot %s for user %s: unsafe overlay storage path: %s",
                    docroot, self.user, exc,
                )
                continue
            # F-14 (CLOS-5952) DiD: remember the paths that passed the
            # symlink-safety check so write_jail_mounts_config() can
            # re-run the check immediately before persisting the
            # generated config. Shrinks the TOCTOU between config-gen
            # and root-side mount consumption; a full close of the
            # window requires the root-run jail mounter (jail.c) to
            # itself bind via openat2(RESOLVE_NO_SYMLINKS) / an
            # O_PATH-pinned /proc/self/fd source — tracked as F-11
            # (CLOS-5949) and F-16 (CLOS-5954).
            self._validated_paths.append(
                (docroot, split_storage_base, home_overlay_source)
            )

            home_overlay = IsolatedRootConfig(
                root_path=home_overlay_source, target=homedir, persistent=True
            )

            # Process ordered mounts for this isolated docroot
            docroot_mounts = process_ordered_mounts(
                active_docroot=docroot, tree=tree, uid=uid, gid=gid
            )

            jail_config = MountConfig(uid=uid, gid=gid)

            # Add storage for the overlay'ed dir
            jail_config.add_overlay(home_overlay)

            # open .clwpos directory to make redis.sock available
            if awp_path_safe:
                home_overlay.mount(MountType.BIND, awp_path, awp_path, ("mkdir",))

            # CLOS-6889 / CLOS-4384: materialise a PER-SITE `tmp` directory
            # inside the isolated view at /home/USER/tmp so PHP session
            # storage keyed on that path works out of the box.
            #
            # DirectAdmin ships `php_home_tmp_session_save_path=1` as a
            # built-in default (absent from directadmin.conf; `da c` reports
            # it as 1 on a fresh install) and emits a per-vhost
            # `php_value session.save_path '/home/USER/tmp'` for every
            # site under the lsphp/mod_lsapi and mod_fcgid handlers - so
            # enabling Website Isolation on ANY DirectAdmin account breaks
            # PHP sessions on the isolated domain at first use, with no
            # user misconfiguration. Same failure hits the cPanel opt-in
            # from CLOS-4384 (PHP Selector -> session.save_path=/home/USER/tmp),
            # since the home overlay hides everything under /home/USER/
            # that is not .clwpos or the active docroot. Apache's
            # <Directory>-scoped `php_value` also outranks the per-site
            # alt_php.ini PHP Selector writes, so a PHP-Selector-only fix
            # cannot help DirectAdmin.
            #
            # Emitted as a self-bind onto <split_storage_base>/home/tmp,
            # the same shape MountConfig.add_overlay uses for the home
            # overlay root itself. Both spellings resolve to the same
            # per-site directory inside the overlay skeleton, so the
            # jail mounter's `mkdir` opt materialises the dir owned by
            # the tenant, and PHP writes to it land there directly. Path
            # is per docroot hash (the overlay itself is per-site), so a
            # session file written by site A lives at
            # <split_storage_base_A>/home/tmp/ and is not visible to
            # site B under the same UID from inside its own isolate -
            # matches the isolation contract of the docroot mount itself
            # and closes the accidental cross-site session-fixation
            # window that binding the real /home/USER/tmp would otherwise
            # open. The account-level /home/USER/tmp on the host is left
            # untouched for cron / non-isolated consumers.
            #
            # `mkdir,uid=USER,gid=USER,mode=0700`: the jail mounter creates
            # the per-site tmp inside the overlay owned by the tenant
            # with private mode. Same shape the home overlay itself uses
            # (add_overlay renders mkdir,uid,gid,mode=0750); 0o700
            # matches the account-level /home/USER/tmp mode DirectAdmin
            # creates for that dir.
            #
            # Refuse - same skip-and-warn as the .clwpos guard above -
            # if the tenant planted a symlink at the tmp path or any
            # ancestor under the resolved home: the source is fed to a
            # bind(2) at the root-run jail mounter and MS_BIND
            # dereferences symlinks. Skipping just this one entry is
            # safe (it regresses THIS site's sessions to the pre-fix
            # state) and leaves the rest of the isolation config intact.
            #
            # Both `validate_docroot_no_symlinks` (O_NOFOLLOW component
            # walk) AND `overlay_path` (which resolves via
            # `os.path.realpath` inside the overlay) go inside the same
            # try, so a symlink at the target is caught in ONE place
            # and translated into a skip-and-warn. Doing overlay_path
            # first would let the realpath dereference the symlink and
            # either raise `ValueError` past the try (aborting build()
            # for every site) or silently rewrite the overlay target
            # to the symlink's destination - so the mount would land
            # on a different overlay slot than the /home/USER/tmp path
            # PHP actually opens, and session_start() would still fail.
            # Validating the account-level path first pins the
            # decision: the leaf is either safe (no symlink) and both
            # calls see the same lexical path, or it is unsafe and
            # both calls are skipped.
            home_tmp_target = os.path.join(homedir, "tmp")
            try:
                validate_docroot_no_symlinks(home_tmp_target, homedir)
                home_tmp_in_overlay = home_overlay.overlay_path(home_tmp_target)
            except ValueError as exc:
                logging.warning(
                    "Skipping /home/USER/tmp bind mount for user %s: %s",
                    self.user, exc,
                )
            else:
                home_overlay.mount(
                    MountType.BIND,
                    home_tmp_in_overlay,
                    home_tmp_target,
                    ("mkdir", f"uid={uid}", f"gid={gid}", "mode=0700"),
                )

            # Add docroot mounts (from tree processing)
            # Mount them into already created overlay
            for mount in docroot_mounts:
                home_overlay.mount(mount.type, mount.source, mount.target, mount.options)

            # CLOS-6824: give the registered docroot a second name at each
            # sibling symlink that resolves to it, so a request arriving
            # under the alias can open its files once isolation is entered.
            # Emitted here - after the docroot mounts above, before
            # close_overlay() flushes the overlay and binds it over the home
            # - and at registration time rather than per request, because the
            # namespace is cached per website id and shared by every request
            # for the site: whichever request builds it first fixes its
            # contents.
            #
            # The bind SOURCE is the docroot's mount inside the skeleton, not
            # the real directory, and the bind is RECURSIVE. The skeleton
            # mount is the isolated view - process_ordered_mounts() has
            # already blanked every other site's docroot nested under it with
            # a tmpfs, and those tmpfs mounts are its children. A plain bind
            # of the real docroot carries none of them (MS_BIND is not
            # recursive), so on a panel that nests one site inside another -
            # cPanel puts an addon domain at `public_html/<addon>` and ships
            # a `www -> public_html` link in every home - the alias would
            # reopen the other site's real content, unmasked and writable,
            # inside this site's jail.
            #
            # A single unusable alias must not cost the user their whole
            # isolation: overlay_path()/mount_alias() raise on a path that
            # escapes the overlay root, and uncaught that aborts build() so
            # NO config is written and every one of the user's sites drops to
            # account-level. Skip and warn, as the .clwpos and
            # overlay-storage guards above do.
            for alias in _docroot_symlink_aliases(docroot, homedir):
                try:
                    home_overlay.mount_alias(
                        MountType.BIND,
                        home_overlay.overlay_path(docroot),
                        alias,
                        ("mkdir", "recursive"),
                    )
                except ValueError as exc:
                    logging.warning(
                        "Skipping alias %s for docroot %s (user %s): %s",
                        alias, docroot, self.user, exc,
                    )

            # Apply mounts from isolated root and close target directory
            jail_config.close_overlay(home_overlay)

            # Home directory is already overlayed, we can apply per-domain mounts directly
            jail_config.add(MountType.USER_MOUNTS, "/")

            # php selector mounts (only when per-domain PHP selector is enabled)
            if docroot in self._phpselector_docroots:
                # CLOS-4351: bind the user-level cl.selector dir over
                # /usr/selector and /usr/selector.etc so per-domain symlinks
                # of the form `lsphp -> /usr/selector/lsphp` (written when
                # per-domain selector is 'native') resolve to the user's
                # account-default alt-php binary instead of the 0-byte
                # placeholder file in cagefs-skeleton. Done before the
                # /etc/cl.selector replacement below so the source string
                # still resolves to the user-level dir at mount time;
                # subsequent re-mounts of /etc/cl.selector do not disturb
                # the established /usr/selector mount.
                jail_config.add(
                    MountType.BIND, source="/etc/cl.selector",
                    target="/usr/selector"
                )
                jail_config.add(
                    MountType.BIND, source="/etc/cl.selector",
                    target="/usr/selector.etc"
                )
                jail_config.add(
                    MountType.BIND, source=f"/etc/cl.selector/{jail_utils.get_website_id(docroot)}",
                    target="/etc/cl.selector"
                )
                jail_config.add(
                    MountType.BIND, source=f"/etc/cl.php.d/{jail_utils.get_website_id(docroot)}",
                    target="/etc/cl.php.d"
                )

            # Override proxyexec token with website specific folder
            jail_config.add(
                MountType.BIND,
                source=f"/var/.cagefs/website/{jail_utils.get_website_id(docroot)}",
                target="/var/.cagefs",
            )

            generated_configs.append(jail_config.render(docroot))

        return "\n".join(generated_configs)

    def revalidate(self, homedir: str) -> None:
        """Re-run the symlink-safety check on every overlay path that
        passed validation during ``build()``.

        F-14 (CLOS-5952) DiD: paths under ``<homedir>/.cagefs/websites``
        are tenant-owned and can be swapped for a symlink between the
        check in ``build()`` and persistence. Re-run the walk here to
        shrink that window; the primary defence is jail.c's
        ``open_directory_nofollow`` on the C bind sink. Missing overlay
        / split-storage / `.clwpos` leaves are treated as safe (the C
        mounter materialises them via its ``mkdir`` opt); the symlink-
        at-leaf race is caught at the C sink regardless.

        Raises:
            ValueError: If any previously validated path now fails the
                symlink-safety check (symlink planted, or path escapes
                the tenant home).
        """
        for _docroot, split_storage_base, home_overlay_source in self._validated_paths:
            validate_docroot_no_symlinks(split_storage_base, homedir)
            validate_docroot_no_symlinks(home_overlay_source, homedir)


def write_jail_mounts_config(user: str, user_config: config.UserConfig | None) -> None:
    """
    Write or remove the jail mounts configuration file for a user.

    If user_config is None or has no enabled websites, the config file is removed.
    Otherwise, builds and writes the mount configuration.

    Args:
        user: Username to generate config for
        user_config: User's isolation configuration, or None to remove config
    """
    jail_config_path = Path(jail_utils.get_jail_config_path(user))

    if user_config is None or not user_config.enabled_websites:
        jail_config_path.unlink(missing_ok=True)
        return

    builder = JailMountsConfigBuilder(user)

    try:
        domain_to_docroot_map = dict(userdomains(user))
    except NoPanelUser:
        logging.warning("Cannot regenerate mount configuration, no panel user=%s", user)
        return

    # Resolved user home is the allowed prefix for the on-disk
    # symlink-rejection check below. Resolve here once so a benign
    # operator-installed symlink like /home -> /home2 does not produce
    # false negatives for every domain.
    user_home = ClPwd().get_pw_by_name(user).pw_dir

    # Defense-in-depth at the second trust boundary: docroot values come
    # back from the panel and flow straight into mount-line source/target
    # (mount_types.py) and the bracketed jail section header
    # (jail_config.py). Drop entries that do not pass the strict allowlist
    # so a single malformed panel record cannot corrupt the mount file.
    # Also drop entries whose on-disk path escapes the user's home - the
    # docroot becomes a BIND source (mount_ordering.py:120,127) consumed
    # by the root-run jail mounter, and MS_BIND dereferences symlinks
    # on the source. See validate_docroot_no_symlinks.
    for domain, docroot in list(domain_to_docroot_map.items()):
        try:
            validate_docroot(docroot)
            validate_docroot_no_symlinks(docroot, user_home)
        except ValueError as exc:
            logging.warning(
                "Skipping domain %s with invalid docroot for user %s: %s",
                domain, user, exc,
            )
            del domain_to_docroot_map[domain]

    # add docroot information for isolations
    for docroot in domain_to_docroot_map.values():
        builder.add_docroot(docroot)

    # add information about which websites should have isolation enabled
    for domain in user_config.enabled_websites:
        try:
            docroot = domain_to_docroot_map[domain]
        except KeyError:
            logging.warning("Docroot not found for domain %s", domain)
            continue

        builder.enable_isolation(docroot)

    # PHP Selector is enabled for all isolated websites
    for domain in user_config.enabled_websites:
        try:
            docroot = domain_to_docroot_map[domain]
        except KeyError:
            logging.warning("Docroot not found for domain %s", domain)
            continue

        builder.enable_phpselector(docroot)

    result = builder.build()

    # F-07 (CLOS-5945) DiD: revalidate every panel docroot ONCE MORE
    # at the moment the config is committed to disk, so the
    # check-then-write window inside this function is closed. The
    # earlier docroot sweep runs before builder.build() (~140 lines of
    # pure Python); a final sweep pins the last observed state right
    # before write_via_tmp. The true C-side check-then-mount gap is
    # handled by jail.c's openat2(RESOLVE_NO_SYMLINKS) bind path —
    # this Python sweep is defence-in-depth for early rejection.
    for domain, docroot in list(domain_to_docroot_map.items()):
        try:
            validate_docroot(docroot)
            validate_docroot_no_symlinks(docroot, user_home)
        except ValueError as exc:
            logging.warning(
                "Refusing to write mount config for user %s: docroot "
                "for domain %s became invalid before disk commit: %s",
                user, domain, exc,
            )
            return

    # F-14 (CLOS-5952) DiD: F-07 above covers panel docroots; this
    # sweep covers the overlay / split-storage / .clwpos bind sources
    # tracked separately inside the builder. If any of those was
    # swapped for a symlink between build() and here, refuse to
    # persist so the previously persisted safe config stays in place.
    try:
        builder.revalidate(user_home)
    except ValueError as exc:
        logging.warning(
            "Refusing to persist jail mounts config for user %s: overlay path changed after generation: %s",
            user, exc,
        )
        return

    jail_config_path.parent.mkdir(exist_ok=True, mode=0o755)
    write_via_tmp(str(jail_config_path.parent), str(jail_config_path), result)